Răsfoiți Sursa

Added brief pinning and deterministic retention guard

Efren Yevale Varela 1 lună în urmă
părinte
comite
cd43065d31

+ 1 - 1
config.example.toml

@@ -105,7 +105,7 @@ confidence_field          = "answer_confidence"
 escalate_threshold        = 0.6
 choice_min_confidence     = 0.85
 extract_enabled           = true
-extract_max_tokens        = 1536
+extract_max_tokens        = 3072
 extract_timeout_s         = 30.0
 brief_slot_id             = ""
 brief_gate_enabled        = true

+ 12 - 7
docs/CONFIGURATION.md

@@ -129,7 +129,7 @@ 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   = 1536                 # Brief reply token cap; the brief is then capped to brief_max_chars characters
+extract_max_tokens   = 3072                 # Brief reply token cap; the brief is then capped to brief_max_chars characters
 extract_timeout_s    = 30.0                 # Brief request timeout
 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
@@ -229,12 +229,17 @@ exactly as before. `dump_io = true` additionally writes one JSON file per triage
 the audio files) containing the Laya request/response and the LLM request/response; it is
 off by default, best-effort (a dump failure never disturbs triage), and gitignored.
 
-`[llm].triage_system_prompt` drives the brief engine: given the current brief (its previous
-reply) and a new transcript segment, it must return the updated brief as plain text only, with
-no JSON and no markdown fences. The model reply is bounded by `[triage].extract_max_tokens`
-(default 1536) and `extract_timeout_s`, and the stored/displayed brief is then capped to
-`brief_max_chars` characters (truncated, never dropped). `[llm].brief_compaction_system_prompt`
-is the summarizer used when the brief conversation's own history is compacted.
+`[llm].triage_system_prompt` drives the brief engine: it is the static system message, and the
+user turn carries the pinned current brief (`CURRENT BRIEF:`) plus the new transcript segment
+(`NEW UTTERANCES:`). The engine keeps no conversation history, so the prompt stays flat instead
+of growing and the previous brief is the single source of truth. The model must return the
+updated brief as plain text only, with no JSON and no markdown fences. The model reply is
+bounded by `[triage].extract_max_tokens` (default 3072) and `extract_timeout_s`, and the
+stored/displayed brief is then capped to `brief_max_chars` characters (truncated, never
+dropped). After parsing, a code-side retention guard re-appends any `- ` bullet the model
+dropped from the prior brief, so a fact is never silently lost to a bad re-emit. 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).
 
 The gate `brief_gate_enabled` (default true) is fail-open: a turn updates the brief only when
 the top signal's calibrated `p` is at least `brief_gate_threshold` (default 0.5), and any

+ 35 - 28
docs/TRIAGE.md

@@ -79,26 +79,32 @@ settled turn (any mode), or every periodic_s while speech keeps accumulating
      criteria
    Escalation only drives the per-item `flagged` flag and the `⚠` marker in the panel;
    it drives neither the gate nor the brief.
-5. **Brief update.** `extract_enabled` is the brief engine's master switch: with it false
-   the panel still classifies and shows signals, but no brief is requested. When it is true
-   and a brief function is wired, the fail-open gate decides whether the turn updates the
-   brief. The gate reads the `substantive_update` `noul` arm of the signals: the update runs
-   when `p >= brief_gate_threshold` (default `0.5`), and any missing or non-numeric signal
-   passes. The service queues **one** `brief-update` job on the existing LLM lane. The job
-   carries the speaker-labeled pending deltas (the transcripts that arrived since the last
-   accepted update) plus the current signal words under a `Focus:` header; a Laya error
-   still runs with the deltas only. `[llm].triage_system_prompt` drives a dedicated brief
-   conversation that keeps its own history; the reply is a plain-text brief, not JSON
-   (fences stripped, then truncated to `brief_max_chars`, never dropped). The accepted brief
-   is emitted as the `content` stage, whose payload key is `brief` (the event carries the
-   merged signals plus `brief`), and, when `[brief].write = true`, snapshotted to disk.
-6. **Compaction.** The brief conversation is its own `ConversationEngine`, so it compacts
-   its history independently, using `[llm].brief_compaction_system_prompt` and the `[llm]`
-   compaction settings. When it fires, the summary is injected as a second `system` message,
-   which **resets that engine's prompt prefix**: the server's prefix cache is invalidated
-   once for it, and the summarizer call can hold the shared LLM lane for up to
-   `compaction_timeout_s`.
-7. **Reset.** `clear` bumps the triage generation, clears the stored signals and the ambient
+5. **Brief update.** The service queues **one** `brief-update` job on the existing LLM
+   lane. The job carries the speaker-labeled pending deltas (the transcripts that arrived
+   since the last accepted update) plus the current signal words under a `Focus:` header; a
+   Laya error still runs with the deltas only. The brief engine is **stateful only in code**:
+   it pins the last accepted brief and sends exactly two messages per call — a static system
+   prompt and ONE user turn containing `CURRENT BRIEF:` (the pinned text) and
+   `NEW UTTERANCES:` (the deltas). No conversation history is replayed, so the prompt stays
+   flat instead of growing, the model never has to disambiguate between stale copies of its
+   own prior briefs, and the previous brief is the single source of truth. The reply is a
+   plain-text brief, not JSON (fences stripped, then truncated to `brief_max_chars`, never
+   dropped). `extract_enabled` is the brief engine's master switch: with it false the panel
+   still classifies and shows signals, but no brief is requested. The accepted brief is
+   emitted as the `content` stage, whose payload key is `brief` (the event carries the merged
+   signals plus `brief`), and, when `[brief].write = true`, snapshotted to disk.
+6. **Retention guard.** Because the model re-emits the whole brief each pass, it can silently
+   drop a previously-recorded fact. After the reply is parsed, the service compares the
+   backend's bullet lines (any `- ` line) against the pinned prior brief by normalized key
+   and **re-appends any bullet that vanished**, under the brief's `Prior Details:` section
+   (or appending that section when absent). Matching ignores the `**New:**` marker and
+   whitespace, so a genuine reword/merge still matches; only a truly absent bullet is
+   restored. The guard is deterministic and lives entirely in code — it does not call the
+   model. It is the guarantee that an important detail is never lost to a bad re-emit.
+7. **No compaction.** The brief engine does not use `ConversationEngine`, so there is no
+   history to compact and `[llm].brief_compaction_system_prompt` is unused by the brief
+   path. `[llm]` compaction settings still govern the chat/command session.
+8. **Reset.** `clear` bumps the triage generation, clears the stored signals and the ambient
    buffer, drops any pending lane request and any queued `brief-update` job, truncates the
    optional durable brief file, and emits the `cleared` stage. An in-flight result that
    lands after the bump is suppressed, so a stale brief writes neither the pane nor the
@@ -283,7 +289,7 @@ 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` | `1536` | 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; the brief is then truncated to `brief_max_chars`. |
 | `extract_timeout_s` | `30.0` | Timeout for the brief request. |
 | `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`. |
@@ -295,12 +301,13 @@ file.
 | `default_checkpoint` | `"multilingual"` | Router default and the per-request `model` for every call. Multilingual is pinned. |
 | `dump_io` | `false` | Opt-in I/O dump under `triage/YYYY-MM-DD/HHMMSS-mmm.json`; a coalesced update writes two files (see below). |
 
-`[llm].triage_system_prompt` drives the brief conversation: given the current brief (its
-previous reply, if any) and the new segment, it must return the updated brief as plain text
-only, with no markdown fences and no JSON. The reply is bounded by `extract_max_tokens` and
-`extract_timeout_s`, then truncated to `brief_max_chars`.
-`[llm].brief_compaction_system_prompt` is the summarizer used when the brief conversation's
-own history is compacted.
+`[llm].triage_system_prompt` drives the brief engine: it is sent as the static system
+message, and the user turn carries the pinned current brief plus the new segment. The model
+must return the updated brief as plain text only, with no markdown fences and no JSON. The
+reply is bounded by `extract_max_tokens` and `extract_timeout_s`, then truncated to
+`brief_max_chars`. 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.
 
 `[brief]` is a separate section that controls durability of the living brief. `write`
 (default `false`) gates disk persistence only: with it false the brief stays in the TUI, and
@@ -352,7 +359,7 @@ the parsed fields in the panel. That is replaced by the plain-text brief describ
 
 - `[triage].extract_enabled` still exists and is still the master switch, but it now gates
   the brief update, not a JSON extraction.
-- `[triage].extract_max_tokens` (default raised to `1536`) is the brief reply cap, and
+- `[triage].extract_max_tokens` (default raised to `3072`) is the brief reply cap, and
   `extract_timeout_s` is the brief request timeout.
 - `[llm].triage_system_prompt` is now a plain-text brief instruction; the shipped default no
   longer asks for JSON.

+ 108 - 0
src/stt_daemon/assistant/llm.py

@@ -452,3 +452,111 @@ class ChatSession(ConversationEngine):
 
     def __init__(self, cfg, client, notify: Callable[..., None] | None = None):
         super().__init__(cfg, client, system_prompt=cfg.system_prompt_chat, notify=notify)
+
+
+class BriefEngine:
+    """Stateless-per-call brief engine that pins the last accepted brief.
+
+    Unlike ``ConversationEngine`` this never accumulates history: each call
+    sends the same static system prompt plus ONE user turn carrying the current
+    brief inline (built by the analyzer). The previous brief is therefore the
+    single source of truth, the prompt stays flat (~2-3k tokens) instead of
+    growing linearly, the model never competes with stale copies of its own
+    output, and there is nothing for compaction to evict or mangle.
+
+    Lock discipline: ``self._lock`` guards the pinned brief and generation and
+    is never held across the HTTP call or the finalize callback.
+    """
+
+    def __init__(
+        self,
+        cfg,
+        client,
+        system_prompt: str,
+        max_tokens: int | None = None,
+        timeout_s: float | None = None,
+        slot_id: int | None = None,
+        notify: Callable[..., None] | None = None,
+    ):
+        self.cfg = cfg
+        self.client = client
+        self.system = system_prompt
+        self._max_tokens = max_tokens
+        self._timeout_s = timeout_s
+        self._slot_id = slot_id
+        self.notify = notify
+        self._brief = ""
+        self._generation = 0
+        self._lock = threading.RLock()
+
+    def reset(self) -> None:
+        """Drop the pinned brief (volatile by design)."""
+        with self._lock:
+            self._brief = ""
+            self._generation += 1
+
+    def last_reply(self) -> str:
+        """Return the pinned brief, or "" when none has been accepted."""
+        with self._lock:
+            return self._brief
+
+    def send(
+        self,
+        user_text: str,
+        on_io: Callable[[dict], None] | None = None,
+        finalize: Callable[[str, str], str | None] | None = None,
+    ) -> str:
+        """Ask the model to update the pinned brief from one user turn.
+
+        ``user_text`` is the analyzer's pinned prompt (current brief + new
+        utterances). ``finalize`` behaves as on ``ConversationEngine.send``:
+        returning None rejects the turn and leaves the pinned brief untouched;
+        returning a string pins THAT string as the accepted brief.
+        """
+        with self._lock:
+            generation = self._generation
+            messages = [
+                {"role": "system", "content": self.system},
+                {"role": "user", "content": user_text},
+            ]
+
+        chat_kwargs: dict[str, Any] = {}
+        if on_io is not None:
+            chat_kwargs["on_io"] = on_io
+        if self._slot_id is not None:
+            chat_kwargs["slot_id"] = self._slot_id
+        response = self.client.chat(
+            messages,
+            max_tokens=self._max_tokens,
+            timeout_s=self._timeout_s,
+            **chat_kwargs,
+        )
+        reply = response.content
+        finish_reason = response.finish_reason
+        truncated = finish_reason == "length"
+
+        with self._lock:
+            if generation != self._generation:
+                return ""
+
+        if finalize is not None:
+            final = finalize(reply, finish_reason)
+            with self._lock:
+                if generation != self._generation:
+                    return ""
+                if final is None:
+                    return ""
+                if not truncated:
+                    self._brief = final
+                return final
+
+        with self._lock:
+            if generation != self._generation:
+                return ""
+            if reply and not truncated:
+                self._brief = reply
+
+        if truncated and self.notify is not None:
+            self.notify("truncated", message="response truncated")
+
+        return reply

+ 2 - 3
src/stt_daemon/assistant/runner.py

@@ -12,7 +12,7 @@ from .audit import AuditLog
 from .brief_file import BriefFile
 from .control import ControlServer
 from .executor import LlmExecutor, NullExecutor
-from .llm import ConversationEngine, LlmClient
+from .llm import BriefEngine, ConversationEngine, LlmClient
 from .service import AssistantService
 from .tools import default_registry
 
@@ -33,11 +33,10 @@ def build_triage(cfg, client, on_result=None):
     brief_fn = None
     brief_reset = None
     if client is not None:
-        engine = ConversationEngine(
+        engine = BriefEngine(
             cfg.llm,
             client,
             system_prompt=cfg.llm.triage_system_prompt,
-            compaction_system_prompt=cfg.llm.brief_compaction_system_prompt,
             max_tokens=cfg.triage.extract_max_tokens,
             timeout_s=cfg.triage.extract_timeout_s,
             slot_id=cfg.triage.brief_slot_id,

+ 10 - 1
src/stt_daemon/assistant/service.py

@@ -1072,6 +1072,12 @@ class AssistantService:
         self._debug_emit("triage-brief", action="dispatch", generation=generation)
         return True
 
+    def _current_brief(self) -> str:
+        """Return the last accepted brief text, or "" when none exists yet."""
+        with self._lock:
+            brief = (self.last_triage or {}).get("brief")
+        return brief if isinstance(brief, str) else ""
+
     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:
@@ -1115,7 +1121,9 @@ class AssistantService:
                             },
                         )
                 io_hook = _record_io
-            prompt = self._triage.build_brief_prompt(deltas, signals)
+            prompt = self._triage.build_pinned_brief_prompt(
+                self._current_brief(), deltas, signals
+            )
             if dump:
                 text = self._brief_fn(prompt, on_io=io_hook)
             else:
@@ -1126,6 +1134,7 @@ class AssistantService:
             if not text:
                 requeued = True
                 return
+            text = self._triage.restore_missing_bullets(text, self._current_brief())
             with self._lock:
                 if generation != self._triage_generation:
                     return

+ 78 - 0
src/stt_daemon/assistant/triage.py

@@ -379,6 +379,84 @@ class TriageAnalyzer:
         parts.append("Return only the updated brief as plain text.")
         return "\n\n".join(parts)
 
+    def build_pinned_brief_prompt(self, current_brief, deltas, signals) -> str:
+        """Single-turn brief prompt: pin the current brief + new utterances.
+
+        Unlike ``build_brief_prompt`` this carries the full prior brief inline so
+        the engine can send one user turn with no conversation history, keeping
+        the prompt flat and the previous brief the single source of truth.
+        """
+        try:
+            body = "\n".join("- " + delta for delta in (deltas or []))
+        except Exception:
+            body = ""
+        parts = [
+            "CURRENT BRIEF:\n" + (current_brief or "(none yet)"),
+            "NEW UTTERANCES:\n" + body,
+        ]
+        focus = self._signal_lines(signals)
+        if focus:
+            parts.append("Focus:\n" + "\n".join(focus))
+        parts.append("Return only the updated brief as plain text.")
+        return "\n\n".join(parts)
+
+    @staticmethod
+    def _normalize_bullet(text: str) -> str:
+        """Collapse a bullet to a comparison key; empty when it carries no words."""
+        lowered = " ".join(str(text).strip().lower().split())
+        for prefix in ("- **new:**", "- new:", "**new:**", "new:"):
+            if lowered.startswith(prefix):
+                lowered = lowered[len(prefix):].strip()
+                break
+        return lowered.strip("-* ").strip()
+
+    def extract_bullets(self, brief) -> list[str]:
+        """Return the normalized bullet keys of every '- ' line in ``brief``."""
+        if not isinstance(brief, str):
+            return []
+        keys = []
+        for line in brief.splitlines():
+            stripped = line.strip()
+            if not stripped.startswith("- "):
+                continue
+            key = self._normalize_bullet(stripped)
+            if key:
+                keys.append(key)
+        return keys
+
+    def restore_missing_bullets(self, new_brief, prior_brief) -> str:
+        """Re-append any prior bullet that the model dropped from ``new_brief``.
+
+        Deterministic retention guard: a previously-recorded fact must never
+        vanish because 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.
+        """
+        if not isinstance(new_brief, str) or not new_brief.strip():
+            return new_brief
+        if not isinstance(prior_brief, str) or not prior_brief.strip():
+            return new_brief
+        present = set(self.extract_bullets(new_brief))
+        missing = []
+        for line in prior_brief.splitlines():
+            stripped = line.strip()
+            if not stripped.startswith("- "):
+                continue
+            key = self._normalize_bullet(stripped)
+            if not key or key in present:
+                continue
+            present.add(key)
+            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}"
+
     @staticmethod
     def _first_arg_start(text: str) -> int:
         name_at = text.find("updated_brief")

+ 1 - 1
src/stt_daemon/config.py

@@ -185,7 +185,7 @@ class TriageConfig:
     escalate_threshold: float = 0.6
     choice_min_confidence: float = 0.85
     extract_enabled: bool = True
-    extract_max_tokens: int = 1536
+    extract_max_tokens: int = 3072
     extract_timeout_s: float = 30.0
     brief_slot_id: int | None = None
     brief_gate_enabled: bool = True

+ 1 - 1
tests/test_assistant_integration.py

@@ -661,7 +661,7 @@ def test_two_engines_emit_signals_then_brief_and_chat_works(tmp_path):
 
     assert [call["slot_id"] for call in client.chat_calls()] == [7]
     assert [call["slot_id"] for call in client.brief_calls()] == [5]
-    assert client.brief_calls()[0]["user"].startswith("New utterances:")
+    assert client.brief_calls()[0]["user"].startswith("CURRENT BRIEF:\n(none yet)")
 
 
 def test_slotless_engines_omit_id_slot(tmp_path):

+ 80 - 2
tests/test_assistant_service.py

@@ -10,11 +10,16 @@ from typing import Any
 from stt_daemon import events
 from stt_daemon.assistant.inference import InferenceJob, InferenceQueue
 from stt_daemon.assistant.service import EXECUTE_BUSY, AssistantService
+from stt_daemon.assistant.triage import TriageAnalyzer
 from stt_daemon.assistant.triage_dump import TriageDumper
 from stt_daemon.config import Config
 
 _sequence = itertools.count(1)
 
+# A real analyzer (pure, stdlib-only helpers) backing the fake's retention
+# guard so guard behaviour is exercised end to end.
+_real_analyzer = TriageAnalyzer(Config().triage)
+
 
 class Clock:
     def __init__(self, start=1000.0):
@@ -598,6 +603,21 @@ class FakeTriage:
         )
         return "BRIEF-PROMPT"
 
+    def build_pinned_brief_prompt(self, current_brief, deltas, signals):
+        self.prompt_calls.append(
+            (tuple(deltas), dict(signals) if isinstance(signals, dict) else signals)
+        )
+        body = "\n".join("- " + d for d in deltas)
+        return (
+            f"CURRENT BRIEF:\n{current_brief or '(none yet)'}\n\nNEW UTTERANCES:\n{body}"
+        )
+
+    def extract_bullets(self, brief):
+        return _real_analyzer.extract_bullets(brief)
+
+    def restore_missing_bullets(self, new_brief, prior_brief):
+        return _real_analyzer.restore_missing_bullets(new_brief, prior_brief)
+
     def parse_brief(self, text):
         if self._parsed is _AUTO:
             return text if text else None
@@ -1451,6 +1471,60 @@ def _seed_brief(service, *deltas):
     return service._brief_token
 
 
+def test_retention_guard_reappends_fact_dropped_by_model():
+    """A prior bullet the model omitted is deterministically restored."""
+    prior = (
+        "Summary: rolling\n\nPrior Details:\n"
+        "- The budget was $305 million.\n"
+        "- Sense could find prey.\n"
+    )
+    new = "Summary: newer\n\nPrior Details:\n- Sense could find prey.\n"
+    service, emitted, _, _, _ = _triage_service(brief_fn=lambda prompt: new)
+    service.last_triage = {**_escalated_payload(), "brief": prior}
+    service._pending_brief = ["Unknown: more"]
+    service._brief_outstanding = True
+
+    service._run_brief_update(0, _escalated_payload(), service._now(), service._brief_token)
+
+    assert service.last_triage is not None
+    brief = service.last_triage["brief"]
+    assert "- Sense could find prey." in brief
+    assert "- The budget was $305 million." in brief
+    assert brief.count("- Sense could find prey.") == 1
+
+
+def test_retention_guard_leaves_complete_brief_unchanged():
+    """A brief that already carries every prior bullet is stored verbatim."""
+    prior = "Prior Details:\n- fact one\n- fact two\n"
+    new = "Summary: newer\n\nPrior Details:\n- fact one\n- fact two\n"
+    service, _, _, _, _ = _triage_service(brief_fn=lambda prompt: new)
+    service.last_triage = {**_escalated_payload(), "brief": prior}
+    service._pending_brief = ["Unknown: more"]
+    service._brief_outstanding = True
+
+    service._run_brief_update(0, _escalated_payload(), service._now(), service._brief_token)
+
+    assert service.last_triage is not None
+    assert service.last_triage["brief"] == new
+
+
+def test_pinned_prompt_carries_prior_brief_and_new_utterances():
+    """The brief request hands the model the pinned current brief + new deltas."""
+    seen: list[str] = []
+    service, _, _, _, _ = _triage_service(
+        brief_fn=lambda prompt: seen.append(prompt) or "UPDATED"
+    )
+    service.last_triage = {**_escalated_payload(), "brief": "PRIOR BRIEF TEXT"}
+    service._pending_brief = ["Unknown: hello there"]
+    service._brief_outstanding = True
+
+    service._run_brief_update(0, _escalated_payload(), service._now(), service._brief_token)
+
+    assert len(seen) == 1
+    assert seen[0].startswith("CURRENT BRIEF:\nPRIOR BRIEF TEXT")
+    assert "NEW UTTERANCES:\n- Unknown: hello there" in seen[0]
+
+
 def test_brief_merges_content_and_writes_file():
     brief_file = FakeBriefFile()
     service, emitted, _, triage, _ = _triage_service(
@@ -2082,7 +2156,9 @@ def test_dump_writes_full_laya_and_llm_io(tmp_path):
     assert data["laya"]["response"] == _escalated_payload()
     assert data["laya"]["error"] is None
     assert data["llm"]["request"]["url"] == "http://llm/v1"
-    assert data["llm"]["request"]["body"] == {"prompt": "BRIEF-PROMPT"}
+    assert data["llm"]["request"]["body"] == {
+        "prompt": "CURRENT BRIEF:\n(none yet)\n\nNEW UTTERANCES:\n- Unknown: hello"
+    }
     assert data["llm"]["response"] == {"content": "parsed"}
     assert data["llm"]["error"] is None
     assert service._triage_dump._pending == {}
@@ -2303,7 +2379,9 @@ def test_dump_coalesced_update_writes_laya_only_then_llm_only_flush(tmp_path):
     assert len(flushed_records) == 1
     assert len(both) == 1
     assert coalesced[0]["ts"] != flushed_records[0]["ts"]
-    assert flushed_records[0]["llm"]["request"]["body"] == {"prompt": "BRIEF-PROMPT"}
+    assert flushed_records[0]["llm"]["request"]["body"] == {
+        "prompt": "CURRENT BRIEF:\nBRIEF\n\nNEW UTTERANCES:\n- Unknown: during call"
+    }
 
 
 def test_chat_client_failure_emits_llm_error_and_service_survives():

+ 1 - 1
tests/test_config.py

@@ -563,7 +563,7 @@ def test_triage_defaults():
     assert cfg.triage.escalate_threshold == 0.6
     assert cfg.triage.choice_min_confidence == 0.85
     assert cfg.triage.extract_enabled is True
-    assert cfg.triage.extract_max_tokens == 1536
+    assert cfg.triage.extract_max_tokens == 3072
     assert cfg.triage.extract_timeout_s == 30.0
     assert cfg.triage.brief_slot_id is None
     assert cfg.triage.brief_gate_enabled is True

+ 78 - 0
tests/test_llm.py

@@ -3,6 +3,7 @@
 import pytest
 
 from stt_daemon.assistant.llm import (
+    BriefEngine,
     ChatSession,
     ConversationEngine,
     LlmClient,
@@ -945,3 +946,80 @@ def test_chat_session_is_engine_with_chat_prompt():
     assert session.send("hi") == "hello"
     assert calls[0]["body"]["messages"][0]["content"] == LlmConfig().system_prompt_chat
     assert session.last_reply() == "hello"
+
+
+def test_brief_engine_pins_single_turn_without_history():
+    client, calls = _make_client(lambda _p: _reply(content="BRIEF v1"))
+    engine = BriefEngine(LlmConfig(), client, system_prompt="SYS", max_tokens=99)
+
+    assert engine.last_reply() == ""
+    assert engine.send("CURRENT BRIEF:\n(none yet)\n\nNEW UTTERANCES:\n- one") == "BRIEF v1"
+
+    assert engine.last_reply() == "BRIEF v1"
+    assert len(calls) == 1
+    messages = calls[0]["body"]["messages"]
+    assert messages[0] == {"role": "system", "content": "SYS"}
+    assert len(messages) == 2
+    assert messages[1]["content"].startswith("CURRENT BRIEF:")
+    assert calls[0]["body"]["max_tokens"] == 99
+
+
+def test_brief_engine_second_call_still_sends_two_messages():
+    client, calls = _make_client(lambda _p: _reply(content="BRIEF"))
+    engine = BriefEngine(LlmConfig(), client, system_prompt="SYS")
+
+    engine.send("first")
+    engine.send("second")
+
+    assert len(calls) == 2
+    for call in calls:
+        assert len(call["body"]["messages"]) == 2
+
+
+def test_brief_engine_finalize_none_leaves_pinned_brief():
+    client, _ = _make_client(lambda _p: _reply(content="raw"))
+    engine = BriefEngine(LlmConfig(), client, system_prompt="SYS")
+    engine._brief = "prior"
+
+    assert engine.send("q", finalize=lambda reply, finish: None) == ""
+    assert engine.last_reply() == "prior"
+
+
+def test_brief_engine_finalize_transform_is_pinned():
+    client, _ = _make_client(lambda _p: _reply(content="raw"))
+    engine = BriefEngine(LlmConfig(), client, system_prompt="SYS")
+
+    result = engine.send("q", finalize=lambda reply, finish: "CLEANED: " + reply)
+
+    assert result == "CLEANED: raw"
+    assert engine.last_reply() == "CLEANED: raw"
+
+
+def test_brief_engine_length_truncation_does_not_pin():
+    client, _ = _make_client(lambda _p: _reply(content="half", finish="length"))
+    engine = BriefEngine(LlmConfig(), client, system_prompt="SYS")
+
+    assert engine.send("q", finalize=lambda reply, finish: reply) == "half"
+    assert engine.last_reply() == ""
+
+
+def test_brief_engine_reset_drops_pinned_brief():
+    client, _ = _make_client(lambda _p: _reply(content="BRIEF"))
+    engine = BriefEngine(LlmConfig(), client, system_prompt="SYS")
+    engine.send("q")
+    assert engine.last_reply() == "BRIEF"
+
+    engine.reset()
+
+    assert engine.last_reply() == ""
+
+
+def test_brief_engine_passes_slot_and_io_hook():
+    client, calls = _make_client(lambda _p: _reply(content="BRIEF"))
+    engine = BriefEngine(LlmConfig(), client, system_prompt="SYS", slot_id=5)
+    seen: list[dict] = []
+
+    engine.send("q", on_io=lambda part: seen.append(part))
+
+    assert calls[0]["body"]["id_slot"] == 5
+    assert any(part["phase"] == "request" for part in seen)

+ 99 - 0
tests/test_triage.py

@@ -406,6 +406,105 @@ def test_build_brief_prompt_no_focus_without_signals(tmp_path):
         assert prompt.endswith("Return only the updated brief as plain text.")
 
 
+def test_build_pinned_brief_prompt_inlines_current_brief(tmp_path):
+    analyzer = _analyzer(tmp_path, _score_questions())
+    prompt = analyzer.build_pinned_brief_prompt(
+        "Summary: shipped\n\nPrior Details:\n- fact one",
+        ["Alice: let's ship Friday"],
+        _signals(noul_p=0.8),
+    )
+    assert prompt.startswith("CURRENT BRIEF:\nSummary: shipped")
+    assert "- fact one" in prompt
+    assert "NEW UTTERANCES:\n- Alice: let's ship Friday" in prompt
+    assert prompt.index("CURRENT BRIEF:") < prompt.index("NEW UTTERANCES:")
+    assert prompt.index("NEW UTTERANCES:") < prompt.index("Focus:")
+    assert "churn_risk" in prompt
+    assert prompt.endswith("Return only the updated brief as plain text.")
+
+
+def test_build_pinned_brief_prompt_none_brief_is_placeholder(tmp_path):
+    analyzer = _analyzer(tmp_path, _score_questions())
+    prompt = analyzer.build_pinned_brief_prompt(None, ["solo"], None)
+    assert "CURRENT BRIEF:\n(none yet)" in prompt
+    assert "NEW UTTERANCES:\n- solo" in prompt
+    assert "Focus:" not in prompt
+
+
+def test_extract_bullets_strips_new_marker_and_normalizes(tmp_path):
+    analyzer = _analyzer(tmp_path, _score_questions())
+    brief = (
+        "Summary: things\n"
+        "- top level bullet\n"
+        "  - indented child\n"
+        "- **New:** tagged fact\n"
+        "not a bullet\n"
+        "\n"
+    )
+    assert analyzer.extract_bullets(brief) == [
+        "top level bullet",
+        "indented child",
+        "tagged fact",
+    ]
+
+
+def test_restore_missing_bullets_reappends_dropped_fact(tmp_path):
+    analyzer = _analyzer(tmp_path, _score_questions())
+    prior = (
+        "Summary: old\n\nPrior Details:\n"
+        "- The budget was $305 million.\n"
+        "- Sense could find prey.\n"
+    )
+    new = "Summary: newer\n\nPrior Details:\n- Sense could find prey.\n"
+
+    restored = analyzer.restore_missing_bullets(new, prior)
+
+    assert "- Sense could find prey." in restored
+    assert "- The budget was $305 million." in restored
+    assert restored.count("- Sense could find prey.") == 1
+
+
+def test_restore_missing_bullets_matches_reworded_bullet(tmp_path):
+    analyzer = _analyzer(tmp_path, _score_questions())
+    prior = "Prior Details:\n- **New:** Sense could find prey.\n"
+    new = "Prior Details:\n- Sense could find prey.\n"
+
+    restored = analyzer.restore_missing_bullets(new, prior)
+
+    assert restored == new
+
+
+def test_restore_missing_bullets_restores_reworded_as_new_fact(tmp_path):
+    analyzer = _analyzer(tmp_path, _score_questions())
+    prior = "Prior Details:\n- The budget was $305 million.\n"
+    new = "Prior Details:\n- Budget: $305 million (nearly Spider-Man 2).\n"
+
+    restored = analyzer.restore_missing_bullets(new, prior)
+
+    assert restored.count("$305 million") == 2
+    assert restored.rstrip().endswith("- The budget was $305 million.")
+
+
+def test_restore_missing_bullets_noop_without_prior_or_new(tmp_path):
+    analyzer = _analyzer(tmp_path, _score_questions())
+    new = "Summary: only\nPrior Details:\n- keep me\n"
+
+    assert analyzer.restore_missing_bullets(new, "") == new
+    assert analyzer.restore_missing_bullets(new, None) == new
+    assert analyzer.restore_missing_bullets("", new) == ""
+    assert analyzer.restore_missing_bullets(None, new) is None
+
+
+def test_restore_missing_bullets_appends_section_when_absent(tmp_path):
+    analyzer = _analyzer(tmp_path, _score_questions())
+    prior = "- orphan fact\n"
+    new = "Summary: no prior details header\n"
+
+    restored = analyzer.restore_missing_bullets(new, prior)
+
+    assert "- orphan fact" in restored
+    assert "Prior Details:" in restored
+
+
 def test_parse_brief_strips_fences_and_rejects_empty(tmp_path):
     analyzer = _analyzer(tmp_path, _score_questions())
     assert analyzer.parse_brief("```\nhello brief\n```") == "hello brief"