Bläddra i källkod

Tightened system prompt and temperature override for brief path

Efren Yevale Varela 3 veckor sedan
förälder
incheckning
8e670fb527

+ 2 - 1
config.example.toml

@@ -99,7 +99,7 @@ trim_block_turns               = 4
 max_tool_iterations            = 3
 max_tool_iterations            = 3
 system_prompt_chat             = "You are a concise voice assistant. Reply in 1-3 sentences."
 system_prompt_chat             = "You are a concise voice assistant. Reply in 1-3 sentences."
 system_prompt_command          = "You translate a spoken request into a single tool call when one applies."
 system_prompt_command          = "You translate a spoken request into a single tool call when one applies."
-triage_system_prompt           = "You maintain a living brief of a business conversation. Given the current brief (your previous reply, if any) and a new transcript segment, return the UPDATED brief as plain text only, with no markdown fences and no JSON. Open with a Summary section: a rolling overview of the conversation so far, one to a few sentences for a short session and growing to a short paragraph or two as the session extends. Preserve prior content unless the new segment contradicts it; merge duplicates; prune stale items; keep owners, responsibilities, and deadlines explicit. Use short sections: Summary, Opportunities, Blockers/Risks, Responsibilities, Decisions, Action Items, Deadlines, Open Questions. Output the brief text directly; never emit tool calls, function-call syntax, or special wrapper tokens."
+triage_system_prompt           = "You maintain a living brief of a business conversation. The caller sends CURRENT LEDGER plus CURRENT BRIEF plus NEW UTTERANCES. Reply with exactly two sections in this order: NEW FACTS: first, then BRIEF:. Facts go first so a truncated reply still preserves extraction (truncation safety). In NEW FACTS:, emit one fact per line as '- <fact>', or the single line 'none' when the new utterances add no new facts. Never repeat facts already listed in CURRENT LEDGER. In BRIEF:, return the UPDATED brief as plain text only, with no markdown fences and no JSON. Open with a Summary section: a rolling overview of the conversation so far, one to a few sentences for a short session and growing to a short paragraph or two as the session extends. Preserve prior content unless the new utterances contradict it; merge duplicates; prune stale items; keep owners, responsibilities, and deadlines explicit. Use short sections: Summary, Opportunities, Blockers/Risks, Responsibilities, Decisions, Action Items, Deadlines, Open Questions. Never emit a 'Prior Details:' section. Output the two sections directly; never emit tool calls, function-call syntax, or special wrapper tokens."
 
 
 [triage]
 [triage]
 enabled                   = false
 enabled                   = false
@@ -114,6 +114,7 @@ choice_min_confidence     = 0.85
 extract_enabled           = true
 extract_enabled           = true
 extract_max_tokens        = 3072
 extract_max_tokens        = 3072
 extract_timeout_s         = 30.0
 extract_timeout_s         = 30.0
+extract_temperature       = 0.2
 brief_slot_id             = ""
 brief_slot_id             = ""
 brief_gate_enabled        = true
 brief_gate_enabled        = true
 brief_gate_threshold      = 0.5
 brief_gate_threshold      = 0.5

+ 4 - 2
docs/CONFIGURATION.md

@@ -123,7 +123,7 @@ trim_block_turns         = 4                # fallback block trimming, not per-t
 max_tool_iterations      = 3
 max_tool_iterations      = 3
 system_prompt_chat       = "You are a concise voice assistant. Reply in 1-3 sentences."
 system_prompt_chat       = "You are a concise voice assistant. Reply in 1-3 sentences."
 system_prompt_command    = "You translate a spoken request into a single tool call when one applies."
 system_prompt_command    = "You translate a spoken request into a single tool call when one applies."
-triage_system_prompt     = "You maintain a living brief of a business conversation. Given the current brief (your previous reply, if any) and a new transcript segment, return the UPDATED brief as plain text only, with no markdown fences and no JSON. Open with a Summary section: a rolling overview of the conversation so far, one to a few sentences for a short session and growing to a short paragraph or two as the session extends. Preserve prior content unless the new segment contradicts it; merge duplicates; prune stale items; keep owners, responsibilities, and deadlines explicit. Use short sections: Summary, Opportunities, Blockers/Risks, Responsibilities, Decisions, Action Items, Deadlines, Open Questions. Output the brief text directly; never emit tool calls, function-call syntax, or special wrapper tokens."
+triage_system_prompt     = "You maintain a living brief of a business conversation. The caller sends CURRENT LEDGER plus CURRENT BRIEF plus NEW UTTERANCES. Reply with exactly two sections in this order: NEW FACTS: first, then BRIEF:. Facts go first so a truncated reply still preserves extraction (truncation safety). In NEW FACTS:, emit one fact per line as '- <fact>', or the single line 'none' when the new utterances add no new facts. Never repeat facts already listed in CURRENT LEDGER. In BRIEF:, return the UPDATED brief as plain text only, with no markdown fences and no JSON. Open with a Summary section: a rolling overview of the conversation so far, one to a few sentences for a short session and growing to a short paragraph or two as the session extends. Preserve prior content unless the new utterances contradict it; merge duplicates; prune stale items; keep owners, responsibilities, and deadlines explicit. Use short sections: Summary, Opportunities, Blockers/Risks, Responsibilities, Decisions, Action Items, Deadlines, Open Questions. Never emit a 'Prior Details:' section. Output the two sections directly; never emit tool calls, function-call syntax, or special wrapper tokens."
 
 
 [triage]                                    # Display-only signal panel (Laya lane; no socket, no extra process)
 [triage]                                    # Display-only signal panel (Laya lane; no socket, no extra process)
 enabled              = false                # Master gate: false = no lane, no panel, behavior identical to today
 enabled              = false                # Master gate: false = no lane, no panel, behavior identical to today
@@ -138,6 +138,7 @@ choice_min_confidence = 0.85                # choice answers below this confiden
 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; the brief is then capped to brief_max_chars characters
 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)
 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
@@ -248,7 +249,8 @@ it parses the reply, appends new facts with conservative dedupe, evicts oldest p
 `ledger_max_facts` (logged), truncates facts past `fact_max_chars`, and renders the final brief
 `ledger_max_facts` (logged), truncates facts past `fact_max_chars`, and renders the final brief
 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) and `extract_timeout_s`, and the rendered brief is
+`[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
 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
 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,
 when the reply is truncated. Because the brief engine keeps no history,

+ 6 - 4
docs/TRIAGE.md

@@ -303,6 +303,7 @@ file.
 | `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; the brief is then truncated to `brief_max_chars`. |
 | `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`). |
 | `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`. |
@@ -321,8 +322,8 @@ narrative (`CURRENT BRIEF`), and the new segment. The model must return exactly
 `NEW FACTS:` (only genuinely-new durable facts, one per `- ` line, or `none`) and `BRIEF:` (the
 `NEW FACTS:` (only genuinely-new durable facts, one per `- ` line, or `none`) and `BRIEF:` (the
 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` and `extract_timeout_s`,
-then the rendered brief is truncated to `brief_max_chars`. Because the brief engine keeps no
+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
 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.
 
 
@@ -380,8 +381,9 @@ 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
 - `[triage].extract_enabled` still exists and is still the master switch, but it now gates
   the brief update, not a JSON extraction.
   the brief update, not a JSON extraction.
-- `[triage].extract_max_tokens` (default raised to `3072`) is the brief reply cap, and
-  `extract_timeout_s` is the brief request timeout.
+- `[triage].extract_max_tokens` (default raised to `3072`) is the brief reply cap,
+  `extract_timeout_s` is the brief request timeout, and `extract_temperature` (default `0.2`)
+  is the brief request temperature.
 - `[llm].triage_system_prompt` is now a plain-text brief instruction; the shipped default no
 - `[llm].triage_system_prompt` is now a plain-text brief instruction; the shipped default no
   longer asks for JSON.
   longer asks for JSON.
 - The panel's `content` stage now carries a `brief` string instead of merged JSON.
 - The panel's `content` stage now carries a `brief` string instead of merged JSON.

+ 7 - 1
src/stt_daemon/assistant/llm.py

@@ -116,6 +116,7 @@ class LlmClient:
         tools: list[dict] | None = None,
         tools: list[dict] | None = None,
         max_tokens: int | None = None,
         max_tokens: int | None = None,
         timeout_s: float | None = None,
         timeout_s: float | None = None,
+        temperature: float | None = None,
         on_io: Callable[[dict], None] | None = None,
         on_io: Callable[[dict], None] | None = None,
         *,
         *,
         slot_id: int | None = None,
         slot_id: int | None = None,
@@ -127,16 +128,18 @@ class LlmClient:
             tools: Optional tool schemas.
             tools: Optional tool schemas.
             max_tokens: Per-request output cap; uses cfg.max_tokens when None.
             max_tokens: Per-request output cap; uses cfg.max_tokens when None.
             timeout_s: Per-request timeout; uses cfg.timeout_s when None.
             timeout_s: Per-request timeout; uses cfg.timeout_s when None.
+            temperature: Per-request temperature; uses cfg.temperature when None.
             on_io: Optional hook receiving `{"phase": "request", ...}` before
             on_io: Optional hook receiving `{"phase": "request", ...}` before
                 the call and `{"phase": "response", ...}` after it.
                 the call and `{"phase": "response", ...}` after it.
             slot_id: Optional llama.cpp slot id (`id_slot`); omitted when None.
             slot_id: Optional llama.cpp slot id (`id_slot`); omitted when None.
         """
         """
         effective_max_tokens = self.cfg.max_tokens if max_tokens is None else max_tokens
         effective_max_tokens = self.cfg.max_tokens if max_tokens is None else max_tokens
         effective_timeout   = self.cfg.timeout_s  if timeout_s is None else timeout_s
         effective_timeout   = self.cfg.timeout_s  if timeout_s is None else timeout_s
+        effective_temperature = self.cfg.temperature if temperature is None else temperature
 
 
         body: dict[str, Any] = {
         body: dict[str, Any] = {
             "messages": messages,
             "messages": messages,
-            "temperature": self.cfg.temperature,
+            "temperature": effective_temperature,
             "max_tokens": effective_max_tokens,
             "max_tokens": effective_max_tokens,
             "cache_prompt": self.cfg.cache_prompt,
             "cache_prompt": self.cfg.cache_prompt,
             "stream": False,
             "stream": False,
@@ -541,6 +544,7 @@ class BriefEngine:
         timeout_s: float | None = None,
         timeout_s: float | None = None,
         slot_id: int | None = None,
         slot_id: int | None = None,
         notify: Callable[..., None] | None = None,
         notify: Callable[..., None] | None = None,
+        temperature: float | None = None,
     ):
     ):
         self.cfg = cfg
         self.cfg = cfg
         self.client = client
         self.client = client
@@ -549,6 +553,7 @@ class BriefEngine:
         self._timeout_s = timeout_s
         self._timeout_s = timeout_s
         self._slot_id = slot_id
         self._slot_id = slot_id
         self.notify = notify
         self.notify = notify
+        self._temperature = temperature
         self._brief = ""
         self._brief = ""
         self._generation = 0
         self._generation = 0
         self._lock = threading.RLock()
         self._lock = threading.RLock()
@@ -590,6 +595,7 @@ class BriefEngine:
             messages,
             messages,
             max_tokens=self._max_tokens,
             max_tokens=self._max_tokens,
             timeout_s=self._timeout_s,
             timeout_s=self._timeout_s,
+            temperature=self._temperature,
             **chat_kwargs,
             **chat_kwargs,
         )
         )
         reply = response.content
         reply = response.content

+ 1 - 0
src/stt_daemon/assistant/runner.py

@@ -38,6 +38,7 @@ def build_triage(cfg, client, on_result=None):
             system_prompt=cfg.llm.triage_system_prompt,
             system_prompt=cfg.llm.triage_system_prompt,
             max_tokens=cfg.triage.extract_max_tokens,
             max_tokens=cfg.triage.extract_max_tokens,
             timeout_s=cfg.triage.extract_timeout_s,
             timeout_s=cfg.triage.extract_timeout_s,
+            temperature=cfg.triage.extract_temperature,
             slot_id=cfg.triage.brief_slot_id,
             slot_id=cfg.triage.brief_slot_id,
             notify=None,
             notify=None,
         )
         )

+ 21 - 10
src/stt_daemon/config.py

@@ -152,16 +152,23 @@ class LlmConfig:
         "Output only the summary."
         "Output only the summary."
     )
     )
     triage_system_prompt: str = (
     triage_system_prompt: str = (
-        "You maintain a living brief of a business conversation. Given the current brief "
-        "(your previous reply, if any) and a new transcript segment, return the UPDATED "
-        "brief as plain text only, with no markdown fences and no JSON. Open with a Summary "
-        "section: a rolling overview of the conversation so far, one to a few sentences for "
-        "a short session and growing to a short paragraph or two as the session extends. "
-        "Preserve prior content unless the new segment contradicts it; merge duplicates; "
-        "prune stale items; keep owners, responsibilities, and deadlines explicit. Use short "
-        "sections: Summary, Opportunities, Blockers/Risks, Responsibilities, Decisions, "
-        "Action Items, Deadlines, Open Questions. Output the brief text directly; never "
-        "emit tool calls, function-call syntax, or special wrapper tokens."
+        "You maintain a living brief of a business conversation. The caller sends "
+        "CURRENT LEDGER plus CURRENT BRIEF plus NEW UTTERANCES. Reply with exactly "
+        "two sections in this order: NEW FACTS: first, then BRIEF:. Facts go first "
+        "so a truncated reply still preserves extraction (truncation safety). In "
+        "NEW FACTS:, emit one fact per line as '- <fact>', or the single line "
+        "'none' when the new utterances add no new facts. Never repeat facts "
+        "already listed in CURRENT LEDGER. In BRIEF:, return the UPDATED brief as "
+        "plain text only, with no markdown fences and no JSON. Open with a Summary "
+        "section: a rolling overview of the conversation so far, one to a few "
+        "sentences for a short session and growing to a short paragraph or two as "
+        "the session extends. Preserve prior content unless the new utterances "
+        "contradict it; merge duplicates; prune stale items; keep owners, "
+        "responsibilities, and deadlines explicit. Use short sections: Summary, "
+        "Opportunities, Blockers/Risks, Responsibilities, Decisions, Action Items, "
+        "Deadlines, Open Questions. Never emit a 'Prior Details:' section. Output "
+        "the two sections directly; never emit tool calls, function-call syntax, "
+        "or special wrapper tokens."
     )
     )
     inference_queue_size: int = 8
     inference_queue_size: int = 8
     trim_block_turns: int = 4
     trim_block_turns: int = 4
@@ -194,6 +201,7 @@ class TriageConfig:
     extract_enabled: bool = True
     extract_enabled: bool = True
     extract_max_tokens: int = 3072
     extract_max_tokens: int = 3072
     extract_timeout_s: float = 30.0
     extract_timeout_s: float = 30.0
+    extract_temperature: float = 0.2
     brief_slot_id: int | None = None
     brief_slot_id: int | None = None
     brief_gate_enabled: bool = True
     brief_gate_enabled: bool = True
     brief_gate_threshold: float = 0.5
     brief_gate_threshold: float = 0.5
@@ -575,6 +583,9 @@ def load_config(path: str | None = None) -> Config:
                 extract_timeout_s=triage_data.get(
                 extract_timeout_s=triage_data.get(
                     "extract_timeout_s", cfg.triage.extract_timeout_s
                     "extract_timeout_s", cfg.triage.extract_timeout_s
                 ),
                 ),
+                extract_temperature=triage_data.get(
+                    "extract_temperature", cfg.triage.extract_temperature
+                ),
                 brief_slot_id=_optional_int(triage_data.get("brief_slot_id")),
                 brief_slot_id=_optional_int(triage_data.get("brief_slot_id")),
                 brief_gate_enabled=triage_data.get(
                 brief_gate_enabled=triage_data.get(
                     "brief_gate_enabled", cfg.triage.brief_gate_enabled
                     "brief_gate_enabled", cfg.triage.brief_gate_enabled

+ 12 - 4
tests/test_assistant_integration.py

@@ -113,13 +113,17 @@ class _FakeChatClient:
     def __init__(self):
     def __init__(self):
         self.calls = []
         self.calls = []
 
 
-    def chat(self, messages, tools=None, max_tokens=None, timeout_s=None, slot_id=None):
+    def chat(
+        self, messages, tools=None, max_tokens=None, timeout_s=None,
+        temperature=None, slot_id=None,
+    ):
         self.calls.append(
         self.calls.append(
             {
             {
                 "messages": messages,
                 "messages": messages,
                 "tools": tools,
                 "tools": tools,
                 "max_tokens": max_tokens,
                 "max_tokens": max_tokens,
                 "timeout_s": timeout_s,
                 "timeout_s": timeout_s,
+                "temperature": temperature,
                 "slot_id": slot_id,
                 "slot_id": slot_id,
             }
             }
         )
         )
@@ -389,7 +393,7 @@ def test_runner_wires_brief_engine_slot_when_configured(monkeypatch, tmp_path):
     brief_fn = made_services[0].kwargs["brief_fn"]
     brief_fn = made_services[0].kwargs["brief_fn"]
     assert brief_fn is not None
     assert brief_fn is not None
     assert brief_fn("BRIEF-PROMPT") == "hi there"
     assert brief_fn("BRIEF-PROMPT") == "hi there"
-    assert calls[0]["kwargs"] == {"slot_id": 5}
+    assert calls[0]["kwargs"] == {"temperature": 0.2, "slot_id": 5}
 
 
 
 
 def test_runner_wires_slotless_brief_engine_when_slot_unset(monkeypatch, tmp_path):
 def test_runner_wires_slotless_brief_engine_when_slot_unset(monkeypatch, tmp_path):
@@ -406,7 +410,7 @@ def test_runner_wires_slotless_brief_engine_when_slot_unset(monkeypatch, tmp_pat
     brief_fn = made_services[0].kwargs["brief_fn"]
     brief_fn = made_services[0].kwargs["brief_fn"]
     assert brief_fn is not None
     assert brief_fn is not None
     assert brief_fn("BRIEF-PROMPT") == "hi there"
     assert brief_fn("BRIEF-PROMPT") == "hi there"
-    assert calls[0]["kwargs"] == {}
+    assert calls[0]["kwargs"] == {"temperature": 0.2}
 
 
 
 
 def test_runner_brief_file_writes_when_enabled(monkeypatch, tmp_path):
 def test_runner_brief_file_writes_when_enabled(monkeypatch, tmp_path):
@@ -501,7 +505,10 @@ class _EngineClient:
         self.chat_reply = chat_reply
         self.chat_reply = chat_reply
         self.brief_system: str | None = None
         self.brief_system: str | None = None
 
 
-    def chat(self, messages, tools=None, max_tokens=None, timeout_s=None, slot_id=None):
+    def chat(
+        self, messages, tools=None, max_tokens=None, timeout_s=None,
+        temperature=None, slot_id=None,
+    ):
         system = messages[0].get("content") if messages else ""
         system = messages[0].get("content") if messages else ""
         is_brief = self.brief_system is not None and system == self.brief_system
         is_brief = self.brief_system is not None and system == self.brief_system
         self.calls.append(
         self.calls.append(
@@ -512,6 +519,7 @@ class _EngineClient:
                 "slot_id": slot_id,
                 "slot_id": slot_id,
                 "max_tokens": max_tokens,
                 "max_tokens": max_tokens,
                 "timeout_s": timeout_s,
                 "timeout_s": timeout_s,
+                "temperature": temperature,
             }
             }
         )
         )
         if is_brief and self.brief_error:
         if is_brief and self.brief_error:

+ 17 - 10
tests/test_config.py

@@ -677,16 +677,23 @@ def test_llm_slot_and_brief_prompt_defaults():
     assert cfg.llm.chat_slot_id is None
     assert cfg.llm.chat_slot_id is None
     assert cfg.llm.command_slot_id is None
     assert cfg.llm.command_slot_id is None
     assert cfg.llm.triage_system_prompt == (
     assert cfg.llm.triage_system_prompt == (
-        "You maintain a living brief of a business conversation. Given the current brief "
-        "(your previous reply, if any) and a new transcript segment, return the UPDATED "
-        "brief as plain text only, with no markdown fences and no JSON. Open with a Summary "
-        "section: a rolling overview of the conversation so far, one to a few sentences for "
-        "a short session and growing to a short paragraph or two as the session extends. "
-        "Preserve prior content unless the new segment contradicts it; merge duplicates; "
-        "prune stale items; keep owners, responsibilities, and deadlines explicit. Use short "
-        "sections: Summary, Opportunities, Blockers/Risks, Responsibilities, Decisions, "
-        "Action Items, Deadlines, Open Questions. Output the brief text directly; never "
-        "emit tool calls, function-call syntax, or special wrapper tokens."
+        "You maintain a living brief of a business conversation. The caller sends "
+        "CURRENT LEDGER plus CURRENT BRIEF plus NEW UTTERANCES. Reply with exactly "
+        "two sections in this order: NEW FACTS: first, then BRIEF:. Facts go first "
+        "so a truncated reply still preserves extraction (truncation safety). In "
+        "NEW FACTS:, emit one fact per line as '- <fact>', or the single line "
+        "'none' when the new utterances add no new facts. Never repeat facts "
+        "already listed in CURRENT LEDGER. In BRIEF:, return the UPDATED brief as "
+        "plain text only, with no markdown fences and no JSON. Open with a Summary "
+        "section: a rolling overview of the conversation so far, one to a few "
+        "sentences for a short session and growing to a short paragraph or two as "
+        "the session extends. Preserve prior content unless the new utterances "
+        "contradict it; merge duplicates; prune stale items; keep owners, "
+        "responsibilities, and deadlines explicit. Use short sections: Summary, "
+        "Opportunities, Blockers/Risks, Responsibilities, Decisions, Action Items, "
+        "Deadlines, Open Questions. Never emit a 'Prior Details:' section. Output "
+        "the two sections directly; never emit tool calls, function-call syntax, "
+        "or special wrapper tokens."
     )
     )
     assert cfg.llm.brief_compaction_system_prompt == (
     assert cfg.llm.brief_compaction_system_prompt == (
         "You compress a triage briefing conversation. Preserve every opportunity, blocker, "
         "You compress a triage briefing conversation. Preserve every opportunity, blocker, "