Răsfoiți Sursa

Improved triage

Efren Yevale Varela 4 zile în urmă
părinte
comite
2997040b61

+ 11 - 6
docs/CONFIGURATION.md

@@ -136,13 +136,13 @@ confidence_field     = "answer_confidence"  # Gating confidence: "answer_confide
 escalate_threshold   = 0.6                  # noul answers at or above this p are flagged (⚠ marker only)
 choice_min_confidence = 0.85                # choice answers below this confidence are flagged (⚠ marker only)
 extract_enabled      = true                 # Master switch for the brief engine (plain-text brief update after each Laya pass)
-extract_max_tokens   = 3072                 # Brief reply token cap; the brief is then capped to brief_max_chars characters
+extract_max_tokens   = 3072                 # Brief reply token cap; a reply cut at this limit is rejected, never stored
 extract_timeout_s    = 30.0                 # Brief request timeout
 extract_temperature  = 0.2                  # Brief request temperature (deterministic extraction; chat keeps [llm].temperature)
 brief_slot_id        = ""                   # optional llama.cpp id_slot for the brief engine; empty/unset omits it (server needs -np 2)
 brief_gate_enabled   = true                 # fail-open gate: update the brief only when the top signal's p >= brief_gate_threshold
 brief_gate_threshold = 0.5                  # gate threshold; the top signal at/above this p triggers a brief update
-brief_max_chars      = 4000                 # display cap for the brief (truncated, never dropped) when over this
+brief_max_chars      = 4000                 # budget for the CURRENT BRIEF sent to the model; the accepted brief is never truncated
 brief_pending_char_budget = 3000            # pending transcript chars that force an immediate flush of a coalesced update
 ledger_max_facts     = 200                  # cap on code-owned durable facts; oldest are evicted (logged) past this
 fact_max_chars       = 240                  # per-fact character cap; over-long facts are truncated
@@ -266,10 +266,15 @@ it parses the reply, appends new facts with conservative dedupe, evicts oldest p
 as narrative + a `Prior Details:` section. A recorded fact can therefore only disappear via a
 logged cap eviction, never because the model dropped it. The model reply is bounded by
 `[triage].extract_max_tokens` (default 3072), `extract_timeout_s` (default 30.0), and
-`extract_temperature` (default 0.2, deterministic extraction; `[llm].temperature` still governs chat/command), and the rendered brief is
-capped to `brief_max_chars` characters. `NEW FACTS:` is emitted first so a length cutoff loses
-narrative (regenerated each pass) rather than a durable fact; the final fact line is discarded
-when the reply is truncated. Because the brief engine keeps no history,
+`extract_temperature` (default 0.2, deterministic extraction; `[llm].temperature` still governs chat/command).
+`brief_max_chars` bounds only the `CURRENT BRIEF` handed to the model on the next pass: the
+accepted brief is never truncated, and the prompt view is trimmed on a line boundary with an
+instruction to compact. A reply that hits the token limit is rejected outright (the prior
+narrative stays authoritative) and its `NEW FACTS` are still merged, so a mid-sentence cutoff
+can never become the stored brief. A rewrite that drops a canonical section the prior brief had
+is rejected the same way. `NEW FACTS:` is emitted first so a length cutoff loses narrative
+(regenerated each pass) rather than a durable fact; the final fact line is discarded when the
+reply is truncated. Because the brief engine keeps no history,
 `[llm].brief_compaction_system_prompt` and the `[llm]` compaction settings do not apply to it
 (they still govern chat/command).
 

+ 6 - 4
docs/TRIAGE.md

@@ -23,7 +23,7 @@ opt-in, so a plain `uv sync` and the existing test suite behave exactly as befor
   - A `⚠` mark follows any item the analyzer flagged.
 - **`content`** after a brief update: the plain-text brief arrives as a `content` stage and
   the panel appends it below the signals. The brief is written by the model, so the panel
-  shows it as-is (truncated only when it exceeds `brief_max_chars`).
+  shows it as-is; the accepted brief is never truncated.
 - **A reason line** while the lane is not `ok`, for example when the model is still loading
   or the `questions.json` file is missing. The panel never renders blank.
 - **A last-triage clock** as the first line whenever a real triage is shown: the server-side
@@ -314,13 +314,13 @@ file.
 | `escalate_threshold` | `0.6` | A `noul` answer at or above this `p` is flagged (`⚠` marker only). |
 | `choice_min_confidence` | `0.85` | A `choice` answer below this confidence is flagged (`⚠` marker only). |
 | `extract_enabled` | `true` | Master switch for the brief engine: with it false the panel classifies but no brief is requested. |
-| `extract_max_tokens` | `3072` | Token cap for the brief reply; the brief is then truncated to `brief_max_chars`. |
+| `extract_max_tokens` | `3072` | Token cap for the brief reply; a reply cut at this limit is rejected and the prior brief kept. |
 | `extract_timeout_s` | `30.0` | Timeout for the brief request. |
 | `extract_temperature` | `0.2` | Temperature for the brief request (deterministic extraction; chat keeps `[llm].temperature`). |
 | `brief_slot_id` | unset | Optional llama.cpp `id_slot` for the brief engine; when unset the `id_slot` field is omitted (needs a server started with `-np 2`). |
 | `brief_gate_enabled` | `true` | Fail-open gate: update the brief only when the `substantive_update` `p` is at least `brief_gate_threshold`. |
 | `brief_gate_threshold` | `0.5` | Gate threshold for `substantive_update`; the update runs at or above this `p`. |
-| `brief_max_chars` | `4000` | Display cap for the brief; over this it is truncated, never dropped. |
+| `brief_max_chars` | `4000` | Budget for the `CURRENT BRIEF` sent to the model; over this the prompt view is trimmed on a line boundary. The accepted brief is never truncated. |
 | `brief_pending_char_budget` | `3000` | Pending transcript-character budget before a coalesced update is flushed; the oldest deltas are dropped past it. |
 | `ledger_max_facts` | `200` | Cap on code-owned durable facts; the oldest are evicted (logged) past this. |
 | `fact_max_chars` | `240` | Per-fact character cap; over-long facts are truncated. |
@@ -336,7 +336,9 @@ narrative (`CURRENT BRIEF`), and the new segment. The model must return exactly
 volatile narrative). It never rewrites the ledger. Code appends new facts with conservative
 dedupe, evicts the oldest past `ledger_max_facts`, truncates each past `fact_max_chars`, and
 renders the final brief. The reply is bounded by `extract_max_tokens`, `extract_timeout_s`,
-and `extract_temperature` (default `0.2`), then the rendered brief is truncated to `brief_max_chars`. Because the brief engine keeps no
+and `extract_temperature` (default `0.2`). `brief_max_chars` bounds only the `CURRENT BRIEF` sent on the next
+pass, trimmed on a line boundary; a length-truncated reply or a rewrite that drops a canonical section is rejected
+and the prior narrative stays authoritative. Because the brief engine keeps no
 conversation history, `[llm].brief_compaction_system_prompt` and the `[llm]` compaction
 settings do not apply to it; they remain in effect for the chat/command session.
 

+ 50 - 18
src/stt_daemon/assistant/service.py

@@ -1147,6 +1147,32 @@ class AssistantService:
             return f"{narrative.rstrip()}\n\n{ledger_text}"
         return narrative or ledger_text
 
+    def _screen_narrative(self, prior, new, truncated):
+        """(narrative to accept or None, reject reason or None).
+
+        A reply cut at the token limit and a rewrite that dropped canonical
+        sections are both refused: the prior narrative stays authoritative rather
+        than being replaced by a shorter document.
+        """
+        if truncated:
+            return None, "truncated"
+        regressed = getattr(self._triage, "is_regression", None)
+        try:
+            if regressed is not None and regressed(prior, new):
+                return None, "regressed"
+        except Exception:  # noqa: BLE001 - screening must never stop the update
+            pass
+        guarded = new
+        restore = getattr(self._triage, "restore_missing_bullets", None)
+        try:
+            if restore is not None:
+                candidate = restore(new, prior)
+                if isinstance(candidate, str) and candidate.strip():
+                    guarded = candidate
+        except Exception:  # noqa: BLE001 - guard must never fail the update
+            guarded = new
+        return guarded, None
+
     def _run_brief_update(self, generation, signals, ts=None, token=None) -> None:
         if self._triage is None or self._brief_fn is None:
             if ts is not None:
@@ -1213,23 +1239,18 @@ class AssistantService:
             if not text:
                 requeued = True
                 return
-            parsed = self._triage.parse_ledger_reply(
-                text, truncated=bool(finish and finish[0] == "length")
-            )
+            truncated = bool(finish and finish[0] == "length")
+            parsed = self._triage.parse_ledger_reply(text, truncated=truncated)
             if parsed is None:
                 requeued = True
                 return
             new_narrative, new_facts = parsed
-            guarded = new_narrative
+            guarded = None
+            reject_reason = None
             if new_narrative:
-                try:
-                    restore = getattr(self._triage, "restore_missing_bullets", None)
-                    if restore is not None:
-                        candidate = restore(new_narrative, narrative)
-                        if isinstance(candidate, str) and candidate.strip():
-                            guarded = candidate
-                except Exception:  # noqa: BLE001 - guard must never fail the update
-                    guarded = new_narrative
+                guarded, reject_reason = self._screen_narrative(
+                    narrative, new_narrative, truncated
+                )
             with self._lock:
                 if generation != self._triage_generation:
                     return
@@ -1238,12 +1259,23 @@ class AssistantService:
                 added = 0
                 if self._fact_ledger is not None:
                     added = self._fact_ledger.merge(new_facts)
-                text = self._current_brief()
-                merged = {**(signals or {}), "brief": text}
-                self.last_triage = merged
-                if self._brief_file is not None:
-                    self._brief_file.write(text)
-                self._emit(TRIAGE_EVENT, stage="content", triage=merged, ts=ts)
+                skip_emit = reject_reason is not None and not added
+                if not skip_emit:
+                    text = self._current_brief()
+                    merged = {**(signals or {}), "brief": text}
+                    self.last_triage = merged
+                    if self._brief_file is not None:
+                        self._brief_file.write(text)
+                    self._emit(TRIAGE_EVENT, stage="content", triage=merged, ts=ts)
+            if reject_reason is not None:
+                self._debug_emit(
+                    "triage-brief",
+                    action="reject",
+                    reason=reject_reason,
+                    generation=generation,
+                )
+            if skip_emit:
+                return
             self._debug_emit(
                 "triage-ledger",
                 added=added,

+ 101 - 18
src/stt_daemon/assistant/triage.py

@@ -27,6 +27,7 @@ Public API (consumed by the assistant service layer):
         def parse_ledger_reply(self, text, truncated=False) -> tuple[str, list[str]] | None
         def parse_facts(self, block) -> list[str]
         def should_update_brief(self, signals) -> bool
+        def is_regression(self, prior_brief, new_brief) -> bool
         def finalize_brief(self, text, finish_reason) -> str | None
         def state(self) -> tuple[str, str]
         def refresh(self, state: str, reason: str = "") -> None
@@ -59,6 +60,21 @@ TRIAGE_REASONS = ("ok", "no_questions", "loading", "laya_missing", "disabled", "
 
 _VALID_TYPES = ("choice", "score", "noul")
 
+# Canonical brief sections in model emission order; regression detection and the
+# reply template both depend on this exact list.
+BRIEF_HEADINGS = (
+    "Summary:",
+    "Opportunities:",
+    "Blockers/Risks:",
+    "Responsibilities:",
+    "Decisions:",
+    "Action Items:",
+    "Deadlines:",
+    "Open Questions:",
+)
+
+BRIEF_SECTION_TEMPLATE = "\n".join(BRIEF_HEADINGS)
+
 
 def _invalid(path, qid, why):
     logger.warning("ignoring questions file %r: question %r: %s", path, qid, why)
@@ -420,6 +436,27 @@ class TriageAnalyzer:
         except (TypeError, ValueError):
             return 0.5
 
+    def _clip_for_prompt(self, text: str) -> tuple[str, bool]:
+        """Trim ``text`` to the brief budget at a line or word boundary.
+
+        Returns ``(text, trimmed)``. The stored brief is never clipped; only the
+        view handed to the model is bounded, so a long-running meeting cannot
+        lose sections to a mid-word cut. The line boundary is used only when it
+        keeps at least half the budget, because a single long Summary paragraph
+        is a normal brief shape.
+        """
+        limit = self._brief_max_chars()
+        if len(text) <= limit:
+            return text, False
+        cut = text.rfind("\n", 0, limit)
+        if cut < limit // 2:
+            space = text.rfind(" ", 0, limit)
+            if space > cut:
+                cut = space
+        if cut <= 0:
+            cut = limit
+        return text[:cut].rstrip(), True
+
     def _signal_lines(self, signals) -> list[str]:
         """Render each ordered signal as a "- " words line; never raises.
 
@@ -489,37 +526,46 @@ class TriageAnalyzer:
         current ledger (facts it must NOT repeat) and asked for the two halves
         back in fixed machine-parseable sections. ``NEW FACTS:`` is placed first
         so a length cutoff loses narrative (regenerated every pass) rather than a
-        durable fact.
+        durable fact. The narrative is bounded here, at a line or word boundary,
+        and never in the accepted text.
         """
         try:
             body = "\n".join("- " + delta for delta in (deltas or []))
         except Exception:
             body = ""
         ledger_text = "\n".join("- " + fact for fact in (ledger or [])) or "(none yet)"
+        current, trimmed = self._clip_for_prompt(narrative or "")
         parts = [
             "CURRENT LEDGER (facts already recorded - never repeat these):\n" + ledger_text,
-            "CURRENT BRIEF:\n" + (narrative or "(none yet)"),
+            "CURRENT BRIEF:\n" + (current or "(none yet)"),
             "NEW UTTERANCES:\n" + body,
         ]
         focus = self._signal_lines(signals)
         if focus:
             parts.append("Focus:\n" + "\n".join(focus))
-        parts.append(
+        instructions = [
             "Reply with exactly these two sections and nothing else:\n"
             "NEW FACTS:\n"
             "- only genuinely new durable facts, one per line; write 'none' if there are none\n"
             "BRIEF:\n"
-            "Summary:\n...\nOpportunities:\n...\nBlockers/Risks:\n...\n"
-            "Decisions:\n...\nOpen Questions:\n..."
-            "\nNever silently delete Action Items, Open Questions, or Decisions "
+            + BRIEF_SECTION_TEMPLATE,
+            "Never silently delete Action Items, Open Questions, or Decisions "
             "lines. When an action completes or a question is answered, move it "
             "to Decisions as 'Done: <original> — resolved: <what happened>' "
             "(or keep it with a 'Done (...)' suffix) and record the resolution "
             "as a NEW FACTS bullet; prune only exact duplicates."
             " Lead the Summary with currently-open opportunities first, and keep "
             "the Opportunities section the most complete and current without "
-            "dropping other sections."
-        )
+            "dropping other sections. Emit every section heading above, even when "
+            "a section is empty.",
+        ]
+        if trimmed:
+            instructions.append(
+                "The CURRENT BRIEF was trimmed to fit this request. Keep every "
+                "existing section heading, merge duplicates, and shorten prose so "
+                "the whole brief fits again."
+            )
+        parts.append("\n".join(instructions))
         return "\n\n".join(parts)
 
     def parse_ledger_reply(self, text, truncated: bool = False) -> tuple[str, list[str]] | None:
@@ -618,6 +664,21 @@ class TriageAnalyzer:
                 keys.append(key)
         return keys
 
+    def _budget_bullets(self, bullets: list[str]) -> str:
+        """Render ``- `` bullets within the brief budget, keeping the newest."""
+        budget = self._brief_max_chars()
+        kept: list[str] = []
+        used = 0
+        for bullet in reversed(bullets):
+            line = "- " + bullet
+            cost = len(line) + 1
+            if kept and used + cost > budget:
+                break
+            kept.append(line)
+            used += cost
+        kept.reverse()
+        return "\n".join(kept)
+
     def restore_missing_bullets(self, new_brief, prior_brief) -> str:
         """Re-append any prior bullet that the model dropped from ``new_brief``.
 
@@ -626,7 +687,8 @@ class TriageAnalyzer:
         the model re-emitted the document without it. Bullets the new brief merely
         reworded still match by normalized key, so only a genuinely absent bullet
         is restored. Restoration appends under the brief's final ``Prior
-        Details:`` section, or at the end when absent.
+        Details:`` section, which is bounded by ``brief_max_chars`` and keeps the
+        newest bullets when it overflows.
         """
         if not isinstance(new_brief, str) or not new_brief.strip():
             return new_brief
@@ -645,12 +707,20 @@ class TriageAnalyzer:
             missing.append(stripped[2:].strip())
         if not missing:
             return new_brief
-        restored = "\n".join("- " + bullet for bullet in missing)
         marker = "Prior Details:"
         if marker in new_brief:
             head, _, tail = new_brief.rpartition(marker)
-            return f"{head}{marker}{tail.rstrip()}\n{restored}"
-        return f"{new_brief.rstrip()}\n\nPrior Details:\n{restored}"
+            base = head + marker
+            carried = [
+                line.strip()[2:].strip()
+                for line in tail.splitlines()
+                if line.strip().startswith("- ")
+            ]
+        else:
+            base = new_brief.rstrip() + "\n\n" + marker
+            carried = []
+        block = self._budget_bullets(carried + missing)
+        return f"{base.rstrip()}\n{block}"
 
     @staticmethod
     def _first_arg_start(text: str) -> int:
@@ -753,10 +823,6 @@ class TriageAnalyzer:
             )
             if not cleaned:
                 return None
-        limit = self._brief_max_chars()
-        if len(cleaned) > limit:
-            logger.warning("truncating brief from %d to %d chars", len(cleaned), limit)
-            cleaned = cleaned[:limit]
         return cleaned
 
     def should_update_brief(self, signals) -> bool:
@@ -774,10 +840,27 @@ class TriageAnalyzer:
             return True
         return prob >= self._brief_gate_threshold()
 
+    def is_regression(self, prior_brief, new_brief) -> bool:
+        """True when ``new_brief`` lost a canonical section the prior one had.
+
+        Guards against accepting a rewrite that silently dropped whole sections:
+        the previous brief stays authoritative instead of being replaced by a
+        shorter, structurally poorer document.
+        """
+        if not isinstance(prior_brief, str) or not isinstance(new_brief, str):
+            return False
+        if not prior_brief.strip() or not new_brief.strip():
+            return False
+        for heading in BRIEF_HEADINGS:
+            if heading in prior_brief and heading not in new_brief:
+                return True
+        return False
+
     def finalize_brief(self, text, finish_reason) -> str | None:
-        """Accept any parseable brief; a length cutoff truncates, never rejects."""
+        """Parse a complete brief; a length cutoff rejects the whole reply."""
         if finish_reason == "length":
-            logger.warning("brief reply hit the length limit; keeping the truncated text")
+            logger.warning("brief reply hit the length limit; rejecting the truncated text")
+            return None
         return self.parse_brief(text)
 
     def state(self) -> tuple[str, str]:

+ 67 - 4
tests/test_assistant_service.py

@@ -779,6 +779,9 @@ class FakeTriage:
     def parse_ledger_reply(self, text, truncated=False):
         return _real_analyzer.parse_ledger_reply(text, truncated=truncated)
 
+    def is_regression(self, prior_brief, new_brief):
+        return _real_analyzer.is_regression(prior_brief, new_brief)
+
     def parse_facts(self, block):
         return _real_analyzer.parse_facts(block)
 
@@ -858,6 +861,7 @@ def _triage_service(
     brief_reset=None,
     chat_reset=None,
     debug_sink=None,
+    brief_finish: Any = "stop",
 ):
     cfg = Config()
     cfg.assistant.turn_gap_s = 0.5
@@ -881,7 +885,7 @@ def _triage_service(
         time_fn=clock,
         triage=triage,
         laya_lane=lane,
-        brief_fn=_wrap_brief_fn(brief_fn),
+        brief_fn=_wrap_brief_fn(brief_fn, brief_finish),
         brief_reset=brief_reset,
         brief_file=brief_file,
         chat_fn=chat_fn,
@@ -891,11 +895,12 @@ def _triage_service(
     return service, emitted, clock, triage, lane
 
 
-def _wrap_brief_fn(brief_fn):
+def _wrap_brief_fn(brief_fn, finish_reason="stop"):
     """Adapt a simple `prompt -> text` stub to the engine's call signature.
 
     A plain string return is treated as the volatile narrative half of a
-    ledger reply, so existing stubs keep reading naturally.
+    ledger reply, so existing stubs keep reading naturally. ``finish_reason``
+    may be a callable taking the reply so a test can mark one reply truncated.
     """
     if brief_fn is None:
         return None
@@ -909,7 +914,8 @@ def _wrap_brief_fn(brief_fn):
         except TypeError:
             result = brief_fn(prompt)
         if finalize is not None:
-            result = finalize(result, "stop")
+            reason = finish_reason(result) if callable(finish_reason) else finish_reason
+            result = finalize(result, reason)
         is_ledger = "BRIEF:" in result or "NEW FACTS:" in result
         if isinstance(result, str) and result and not is_ledger:
             result = _brief_reply(narrative=result)
@@ -1711,6 +1717,63 @@ def test_ledger_dedupes_repeated_fact():
     assert service._fact_ledger.facts() == ["Sense could find prey."]
 
 
+def test_truncated_brief_reply_keeps_prior_narrative_and_facts():
+    """A reply cut at the token limit must not replace the accepted narrative."""
+    sink = FakeDebugSink()
+    replies = iter(
+        [
+            _brief_reply("Summary: good\n\nOpen Questions:\n- who owns it?"),
+            _brief_reply("Summary: cut mid-wor", ["fact one durable", "fact two"]),
+        ]
+    )
+    service, _, _, _, _ = _triage_service(
+        brief_fn=lambda prompt: next(replies),
+        brief_finish=lambda text: "length" if "cut mid-wor" in text else "stop",
+        debug_sink=sink,
+    )
+
+    service._pending_brief = ["Unknown: one"]
+    service._brief_outstanding = True
+    service._run_brief_update(0, _escalated_payload(), service._now(), service._brief_token)
+
+    service._pending_brief = ["Unknown: two"]
+    service._brief_outstanding = True
+    service._run_brief_update(0, _escalated_payload(), service._now(), service._brief_token)
+
+    assert service._last_brief_narrative.startswith("Summary: good")
+    assert "cut mid-wor" not in service.last_triage["brief"]
+    assert service._fact_ledger.facts() == ["fact one durable"]
+    rejects = [
+        event
+        for event in sink.events
+        if event[0] == "triage-brief" and event[1].get("action") == "reject"
+    ]
+    assert rejects
+    assert rejects[-1][1]["reason"] == "truncated"
+
+
+def test_brief_rewrite_that_drops_a_section_is_rejected():
+    """Dropping a canonical section keeps the prior, more complete brief."""
+    replies = iter(
+        [
+            _brief_reply("Summary: full\n\nOpen Questions:\n- who owns it?"),
+            _brief_reply("Summary: only"),
+        ]
+    )
+    service, _, _, _, _ = _triage_service(brief_fn=lambda prompt: next(replies))
+
+    service._pending_brief = ["Unknown: one"]
+    service._brief_outstanding = True
+    service._run_brief_update(0, _escalated_payload(), service._now(), service._brief_token)
+
+    service._pending_brief = ["Unknown: two"]
+    service._brief_outstanding = True
+    service._run_brief_update(0, _escalated_payload(), service._now(), service._brief_token)
+
+    assert "Open Questions:" in service.last_triage["brief"]
+    assert "Summary: only" not in service.last_triage["brief"]
+
+
 def test_ledger_prompt_carries_ledger_and_narrative():
     """The brief request hands the model the ledger, narrative, and new deltas."""
     seen: list[str] = []

+ 70 - 13
tests/test_triage.py

@@ -550,19 +550,78 @@ def test_parse_brief_unescapes_literal_backslash_n(tmp_path):
     assert analyzer.parse_brief("line1\nline2\\nline3") == "line1\nline2\\nline3"
 
 
-def test_parse_brief_truncates_not_rejects(tmp_path):
+def test_parse_brief_never_truncates_accepted_text(tmp_path):
+    """The accepted brief is never cut: a long reply is returned whole."""
     analyzer = _analyzer(tmp_path, _score_questions())
     parsed = analyzer.parse_brief("x" * 5000)
-    assert parsed is not None
-    assert parsed == "x" * 4000
-    assert len(parsed) == 4000
+    assert parsed == "x" * 5000
     wrapped = "[update_brief(updated_brief='" + "x" * 5000 + "')]"
-    assert analyzer.parse_brief(wrapped) == "x" * 4000
+    assert analyzer.parse_brief(wrapped) == "x" * 5000
+
+
+def test_build_ledger_prompt_clips_current_brief_on_line_boundary(tmp_path):
+    analyzer = _analyzer(tmp_path, _score_questions(), brief_max_chars=40)
+    narrative = "Summary: first line\nOpportunities:\n- keep this out"
+    prompt = analyzer.build_ledger_prompt(narrative, [], ["hi"], None)
+    assert "Summary: first line" in prompt
+    assert "- keep this out" not in prompt
+    assert "The CURRENT BRIEF was trimmed" in prompt
+
+
+def test_build_ledger_prompt_trims_long_paragraph_at_a_word(tmp_path):
+    """A single long Summary paragraph must keep most of the budget, not 8 chars."""
+    analyzer = _analyzer(tmp_path, _score_questions(), brief_max_chars=400)
+    narrative = "Summary:\n" + ("word " * 200).strip()
+    prompt = analyzer.build_ledger_prompt(narrative, [], ["hi"], None)
+    view = prompt.split("CURRENT BRIEF:\n", 1)[1].split("\n\nNEW UTTERANCES:", 1)[0]
+    assert len(view) > 200
+    assert not view.endswith("wor")
+
+
+def test_build_ledger_prompt_keeps_short_brief_verbatim(tmp_path):
+    analyzer = _analyzer(tmp_path, _score_questions())
+    prompt = analyzer.build_ledger_prompt("Summary: x", [], ["hi"], None)
+    assert "CURRENT BRIEF:\nSummary: x" in prompt
+    assert "was trimmed" not in prompt
+
 
+def test_parse_ledger_reply_keeps_narrative_past_the_brief_cap(tmp_path):
+    """Regression: the cap used to slice the reply before the BRIEF split."""
+    analyzer = _analyzer(tmp_path, _score_questions(), brief_max_chars=50)
+    narrative = "Summary: " + ("word " * 400).strip() + "\nOpen Questions:\n- who owns it?"
+    reply = "NEW FACTS:\n- durable fact\n\nBRIEF:\n" + narrative
+    parsed = analyzer.parse_ledger_reply(reply)
+    assert parsed is not None
+    got_narrative, facts = parsed
+    assert facts == ["durable fact"]
+    assert got_narrative.endswith("- who owns it?")
+    assert len(got_narrative) > 50
+
+
+def test_restore_missing_bullets_caps_prior_details_block(tmp_path):
+    analyzer = _analyzer(tmp_path, _score_questions(), brief_max_chars=80)
+    prior = "Summary: s\n\nPrior Details:\n" + "\n".join(f"- bullet {i}" for i in range(20))
+    restored = analyzer.restore_missing_bullets("Summary: s2\n", prior)
+    tail = restored.split("Prior Details:", 1)[1]
+    assert len(tail) <= 80
+    assert "- bullet 19" in tail
+    assert "- bullet 0" not in tail
+
+
+def test_is_regression_flags_lost_section_only(tmp_path):
+    analyzer = _analyzer(tmp_path, _score_questions())
+    prior = "Summary: a\n\nOpen Questions:\n- who owns it?\n"
+    assert analyzer.is_regression(prior, "Summary: b\n") is True
+    assert analyzer.is_regression(prior, "Summary: b\n\nOpen Questions:\n- who?\n") is False
+    assert analyzer.is_regression("", "anything") is False
+    assert analyzer.is_regression(None, "x") is False
 
-def test_parse_brief_honors_configured_cap(tmp_path):
-    analyzer = _analyzer(tmp_path, _score_questions(), brief_max_chars=10)
-    assert analyzer.parse_brief("x" * 50) == "x" * 10
+
+def test_build_ledger_prompt_lists_every_canonical_section(tmp_path):
+    analyzer = _analyzer(tmp_path, _score_questions())
+    prompt = analyzer.build_ledger_prompt("Summary: x", [], ["hi"], None)
+    for heading in triage.BRIEF_HEADINGS:
+        assert heading in prompt
 
 
 def test_parse_brief_unwraps_single_quoted_tool_call_with_apostrophes(tmp_path):
@@ -679,13 +738,11 @@ def test_should_update_brief_threshold_configured(tmp_path):
     assert analyzer.should_update_brief(_brief_signals(0.8)) is True
 
 
-def test_finalize_brief_length_truncates_not_rejects(tmp_path, caplog):
+def test_finalize_brief_length_rejects_cut_reply(tmp_path, caplog):
     analyzer = _analyzer(tmp_path, _score_questions())
     with caplog.at_level(logging.WARNING, logger="stt_daemon.assistant.triage"):
-        result = analyzer.finalize_brief("y" * 5000, "length")
-    assert result is not None
-    assert result == "y" * 4000
-    assert caplog.records  # warned about the length cutoff but still accepted
+        assert analyzer.finalize_brief("y" * 5000, "length") is None
+    assert caplog.records
 
 
 def test_finalize_brief_stop_and_empty(tmp_path):