Explorar o código

Tightened system prompt and temperature override for brief path

Efren Yevale Varela hai 3 semanas
pai
achega
8e670fb527

+ 2 - 1
config.example.toml

@@ -99,7 +99,7 @@ trim_block_turns               = 4
 max_tool_iterations            = 3
 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."
-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]
 enabled                   = false
@@ -114,6 +114,7 @@ choice_min_confidence     = 0.85
 extract_enabled           = true
 extract_max_tokens        = 3072
 extract_timeout_s         = 30.0
+extract_temperature       = 0.2
 brief_slot_id             = ""
 brief_gate_enabled        = true
 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
 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."
-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)
 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_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_temperature  = 0.2                  # Brief request temperature (deterministic extraction; chat keeps [llm].temperature)
 brief_slot_id        = ""                   # optional llama.cpp id_slot for the brief engine; empty/unset omits it (server needs -np 2)
 brief_gate_enabled   = true                 # fail-open gate: update the brief only when the top signal's p >= brief_gate_threshold
 brief_gate_threshold = 0.5                  # gate threshold; the top signal at/above this p triggers a brief update
@@ -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
 as narrative + a `Prior Details:` section. A recorded fact can therefore only disappear via a
 logged cap eviction, never because the model dropped it. The model reply is bounded by
-`[triage].extract_max_tokens` (default 3072) 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
 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,

+ 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_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_temperature` | `0.2` | Temperature for the brief request (deterministic extraction; chat keeps `[llm].temperature`). |
 | `brief_slot_id` | unset | Optional llama.cpp `id_slot` for the brief engine; when unset the `id_slot` field is omitted (needs a server started with `-np 2`). |
 | `brief_gate_enabled` | `true` | Fail-open gate: update the brief only when the `substantive_update` `p` is at least `brief_gate_threshold`. |
 | `brief_gate_threshold` | `0.5` | Gate threshold for `substantive_update`; the update runs at or above this `p`. |
@@ -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
 volatile narrative). It never rewrites the ledger. Code appends new facts with conservative
 dedupe, evicts the oldest past `ledger_max_facts`, truncates each past `fact_max_chars`, and
-renders the final brief. The reply is bounded by `extract_max_tokens` 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
 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
   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
   longer asks for 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,
         max_tokens: int | None = None,
         timeout_s: float | None = None,
+        temperature: float | None = None,
         on_io: Callable[[dict], None] | None = None,
         *,
         slot_id: int | None = None,
@@ -127,16 +128,18 @@ class LlmClient:
             tools: Optional tool schemas.
             max_tokens: Per-request output cap; uses cfg.max_tokens 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
                 the call and `{"phase": "response", ...}` after it.
             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_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] = {
             "messages": messages,
-            "temperature": self.cfg.temperature,
+            "temperature": effective_temperature,
             "max_tokens": effective_max_tokens,
             "cache_prompt": self.cfg.cache_prompt,
             "stream": False,
@@ -541,6 +544,7 @@ class BriefEngine:
         timeout_s: float | None = None,
         slot_id: int | None = None,
         notify: Callable[..., None] | None = None,
+        temperature: float | None = None,
     ):
         self.cfg = cfg
         self.client = client
@@ -549,6 +553,7 @@ class BriefEngine:
         self._timeout_s = timeout_s
         self._slot_id = slot_id
         self.notify = notify
+        self._temperature = temperature
         self._brief = ""
         self._generation = 0
         self._lock = threading.RLock()
@@ -590,6 +595,7 @@ class BriefEngine:
             messages,
             max_tokens=self._max_tokens,
             timeout_s=self._timeout_s,
+            temperature=self._temperature,
             **chat_kwargs,
         )
         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,
             max_tokens=cfg.triage.extract_max_tokens,
             timeout_s=cfg.triage.extract_timeout_s,
+            temperature=cfg.triage.extract_temperature,
             slot_id=cfg.triage.brief_slot_id,
             notify=None,
         )

+ 21 - 10
src/stt_daemon/config.py

@@ -152,16 +152,23 @@ class LlmConfig:
         "Output only the summary."
     )
     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
     trim_block_turns: int = 4
@@ -194,6 +201,7 @@ class TriageConfig:
     extract_enabled: bool = True
     extract_max_tokens: int = 3072
     extract_timeout_s: float = 30.0
+    extract_temperature: float = 0.2
     brief_slot_id: int | None = None
     brief_gate_enabled: bool = True
     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", 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_gate_enabled=triage_data.get(
                     "brief_gate_enabled", cfg.triage.brief_gate_enabled

+ 12 - 4
tests/test_assistant_integration.py

@@ -113,13 +113,17 @@ class _FakeChatClient:
     def __init__(self):
         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(
             {
                 "messages": messages,
                 "tools": tools,
                 "max_tokens": max_tokens,
                 "timeout_s": timeout_s,
+                "temperature": temperature,
                 "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"]
     assert brief_fn is not None
     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):
@@ -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"]
     assert brief_fn is not None
     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):
@@ -501,7 +505,10 @@ class _EngineClient:
         self.chat_reply = chat_reply
         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 ""
         is_brief = self.brief_system is not None and system == self.brief_system
         self.calls.append(
@@ -512,6 +519,7 @@ class _EngineClient:
                 "slot_id": slot_id,
                 "max_tokens": max_tokens,
                 "timeout_s": timeout_s,
+                "temperature": temperature,
             }
         )
         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.command_slot_id is None
     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 == (
         "You compress a triage briefing conversation. Preserve every opportunity, blocker, "