Prechádzať zdrojové kódy

Improved triage prompt

Efren Yevale Varela 3 týždňov pred
rodič
commit
75ae8ab79a
4 zmenil súbory, kde vykonal 250 pridanie a 29 odobranie
  1. 3 1
      docs/ASSISTANT.md
  2. 8 1
      docs/TRIAGE.md
  3. 104 27
      src/stt_daemon/assistant/triage.py
  4. 135 0
      tests/test_triage.py

+ 3 - 1
docs/ASSISTANT.md

@@ -195,7 +195,9 @@ arrives first (per-category confidences), and, when `extract_enabled` is true an
 gate on `substantive_update` passes, the assistant queues one `brief-update` job on the existing LLM
 lane. That job runs a stateless brief engine (`[llm].triage_system_prompt`) that sends one user turn
 carrying the code-owned fact ledger, the volatile narrative, and the pending speaker-labeled deltas
-plus the current signal words under a `Focus:` header; the model returns `NEW FACTS:` (only
+plus the current signal words under a `Focus:` header (each line carries the question
+instructions, measured value, gating confidence, and flagged/escalated markers; noul noise
+below p=0.30 omitted except substantive_update); the model returns `NEW FACTS:` (only
 genuinely-new durable facts) and `BRIEF:` (the narrative), code appends facts to the ledger with
 conservative dedupe and renders the final brief, which arrives as the content stage and is rendered
 below the signals. On a Laya error it still runs with the deltas only. The job is token-guarded and

+ 8 - 1
docs/TRIAGE.md

@@ -89,7 +89,14 @@ settled turn (any mode), every periodic_s while speech keeps accumulating, or a
    the existing LLM lane. The job carries the speaker-labeled pending deltas (the transcripts,
    plus labeled chat turns when `send to triage` is on, 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 **stateless**: it
+   header; a Laya error still runs with the deltas only. Each `Focus:` line carries the
+   question `instructions` (clipped to ~160 chars), the measured value (`p`, selected
+   `choice`, or `expected`/`level`/`label`), the gating confidence, and `flagged` /
+   `[escalated]` markers — e.g. `- blocker (p=0.91, flagged, gate=0.90): An obstacle ...`
+   or `- decision_status (choice): selected made (conf 0.90) — What is the state ...?
+   Options: a decision was reached`. Low-value `noul` noise (`p < 0.30`) is omitted,
+   except `substantive_update` which is always kept for gate context; `choice`/`score`
+   items are always kept with their uncertainty flagged. The brief engine is **stateless**: it
    sends exactly two messages per call — a static system prompt and ONE user turn containing
    `CURRENT LEDGER` (the recorded facts), `CURRENT BRIEF` (the volatile narrative), and
    `NEW UTTERANCES` (the deltas). No conversation history is replayed, so the prompt stays

+ 104 - 27
src/stt_daemon/assistant/triage.py

@@ -327,24 +327,85 @@ class TriageAnalyzer:
                     return True
         return False
 
-    def _signal_words(self, qid, item) -> str:
-        kind = item.get("kind")
-        if kind == "choice":
-            return "%s (choice): selected %s, confidence %.2f" % (
-                qid,
-                item.get("choice"),
-                item.get("gate"),
-            )
-        if kind == "score":
-            return "%s (score): expected %.2f, level %s (%s)" % (
-                qid,
-                item.get("expected"),
-                item.get("level"),
-                item.get("label"),
-            )
-        if kind == "noul":
-            return "%s: likelihood %.2f" % (qid, item.get("p"))
-        return "%s: flagged" % (qid,)
+    @staticmethod
+    def _clip(text, limit=160) -> str:
+        """Collapse whitespace and truncate to ~limit chars with an ellipsis."""
+        try:
+            collapsed = " ".join(str(text).split())
+        except Exception:
+            return ""
+        if len(collapsed) <= limit:
+            return collapsed
+        return collapsed[: max(0, limit - 1)].rstrip() + "…"
+
+    def _signal_words(self, qid, item, qdef=None, escalated=False) -> str:
+        try:
+            kind = item.get("kind") if isinstance(item, dict) else None
+        except Exception:
+            return "%s: flagged" % (qid,)
+        try:
+            questions = self._questions or {}
+            if qdef is None and isinstance(questions, dict):
+                candidate = questions.get(qid)
+                if isinstance(candidate, dict):
+                    qdef = candidate
+            instructions = ""
+            if isinstance(qdef, dict):
+                raw = qdef.get("instructions")
+                if isinstance(raw, str) and raw.strip():
+                    instructions = self._clip(raw.strip())
+            suffix = " [escalated]" if (escalated and isinstance(item, dict)
+                                        and item.get("flagged") is True) else ""
+            if kind == "choice":
+                choice = item.get("choice")
+                gate = _number(item.get("gate"))
+                flagged = item.get("flagged") is True
+                conf = "%.2f" % gate if gate is not None else "?"
+                head = "%s (choice): selected %s (conf %s%s)" % (
+                    qid, choice, conf, ", flagged as uncertain" if flagged else "")
+                label_def = ""
+                try:
+                    crit = qdef.get("criteria") if isinstance(qdef, dict) else None
+                    if isinstance(crit, dict) and isinstance(choice, str):
+                        definition = crit.get(choice)
+                        if isinstance(definition, str) and definition.strip():
+                            label_def = self._clip(definition.strip())
+                except Exception:
+                    label_def = ""
+                tail = " — %s" % instructions if instructions else ""
+                if label_def:
+                    tail += " Options: %s" % label_def if not tail else " Options: %s" % label_def
+                    if not instructions:
+                        tail = " Options: %s" % label_def
+                return head + tail + suffix
+            if kind == "score":
+                expected = _number(item.get("expected"))
+                level = item.get("level")
+                label = item.get("label")
+                flagged = item.get("flagged") is True
+                exp = "%.2f" % expected if expected is not None else "?"
+                head = "%s (score): expected %s, level %s (%s)%s" % (
+                    qid, exp, level, label, ", flagged" if flagged else "")
+                if instructions:
+                    head += " — %s" % instructions
+                return head + suffix
+            if kind == "noul":
+                prob = _number(item.get("p"))
+                gate = _number(item.get("gate"))
+                flagged = item.get("flagged") is True
+                prob_s = "%.2f" % prob if prob is not None else "?"
+                gate_s = ", gate=%.2f" % gate if gate is not None else ""
+                head = "%s (p=%s%s%s)" % (
+                    qid, prob_s, ", flagged" if flagged else "", gate_s)
+                if instructions:
+                    head += ": %s" % instructions
+                return head + suffix
+            return "%s: flagged" % (qid,)
+        except Exception:
+            try:
+                return "%s: flagged" % (qid,)
+            except Exception:
+                return "signal: flagged"
 
     def _brief_max_chars(self) -> int:
         try:
@@ -360,24 +421,40 @@ class TriageAnalyzer:
             return 0.5
 
     def _signal_lines(self, signals) -> list[str]:
-        """Render each ordered signal as a "- " words line; never raises."""
+        """Render each ordered signal as a "- " words line; never raises.
+
+        Filters low-value noise: noul items with p < 0.30 are omitted,
+        except the ``substantive_update`` gate arm which is always kept for
+        threshold context. Choice/score items are always kept (flagged
+        marker explains uncertainty). Original order is preserved.
+        """
         lines = []
         try:
             items = signals.get("items", {}) if isinstance(signals, dict) else {}
             order = signals.get("order", []) if isinstance(signals, dict) else []
+            escalated = signals.get("escalated") is True if isinstance(signals, dict) else False
             if not isinstance(items, dict):
-                items = {}
+                return []
             if not isinstance(order, list):
-                order = []
+                return []
+            questions = self._questions if isinstance(self._questions, dict) else {}
             for qid in order:
-                item = items.get(qid)
-                if isinstance(item, dict):
-                    try:
-                        lines.append("- " + self._signal_words(qid, item))
-                    except Exception:
+                try:
+                    item = items.get(qid)
+                    if not isinstance(item, dict):
                         continue
+                    if item.get("kind") == "noul" and qid != "substantive_update":
+                        prob = _number(item.get("p"))
+                        if prob is not None and prob < 0.30:
+                            continue
+                    qdef = questions.get(qid) if isinstance(questions, dict) else None
+                    lines.append(
+                        "- " + self._signal_words(qid, item, qdef=qdef,
+                                                  escalated=escalated))
+                except Exception:
+                    continue
         except Exception:
-            lines = []
+            return []
         return lines
 
     def build_brief_prompt(self, deltas, signals) -> str:

+ 135 - 0
tests/test_triage.py

@@ -1088,3 +1088,138 @@ def test_parse_facts_ignores_prose_and_handles_markers(tmp_path):
         "dot fact",
         "numbered fact",
     ]
+
+
+def _rich_questions():
+    return {
+        "substantive_update": {
+            "type": "noul",
+            "instructions": "This recent conversation segment adds something a running brief "
+            "should record.",
+        },
+        "blocker": {
+            "type": "noul",
+            "instructions": "An obstacle, dependency, objection, or risk that could block "
+            "or delay the work was raised.",
+        },
+        "churn_risk": {"type": "noul", "instructions": "User may leave."},
+        "category": {
+            "type": "choice",
+            "instructions": "What is this about?",
+            "criteria": {"support": "help needed", "sales": "buying"},
+        },
+        "severity": {
+            "type": "score",
+            "instructions": "How severe is the issue?",
+            "criteria": ["none", "low", "high", "critical"],
+        },
+    }
+
+
+def _rich_signals(**overrides):
+    items = {
+        "blocker": {
+            "kind": "noul",
+            "p": 0.91,
+            "confidence": 0.9,
+            "answer_confidence": 0.9,
+            "gate": 0.9,
+            "flagged": True,
+        },
+        "churn_risk": {
+            "kind": "noul",
+            "p": 0.10,
+            "confidence": 0.9,
+            "answer_confidence": 0.9,
+            "gate": 0.9,
+            "flagged": False,
+        },
+        "substantive_update": {
+            "kind": "noul",
+            "p": 0.10,
+            "confidence": 0.9,
+            "answer_confidence": 0.9,
+            "gate": 0.9,
+            "flagged": False,
+        },
+        "category": {
+            "kind": "choice",
+            "choice": "support",
+            "confidence": 0.4,
+            "answer_confidence": 0.4,
+            "gate": 0.4,
+            "flagged": True,
+        },
+        "severity": {
+            "kind": "score",
+            "expected": 2.6,
+            "level": 3,
+            "label": "critical",
+            "confidence": 0.9,
+            "answer_confidence": 0.9,
+            "gate": 0.9,
+            "flagged": True,
+        },
+    }
+    items.update(overrides.get("items", {}))
+    order = overrides.get(
+        "order", ["blocker", "churn_risk", "substantive_update", "category", "severity"])
+    return {"order": order, "items": items, "escalated": overrides.get("escalated", False),
+            "input_tokens": 0}
+
+
+def test_signal_lines_filters_low_noul_but_keeps_gate_qid(tmp_path):
+    analyzer = _analyzer(tmp_path, _rich_questions())
+    lines = analyzer._signal_lines(_rich_signals())
+    text = "\n".join(lines)
+    assert not any("churn_risk" in line for line in lines)  # p=0.10 filtered
+    assert any("substantive_update" in line for line in lines)  # gate qid kept
+    assert any("blocker" in line for line in lines)  # high-signal kept
+    assert "p=0.91" in text and "flagged" in text
+    assert "could block or delay" in text  # instructions carried
+    assert all(line.startswith("- ") for line in lines)
+    # original order preserved among kept lines
+    kept = [line for line in lines]
+    assert [i for i, line in enumerate(kept) if "blocker" in line][0] < [
+        i for i, line in enumerate(kept) if "category" in line][0]
+
+
+def test_signal_lines_choice_carries_instructions_and_flag(tmp_path):
+    analyzer = _analyzer(tmp_path, _rich_questions())
+    lines = analyzer._signal_lines(_rich_signals(order=["category"], escalated=False))
+    assert len(lines) == 1
+    assert "category (choice): selected support (conf 0.40, flagged as uncertain)" in lines[0]
+    assert "What is this about?" in lines[0]
+    assert "help needed" in lines[0]  # criteria[selected] definition
+
+
+def test_signal_lines_escalated_marker_only_on_flagged(tmp_path):
+    analyzer = _analyzer(tmp_path, _rich_questions())
+    lines = analyzer._signal_lines(_rich_signals(
+        order=["blocker", "substantive_update"], escalated=True))
+    by_qid = {line.split(" ")[1]: line for line in lines}
+    assert by_qid["blocker"].endswith(" [escalated]")
+    assert not by_qid["substantive_update"].endswith(" [escalated]")
+    plain = analyzer._signal_lines(_rich_signals(
+        order=["blocker", "substantive_update"], escalated=False))
+    assert not any("[escalated]" in line for line in plain)
+
+
+def test_signal_lines_score_format_and_instruction_cap(tmp_path):
+    questions = _rich_questions()
+    questions["severity"]["instructions"] = "x " * 200  # force long instructions
+    analyzer = _analyzer(tmp_path, questions)
+    lines = analyzer._signal_lines(_rich_signals(order=["severity"]))
+    assert len(lines) == 1
+    assert "severity (score): expected 2.60, level 3 (critical), flagged" in lines[0]
+    assert len(lines[0]) <= len("- severity (score): expected 2.60, level 3 "
+                                "(critical), flagged — ") + 160 + len(" [escalated]")
+    assert lines[0].rstrip().endswith("…")
+
+
+def test_signal_lines_never_raises_on_malformed(tmp_path):
+    analyzer = _analyzer(tmp_path, _rich_questions())
+    assert analyzer._signal_lines(None) == []  # type: ignore[arg-type]
+    assert analyzer._signal_lines({}) == []  # type: ignore[dict-item]
+    assert analyzer._signal_lines({"order": "nope", "items": "nope"}) == []  # type: ignore[dict-item]
+    assert analyzer._signal_lines({"order": ["ghost"], "items": {}}) == []