Efren Yevale Varela 3 тижнів тому
батько
коміт
75ae8ab79a
4 змінених файлів з 250 додано та 29 видалено
  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
 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
 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
 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
 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
 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
 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,
    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
    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:`
    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
    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
    `CURRENT LEDGER` (the recorded facts), `CURRENT BRIEF` (the volatile narrative), and
    `NEW UTTERANCES` (the deltas). No conversation history is replayed, so the prompt stays
    `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 True
         return False
         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:
     def _brief_max_chars(self) -> int:
         try:
         try:
@@ -360,24 +421,40 @@ class TriageAnalyzer:
             return 0.5
             return 0.5
 
 
     def _signal_lines(self, signals) -> list[str]:
     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 = []
         lines = []
         try:
         try:
             items = signals.get("items", {}) if isinstance(signals, dict) else {}
             items = signals.get("items", {}) if isinstance(signals, dict) else {}
             order = signals.get("order", []) 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):
             if not isinstance(items, dict):
-                items = {}
+                return []
             if not isinstance(order, list):
             if not isinstance(order, list):
-                order = []
+                return []
+            questions = self._questions if isinstance(self._questions, dict) else {}
             for qid in order:
             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
                         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:
         except Exception:
-            lines = []
+            return []
         return lines
         return lines
 
 
     def build_brief_prompt(self, deltas, signals) -> str:
     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",
         "dot fact",
         "numbered 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": {}}) == []