Efren Yevale Varela 4 днів тому
батько
коміт
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)
 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)
 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_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_timeout_s    = 30.0                 # Brief request timeout
 extract_temperature  = 0.2                  # Brief request temperature (deterministic extraction; chat keeps [llm].temperature)
 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_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_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_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
 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
 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
 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
 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
 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
 `[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
 `[llm].brief_compaction_system_prompt` and the `[llm]` compaction settings do not apply to it
 (they still govern chat/command).
 (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.
   - A `⚠` mark follows any item the analyzer flagged.
 - **`content`** after a brief update: the plain-text brief arrives as a `content` stage and
 - **`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
   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
 - **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.
   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
 - **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). |
 | `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). |
 | `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_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_timeout_s` | `30.0` | Timeout for the brief request. |
 | `extract_temperature` | `0.2` | Temperature for the brief request (deterministic extraction; chat keeps `[llm].temperature`). |
 | `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_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_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_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. |
 | `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. |
 | `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. |
 | `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
 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
 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`,
 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
 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.
 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 f"{narrative.rstrip()}\n\n{ledger_text}"
         return narrative or 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:
     def _run_brief_update(self, generation, signals, ts=None, token=None) -> None:
         if self._triage is None or self._brief_fn is None:
         if self._triage is None or self._brief_fn is None:
             if ts is not None:
             if ts is not None:
@@ -1213,23 +1239,18 @@ class AssistantService:
             if not text:
             if not text:
                 requeued = True
                 requeued = True
                 return
                 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:
             if parsed is None:
                 requeued = True
                 requeued = True
                 return
                 return
             new_narrative, new_facts = parsed
             new_narrative, new_facts = parsed
-            guarded = new_narrative
+            guarded = None
+            reject_reason = None
             if new_narrative:
             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:
             with self._lock:
                 if generation != self._triage_generation:
                 if generation != self._triage_generation:
                     return
                     return
@@ -1238,12 +1259,23 @@ class AssistantService:
                 added = 0
                 added = 0
                 if self._fact_ledger is not None:
                 if self._fact_ledger is not None:
                     added = self._fact_ledger.merge(new_facts)
                     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(
             self._debug_emit(
                 "triage-ledger",
                 "triage-ledger",
                 added=added,
                 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_ledger_reply(self, text, truncated=False) -> tuple[str, list[str]] | None
         def parse_facts(self, block) -> list[str]
         def parse_facts(self, block) -> list[str]
         def should_update_brief(self, signals) -> bool
         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 finalize_brief(self, text, finish_reason) -> str | None
         def state(self) -> tuple[str, str]
         def state(self) -> tuple[str, str]
         def refresh(self, state: str, reason: str = "") -> None
         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")
 _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):
 def _invalid(path, qid, why):
     logger.warning("ignoring questions file %r: question %r: %s", path, qid, why)
     logger.warning("ignoring questions file %r: question %r: %s", path, qid, why)
@@ -420,6 +436,27 @@ class TriageAnalyzer:
         except (TypeError, ValueError):
         except (TypeError, ValueError):
             return 0.5
             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]:
     def _signal_lines(self, signals) -> list[str]:
         """Render each ordered signal as a "- " words line; never raises.
         """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
         current ledger (facts it must NOT repeat) and asked for the two halves
         back in fixed machine-parseable sections. ``NEW FACTS:`` is placed first
         back in fixed machine-parseable sections. ``NEW FACTS:`` is placed first
         so a length cutoff loses narrative (regenerated every pass) rather than a
         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:
         try:
             body = "\n".join("- " + delta for delta in (deltas or []))
             body = "\n".join("- " + delta for delta in (deltas or []))
         except Exception:
         except Exception:
             body = ""
             body = ""
         ledger_text = "\n".join("- " + fact for fact in (ledger or [])) or "(none yet)"
         ledger_text = "\n".join("- " + fact for fact in (ledger or [])) or "(none yet)"
+        current, trimmed = self._clip_for_prompt(narrative or "")
         parts = [
         parts = [
             "CURRENT LEDGER (facts already recorded - never repeat these):\n" + ledger_text,
             "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,
             "NEW UTTERANCES:\n" + body,
         ]
         ]
         focus = self._signal_lines(signals)
         focus = self._signal_lines(signals)
         if focus:
         if focus:
             parts.append("Focus:\n" + "\n".join(focus))
             parts.append("Focus:\n" + "\n".join(focus))
-        parts.append(
+        instructions = [
             "Reply with exactly these two sections and nothing else:\n"
             "Reply with exactly these two sections and nothing else:\n"
             "NEW FACTS:\n"
             "NEW FACTS:\n"
             "- only genuinely new durable facts, one per line; write 'none' if there are none\n"
             "- only genuinely new durable facts, one per line; write 'none' if there are none\n"
             "BRIEF:\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 "
             "lines. When an action completes or a question is answered, move it "
             "to Decisions as 'Done: <original> — resolved: <what happened>' "
             "to Decisions as 'Done: <original> — resolved: <what happened>' "
             "(or keep it with a 'Done (...)' suffix) and record the resolution "
             "(or keep it with a 'Done (...)' suffix) and record the resolution "
             "as a NEW FACTS bullet; prune only exact duplicates."
             "as a NEW FACTS bullet; prune only exact duplicates."
             " Lead the Summary with currently-open opportunities first, and keep "
             " Lead the Summary with currently-open opportunities first, and keep "
             "the Opportunities section the most complete and current without "
             "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)
         return "\n\n".join(parts)
 
 
     def parse_ledger_reply(self, text, truncated: bool = False) -> tuple[str, list[str]] | None:
     def parse_ledger_reply(self, text, truncated: bool = False) -> tuple[str, list[str]] | None:
@@ -618,6 +664,21 @@ class TriageAnalyzer:
                 keys.append(key)
                 keys.append(key)
         return keys
         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:
     def restore_missing_bullets(self, new_brief, prior_brief) -> str:
         """Re-append any prior bullet that the model dropped from ``new_brief``.
         """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
         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
         reworded still match by normalized key, so only a genuinely absent bullet
         is restored. Restoration appends under the brief's final ``Prior
         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():
         if not isinstance(new_brief, str) or not new_brief.strip():
             return new_brief
             return new_brief
@@ -645,12 +707,20 @@ class TriageAnalyzer:
             missing.append(stripped[2:].strip())
             missing.append(stripped[2:].strip())
         if not missing:
         if not missing:
             return new_brief
             return new_brief
-        restored = "\n".join("- " + bullet for bullet in missing)
         marker = "Prior Details:"
         marker = "Prior Details:"
         if marker in new_brief:
         if marker in new_brief:
             head, _, tail = new_brief.rpartition(marker)
             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
     @staticmethod
     def _first_arg_start(text: str) -> int:
     def _first_arg_start(text: str) -> int:
@@ -753,10 +823,6 @@ class TriageAnalyzer:
             )
             )
             if not cleaned:
             if not cleaned:
                 return None
                 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
         return cleaned
 
 
     def should_update_brief(self, signals) -> bool:
     def should_update_brief(self, signals) -> bool:
@@ -774,10 +840,27 @@ class TriageAnalyzer:
             return True
             return True
         return prob >= self._brief_gate_threshold()
         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:
     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":
         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)
         return self.parse_brief(text)
 
 
     def state(self) -> tuple[str, str]:
     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):
     def parse_ledger_reply(self, text, truncated=False):
         return _real_analyzer.parse_ledger_reply(text, truncated=truncated)
         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):
     def parse_facts(self, block):
         return _real_analyzer.parse_facts(block)
         return _real_analyzer.parse_facts(block)
 
 
@@ -858,6 +861,7 @@ def _triage_service(
     brief_reset=None,
     brief_reset=None,
     chat_reset=None,
     chat_reset=None,
     debug_sink=None,
     debug_sink=None,
+    brief_finish: Any = "stop",
 ):
 ):
     cfg = Config()
     cfg = Config()
     cfg.assistant.turn_gap_s = 0.5
     cfg.assistant.turn_gap_s = 0.5
@@ -881,7 +885,7 @@ def _triage_service(
         time_fn=clock,
         time_fn=clock,
         triage=triage,
         triage=triage,
         laya_lane=lane,
         laya_lane=lane,
-        brief_fn=_wrap_brief_fn(brief_fn),
+        brief_fn=_wrap_brief_fn(brief_fn, brief_finish),
         brief_reset=brief_reset,
         brief_reset=brief_reset,
         brief_file=brief_file,
         brief_file=brief_file,
         chat_fn=chat_fn,
         chat_fn=chat_fn,
@@ -891,11 +895,12 @@ def _triage_service(
     return service, emitted, clock, triage, lane
     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.
     """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
     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:
     if brief_fn is None:
         return None
         return None
@@ -909,7 +914,8 @@ def _wrap_brief_fn(brief_fn):
         except TypeError:
         except TypeError:
             result = brief_fn(prompt)
             result = brief_fn(prompt)
         if finalize is not None:
         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
         is_ledger = "BRIEF:" in result or "NEW FACTS:" in result
         if isinstance(result, str) and result and not is_ledger:
         if isinstance(result, str) and result and not is_ledger:
             result = _brief_reply(narrative=result)
             result = _brief_reply(narrative=result)
@@ -1711,6 +1717,63 @@ def test_ledger_dedupes_repeated_fact():
     assert service._fact_ledger.facts() == ["Sense could find prey."]
     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():
 def test_ledger_prompt_carries_ledger_and_narrative():
     """The brief request hands the model the ledger, narrative, and new deltas."""
     """The brief request hands the model the ledger, narrative, and new deltas."""
     seen: list[str] = []
     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"
     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())
     analyzer = _analyzer(tmp_path, _score_questions())
     parsed = analyzer.parse_brief("x" * 5000)
     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 + "')]"
     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):
 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
     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())
     analyzer = _analyzer(tmp_path, _score_questions())
     with caplog.at_level(logging.WARNING, logger="stt_daemon.assistant.triage"):
     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):
 def test_finalize_brief_stop_and_empty(tmp_path):