Răsfoiți Sursa

Fixed behavior

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

+ 1 - 1
config.example.toml

@@ -92,7 +92,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. Preserve prior content unless the new segment contradicts it; merge duplicates; prune stale items; keep owners, responsibilities, and deadlines explicit. Use short sections: Opportunities, Blockers/Risks, Responsibilities, Decisions, Action Items, Deadlines, Open Questions."
+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. Preserve prior content unless the new segment contradicts it; merge duplicates; prune stale items; keep owners, responsibilities, and deadlines explicit. Use short sections: 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]
 enabled                   = false

+ 1 - 1
docs/CONFIGURATION.md

@@ -116,7 +116,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. Preserve prior content unless the new segment contradicts it; merge duplicates; prune stale items; keep owners, responsibilities, and deadlines explicit. Use short sections: Opportunities, Blockers/Risks, Responsibilities, Decisions, Action Items, Deadlines, Open Questions."
+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. Preserve prior content unless the new segment contradicts it; merge duplicates; prune stale items; keep owners, responsibilities, and deadlines explicit. Use short sections: 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]                                    # Display-only signal panel (Laya lane; no socket, no extra process)
 enabled              = false                # Master gate: false = no lane, no panel, behavior identical to today

+ 5 - 2
src/stt_daemon/assistant/service.py

@@ -796,9 +796,12 @@ class AssistantService:
             if generation != self._triage_generation:
                 self._triage_dump.discard(ts)
                 return
-            self.last_triage = signals
+            prev_brief = (self.last_triage or {}).get("brief")
+            retained = prev_brief if isinstance(prev_brief, str) and prev_brief else None
+            merged = {**signals, "brief": retained} if retained else signals
+            self.last_triage = merged
             self._last_brief_signals = signals
-            self._emit(TRIAGE_EVENT, stage="signals", triage=signals, ts=ts)
+            self._emit(TRIAGE_EVENT, stage="signals", triage=merged, ts=ts)
         cfg = self.cfg.triage
         gated_in = not getattr(cfg, "brief_gate_enabled", True) or (
             self._triage.should_update_brief(signals)

+ 31 - 1
src/stt_daemon/assistant/triage.py

@@ -379,8 +379,35 @@ class TriageAnalyzer:
         parts.append("Return only the updated brief as plain text.")
         return "\n\n".join(parts)
 
+    def _unwrap_tool_call(self, text: str) -> str:
+        """Unwrap a stray model tool-call envelope into its plain brief payload.
+
+        Only fires when an explicit wrapper marker is present so clean markdown
+        containing ``[``, ``(``, or ``'`` is passed through untouched.
+        """
+        markers = ("<|tool_call_start|>", "<|tool_call_end|>", "update_brief(", "update_brief (")
+        if not any(marker in text for marker in markers):
+            return text
+        if "<|tool_call_start|>" in text:
+            text = text.split("<|tool_call_start|>", 1)[1]
+        if "<|tool_call_end|>" in text:
+            text = text.split("<|tool_call_end|>", 1)[0]
+        text = text.strip()
+        if "update_brief(" not in text and "update_brief (" not in text:
+            return text
+        # Extract the argument between the first and last matching quote. The
+        # payload itself may contain apostrophes, so pair on the LAST quote of
+        # whichever delimiter opens first.
+        positions = [(text.find(quote), quote) for quote in ("'", '"') if text.find(quote) != -1]
+        if positions:
+            first, quote = min(positions)
+            last = text.rfind(quote)
+            if last > first:
+                text = text[first + 1 : last]
+        return text.strip()
+
     def parse_brief(self, text) -> str | None:
-        """Strip a code fence; None when empty; truncate (not reject) overlong text."""
+        """Strip a code fence and any stray tool-call envelope; None when empty."""
         if not isinstance(text, str):
             return None
         cleaned = text.strip()
@@ -393,6 +420,9 @@ class TriageAnalyzer:
             if end != -1:
                 cleaned = cleaned[:end]
             cleaned = cleaned.strip()
+        if not cleaned:
+            return None
+        cleaned = self._unwrap_tool_call(cleaned)
         if not cleaned:
             return None
         limit = self._brief_max_chars()

+ 2 - 1
src/stt_daemon/config.py

@@ -150,7 +150,8 @@ class LlmConfig:
         "content unless the new segment contradicts it; merge duplicates; prune stale "
         "items; keep owners, responsibilities, and deadlines explicit. Use short sections: "
         "Opportunities, Blockers/Risks, Responsibilities, Decisions, Action Items, "
-        "Deadlines, Open Questions."
+        "Deadlines, Open Questions. Output the brief text directly; never emit tool calls, "
+        "function-call syntax, or special wrapper tokens."
     )
     inference_queue_size: int = 8
     trim_block_turns: int = 4

+ 104 - 0
tests/test_assistant_service.py

@@ -1120,6 +1120,7 @@ def test_gated_out_turn_skips_brief():
     service._on_triage_result(gen, _escalated_payload())
 
     assert service.last_triage is not None
+    assert "brief" not in service.last_triage  # no prior brief to retain
     assert [e.get("stage") for e in _triage_events(emitted)] == ["signals"]
     assert calls == []
     assert [j.name for j in service._inference._queue] == ["chat"]
@@ -1127,6 +1128,109 @@ def test_gated_out_turn_skips_brief():
     assert len(triage.gate_calls) == 1
 
 
+def test_gated_out_turn_retains_previous_brief():
+    """A gated-out settled turn keeps the last accepted brief in the panel."""
+    service, emitted, clock, triage, lane = _triage_service(
+        triage=FakeTriage(gate=False), brief_fn=lambda prompt: "RAW"
+    )
+    service.toggle_chat()
+    signals = _escalated_payload()
+    service._last_brief_signals = signals
+    token = _seed_brief(service, "Unknown: first")
+    service._run_brief_update(0, signals, service._now(), token)
+    assert service.last_triage is not None
+    assert service.last_triage["brief"] == "RAW"
+    emitted.clear()
+
+    service.handle_event(_transcript(clock, "small talk"))
+    clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
+    service.tick()
+    _, _, _, gen = lane.submits[0]
+
+    service._on_triage_result(gen, _escalated_payload())
+
+    assert service.last_triage is not None
+    assert service.last_triage["brief"] == "RAW"
+    assert "brief" not in service._last_brief_signals  # raw signals stay raw
+    staged = [e for e in _triage_events(emitted) if e.get("stage") == "signals"]
+    assert len(staged) == 1
+    assert staged[0]["triage"] == service.last_triage
+    assert staged[0]["triage"]["brief"] == "RAW"
+    assert len(triage.gate_calls) == 1
+
+
+def test_gated_out_turn_retains_brief_when_extract_disabled():
+    """`extract_enabled = false` skips the dispatch but still retains the brief."""
+    service, emitted, clock, _, lane = _triage_service(
+        triage=FakeTriage(gate=False), brief_fn=lambda prompt: "RAW"
+    )
+    service.toggle_chat()
+    signals = _escalated_payload()
+    service._last_brief_signals = signals
+    token = _seed_brief(service, "Unknown: first")
+    service._run_brief_update(0, signals, service._now(), token)
+    assert service.last_triage is not None
+    assert service.last_triage["brief"] == "RAW"
+    service.cfg.triage.extract_enabled = False
+    emitted.clear()
+
+    service.handle_event(_transcript(clock, "small talk"))
+    clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
+    service.tick()
+    _, _, _, gen = lane.submits[0]
+
+    service._on_triage_result(gen, _escalated_payload())
+
+    assert service.last_triage is not None
+    assert service.last_triage["brief"] == "RAW"
+    assert [j.name for j in service._inference._queue] == ["chat"]
+    staged = [e for e in _triage_events(emitted) if e.get("stage") == "signals"]
+    assert staged[0]["triage"]["brief"] == "RAW"
+
+
+def test_gated_out_turn_retains_brief_without_brief_fn():
+    """No brief_fn means no dispatch, but a retained brief is still carried."""
+    service, emitted, clock, _, lane = _triage_service(triage=FakeTriage(gate=False))
+    service.toggle_chat()
+    service.last_triage = {**_escalated_payload(), "brief": "KEEP"}
+    emitted.clear()
+
+    service.handle_event(_transcript(clock, "small talk"))
+    clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
+    service.tick()
+    _, _, _, gen = lane.submits[0]
+
+    service._on_triage_result(gen, _escalated_payload())
+
+    assert service.last_triage["brief"] == "KEEP"
+    staged = [e for e in _triage_events(emitted) if e.get("stage") == "signals"]
+    assert staged[0]["triage"]["brief"] == "KEEP"
+
+
+def test_later_accepted_update_replaces_retained_brief():
+    """A later accepted update overwrites the retained brief text."""
+    texts = iter(["OLD", "NEW"])
+    service, emitted, _, _, _ = _triage_service(brief_fn=lambda prompt: next(texts))
+    service.toggle_chat()
+    signals = _escalated_payload()
+    service._last_brief_signals = signals
+    token = _seed_brief(service, "Unknown: first")
+    service._run_brief_update(0, signals, service._now(), token)
+    assert service.last_triage is not None
+    assert service.last_triage["brief"] == "OLD"
+
+    token = _seed_brief(service, "Unknown: second")
+    service._run_brief_update(0, signals, service._now(), token)
+
+    assert service.last_triage is not None
+    assert service.last_triage["brief"] == "NEW"
+    contents = [e for e in _triage_events(emitted) if e.get("stage") == "content"]
+    assert contents[-1]["triage"]["brief"] == "NEW"
+
+    service.clear()
+    assert service.last_triage is None
+
+
 def test_no_brief_when_disabled_in_config():
     calls: list[str] = []
     service, _, clock, _, lane = _triage_service(

+ 2 - 1
tests/test_config.py

@@ -604,7 +604,8 @@ def test_llm_slot_and_brief_prompt_defaults():
         "content unless the new segment contradicts it; merge duplicates; prune stale "
         "items; keep owners, responsibilities, and deadlines explicit. Use short sections: "
         "Opportunities, Blockers/Risks, Responsibilities, Decisions, Action Items, "
-        "Deadlines, Open Questions."
+        "Deadlines, Open Questions. Output the brief text 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, "

+ 39 - 0
tests/test_triage.py

@@ -425,6 +425,8 @@ def test_parse_brief_truncates_not_rejects(tmp_path):
     assert parsed is not None
     assert parsed == "x" * 4000
     assert len(parsed) == 4000
+    wrapped = "[update_brief(updated_brief='" + "x" * 5000 + "')]"
+    assert analyzer.parse_brief(wrapped) == "x" * 4000
 
 
 def test_parse_brief_honors_configured_cap(tmp_path):
@@ -432,6 +434,43 @@ def test_parse_brief_honors_configured_cap(tmp_path):
     assert analyzer.parse_brief("x" * 50) == "x" * 10
 
 
+def test_parse_brief_unwraps_single_quoted_tool_call_with_apostrophes(tmp_path):
+    analyzer = _analyzer(tmp_path, _score_questions())
+    observed = "<|tool_call_start|>[update_brief(updated_brief='the company's game failed')]"
+    assert analyzer.parse_brief(observed) == "the company's game failed"
+
+
+def test_parse_brief_unwraps_double_quoted_tool_call(tmp_path):
+    analyzer = _analyzer(tmp_path, _score_questions())
+    wrapped = '[update_brief(updated_brief="the company\'s plan")]'
+    assert analyzer.parse_brief(wrapped) == "the company's plan"
+
+
+def test_parse_brief_unwraps_trailing_end_marker(tmp_path):
+    analyzer = _analyzer(tmp_path, _score_questions())
+    wrapped = "[update_brief(updated_brief='ship friday')]<|tool_call_end|>"
+    assert analyzer.parse_brief(wrapped) == "ship friday"
+
+
+def test_parse_brief_unwraps_fenced_and_wrapped(tmp_path):
+    analyzer = _analyzer(tmp_path, _score_questions())
+    wrapped = "```\n<|tool_call_start|>[update_brief(updated_brief='fenced payload')]\n```"
+    assert analyzer.parse_brief(wrapped) == "fenced payload"
+
+
+def test_parse_brief_does_not_mangle_clean_markdown(tmp_path):
+    analyzer = _analyzer(tmp_path, _score_questions())
+    clean = "Here's the [brief]: it's the company's plan - don't mangle [it]."
+    assert analyzer.parse_brief(clean) == clean
+    prose = "We should update_brief the notes, here's [why]."
+    assert analyzer.parse_brief(prose) == prose
+
+
+def test_parse_brief_empty_tool_call_payload_is_none(tmp_path):
+    analyzer = _analyzer(tmp_path, _score_questions())
+    assert analyzer.parse_brief("[update_brief(updated_brief='')]") is None
+
+
 def _brief_signals(p=None):
     item = {"kind": "noul"}
     if p is not None: