Kaynağa Gözat

Improved process

Efren Yevale Varela 4 gün önce
ebeveyn
işleme
b93e461c3f

+ 2 - 0
config.example.toml

@@ -106,6 +106,8 @@ enabled                   = false
 questions_file            = "questions.json"
 questions_file            = "questions.json"
 window                    = 500
 window                    = 500
 state_char_budget         = 3000
 state_char_budget         = 3000
+state_include_speaker     = true
+confirm_windows           = 2
 periodic_s                = 30.0
 periodic_s                = 30.0
 predict_max_tokens        = 1024
 predict_max_tokens        = 1024
 confidence_field          = "answer_confidence"
 confidence_field          = "answer_confidence"

+ 24 - 0
docs/CLI.md

@@ -220,6 +220,30 @@ Example, using it as a gate:
 uv run stt-daemon speaker-eval --labeled labeled/ --min-accuracy 0.9
 uv run stt-daemon speaker-eval --labeled labeled/ --min-accuracy 0.9
 ```
 ```
 
 
+### `triage-eval` — Calibrate the triage questions on a saved session
+
+Replays a session's `transcripts.jsonl` through the same classifier state the assistant builds
+(speaker-labeled, condensed under `state_char_budget`) and prints, per question, the mean `p`
+and how often it would fire at each threshold. Use it before editing `threshold` values in
+`questions.json` — a question that fires on most windows carries no information.
+
+```bash
+stt-daemon triage-eval --session transcripts/2026-10-27-14-11-44
+stt-daemon triage-eval --session transcripts/2026-10-27-14-11-44 --window 10 --stride 10
+stt-daemon triage-eval --session transcripts/2026-10-27-14-11-44 --thresholds 0.6,0.7,0.8
+```
+
+| Flag | Default | Meaning |
+|---|---|---|
+| `--session` | required | Session directory containing `transcripts.jsonl` |
+| `--window` | `12` | Utterances per classifier window |
+| `--stride` | window size | Utterances between windows; smaller overlaps them |
+| `--thresholds` | `0.5,0.55,...,0.95` | Thresholds to sweep |
+
+It loads the Laya checkpoint (cached after the first run) and needs `uv sync --extra triage`.
+The trailing block suggests, per question, the lowest threshold that keeps the flag rare
+(<= 25% of windows); paste those into `questions.json` as `threshold` keys.
+
 ### (Legacy) `--list-sources` flag
 ### (Legacy) `--list-sources` flag
 
 
 ```bash
 ```bash

+ 2 - 0
docs/CONFIGURATION.md

@@ -130,6 +130,8 @@ enabled              = false                # Master gate: false = no lane, no p
 questions_file       = "questions.json"     # Laya questions JSON in the cwd; missing/invalid -> no_questions
 questions_file       = "questions.json"     # Laya questions JSON in the cwd; missing/invalid -> no_questions
 window               = 500                    # Utterances available per classification; the char budget is the real limiter
 window               = 500                    # Utterances available per classification; the char budget is the real limiter
 state_char_budget    = 3000                 # Char cap (~750 tokens) for the condensed state sent to Laya
 state_char_budget    = 3000                 # Char cap (~750 tokens) for the condensed state sent to Laya
+state_include_speaker = true                # Prefix each classifier line with the speaker's given name ("Efren: ...")
+confirm_windows      = 2                    # Consecutive windows a noul must clear its threshold before it is flagged; <= 1 = single-window
 periodic_s           = 30.0                 # Accumulating-speech cadence (s): while utterances keep arriving without a settled turn, re-run triage every N seconds; <= 0 disables
 periodic_s           = 30.0                 # Accumulating-speech cadence (s): while utterances keep arriving without a settled turn, re-run triage every N seconds; <= 0 disables
 predict_max_tokens   = 1024                 # Max tokens sent to Laya; the multilingual checkpoint supports 1024; state shares this budget with the ~192-token question head (~830-token state room)
 predict_max_tokens   = 1024                 # Max tokens sent to Laya; the multilingual checkpoint supports 1024; state shares this budget with the ~192-token question head (~830-token state room)
 confidence_field     = "answer_confidence"  # Gating confidence: "answer_confidence" (calibrated max(p)) | "confidence"
 confidence_field     = "answer_confidence"  # Gating confidence: "answer_confidence" (calibrated max(p)) | "confidence"

+ 16 - 2
docs/TRIAGE.md

@@ -20,7 +20,13 @@ opt-in, so a plain `uv sync` and the existing test suite behave exactly as befor
   - `choice`: the selected label plus the gating confidence, for example `decision_status: made (0.91)`.
   - `choice`: the selected label plus the gating confidence, for example `decision_status: made (0.91)`.
   - `noul`: a ten-cell bar filled from `p`, the probability, for example `███████   blocker: 0.72`.
   - `noul`: a ten-cell bar filled from `p`, the probability, for example `███████   blocker: 0.72`.
   - `score`: a ten-cell bar filled from the clamped integer level plus the level label.
   - `score`: a ten-cell bar filled from the clamped integer level plus the level label.
-  - A `⚠` mark follows any item the analyzer flagged.
+  - A `⚠` mark follows an item that has *just* become flagged; a signal that stays hot keeps
+    its bar but loses the mark, so the mark means "new", not "still true".
+  - `↑` / `↓` after the value when the probability moved by at least 0.05 since the previous
+    pass, and a `choice` row shows its previous label (`decision_status: made (0.91) ← proposed`)
+    when it flipped.
+  - A trailing `(N low signals hidden)` line when low-probability `noul` answers were omitted
+    from the pane, so a short list never looks like a lost row.
 - **`content`** after a brief update: the plain-text brief arrives as a `content` stage and
 - **`content`** after a brief update: the plain-text brief arrives as a `content` stage and
   the panel appends it below the signals. The brief is written by the model, so the panel
   the panel appends it below the signals. The brief is written by the model, so the panel
   shows it as-is; the accepted brief is never truncated.
   shows it as-is; the accepted brief is never truncated.
@@ -215,6 +221,12 @@ Each definition needs `type` and `instructions`. `criteria` depends on the type:
 | `score` | list, required | non-empty; must not contain `null`; entries are the level labels in order |
 | `score` | list, required | non-empty; must not contain `null`; entries are the level labels in order |
 | `noul` | dict, optional | if present, keys must be only `"true"` and/or `"false"` (case-insensitive); omit it for a plain yes/no question |
 | `noul` | dict, optional | if present, keys must be only `"true"` and/or `"false"` (case-insensitive); omit it for a plain yes/no question |
 
 
+Every definition may also carry an optional `threshold` (a number in `(0, 1]`) that overrides
+`[triage].escalate_threshold` for that question alone. Use it when a question simply fires more
+often than the others: `deadline` typically reads high in a meeting where every other sentence
+mentions a date, so it needs a stricter cut than `action_item`. `triage-eval` prints a suggested
+value per question from real sessions (see the CLI reference).
+
 Anything else fails validation. The whole file is rejected (and triage reports
 Anything else fails validation. The whole file is rejected (and triage reports
 `no_questions`) when the top level is not a non-empty object, when any definition is not an
 `no_questions`) when the top level is not a non-empty object, when any definition is not an
 object, when `type` is not one of `choice` / `score` / `noul`, when `instructions` is
 object, when `type` is not one of `choice` / `score` / `noul`, when `instructions` is
@@ -308,6 +320,8 @@ file.
 | `questions_file` | `"questions.json"` | Laya questions JSON in the working directory. Missing or invalid means `no_questions`. |
 | `questions_file` | `"questions.json"` | Laya questions JSON in the working directory. Missing or invalid means `no_questions`. |
 | `window` | `500` | Recent utterance entries available per classification (transcripts, plus labeled chat turns when send-to-triage is on); the char budget is the real limiter. |
 | `window` | `500` | Recent utterance entries available per classification (transcripts, plus labeled chat turns when send-to-triage is on); the char budget is the real limiter. |
 | `state_char_budget` | `3000` | Character cap (~750 tokens) for the condensed state sent to Laya. |
 | `state_char_budget` | `3000` | Character cap (~750 tokens) for the condensed state sent to Laya. |
+| `state_include_speaker` | `true` | Prefix every classifier line with the speaker's given name (`Efren: ...`). Ownership-sensitive questions (`responsibility_clarity`, `action_item`, `deadline`) classify better with the attributor; set false to send bare utterances. |
+| `confirm_windows` | `2` | Consecutive windows a `noul` must clear its threshold before it is flagged. One noisy window can no longer light the panel; `<= 1` restores single-window behaviour. |
 | `periodic_s` | `30.0` | Accumulating-speech cadence in seconds: while new utterances keep arriving without a settled turn, triage re-runs every `periodic_s`; `<= 0` disables the periodic trigger. |
 | `periodic_s` | `30.0` | Accumulating-speech cadence in seconds: while new utterances keep arriving without a settled turn, triage re-runs every `periodic_s`; `<= 0` disables the periodic trigger. |
 | `predict_max_tokens` | `1024` | Max tokens sent to Laya; the multilingual checkpoint supports 1024; state shares this budget with the ~192-token question head (~830-token state room). |
 | `predict_max_tokens` | `1024` | Max tokens sent to Laya; the multilingual checkpoint supports 1024; state shares this budget with the ~192-token question head (~830-token state room). |
 | `confidence_field` | `"answer_confidence"` | Which Laya confidence feeds the per-item `flagged`/`⚠` marker: `answer_confidence` (calibrated `max(p)`) or `confidence` (normalized entropy). |
 | `confidence_field` | `"answer_confidence"` | Which Laya confidence feeds the per-item `flagged`/`⚠` marker: `answer_confidence` (calibrated `max(p)`) or `confidence` (normalized entropy). |
@@ -318,7 +332,7 @@ file.
 | `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`). |
 | `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 when the `substantive_update` `p` is at least `brief_gate_threshold`, when a `noul` becomes flagged, or when a `choice` label changed since the previous pass. With no previous pass only the `substantive_update` arm applies. |
 | `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`. |
 | `brief_max_chars` | `4000` | Budget for the `CURRENT BRIEF` sent to the model; over this the prompt view is trimmed on a line boundary. The accepted brief is never truncated. |
 | `brief_max_chars` | `4000` | Budget for the `CURRENT BRIEF` sent to the model; over this the prompt view is trimmed on a line boundary. The accepted brief is never truncated. |
 | `brief_pending_char_budget` | `3000` | Pending transcript-character budget before a coalesced update is flushed; the oldest deltas are dropped past it. |
 | `brief_pending_char_budget` | `3000` | Pending transcript-character budget before a coalesced update is flushed; the oldest deltas are dropped past it. |

+ 52 - 4
src/stt_daemon/assistant/service.py

@@ -19,7 +19,7 @@ from ..config import Config
 from .executor import ExecutionResult
 from .executor import ExecutionResult
 from .inference import InferenceJob, InferenceQueue
 from .inference import InferenceJob, InferenceQueue
 from .modes import Mode, ModeMachine
 from .modes import Mode, ModeMachine
-from .triage import FactLedger
+from .triage import FactLedger, window_line
 from .triage_dump import TriageDumper
 from .triage_dump import TriageDumper
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
@@ -170,6 +170,7 @@ class AssistantService:
         self._last_triage_state: tuple[str, str] | None = None
         self._last_triage_state: tuple[str, str] | None = None
         self.last_triage: dict | None = None
         self.last_triage: dict | None = None
         self._last_triage_window: tuple = ()
         self._last_triage_window: tuple = ()
+        self._signal_streaks: dict[str, int] = {}
         # Periodic cadence for accumulating speech (no settled turn): armed to
         # Periodic cadence for accumulating speech (no settled turn): armed to
         # `now + periodic_s` on the first transcript of a burst and re-armed on
         # `now + periodic_s` on the first transcript of a burst and re-armed on
         # every accepted submit. None while disarmed (periodic_s <= 0 or after a
         # every accepted submit. None while disarmed (periodic_s <= 0 or after a
@@ -630,6 +631,14 @@ class AssistantService:
         )
         )
         return str(raw) if named else "Unknown"
         return str(raw) if named else "Unknown"
 
 
+    def _triage_window_line(self, label: str, text: str) -> str:
+        """One classifier-state line: speaker-attributed when configured."""
+        try:
+            include = bool(getattr(self.cfg.triage, "state_include_speaker", True))
+        except Exception:  # noqa: BLE001 - a config quirk must not drop a turn
+            include = True
+        return window_line(label, text, include)
+
     # ------------------------------------------------------------------ events
     # ------------------------------------------------------------------ events
 
 
     def handle_event(self, event: dict) -> None:
     def handle_event(self, event: dict) -> None:
@@ -846,12 +855,13 @@ class AssistantService:
         if not text:
         if not text:
             return
             return
         self._last_activity_ts = now
         self._last_activity_ts = now
+        label = self._speaker_display(event)
         with self._lock:
         with self._lock:
-            self.triage_history.append(text)
+            self.triage_history.append(self._triage_window_line(label, text))
             del self.triage_history[:-_TRIAGE_HISTORY_MAX]
             del self.triage_history[:-_TRIAGE_HISTORY_MAX]
         if self._triage is not None:
         if self._triage is not None:
             with self._lock:
             with self._lock:
-                self._pending_brief.append(f"{self._speaker_display(event)}: {text}")
+                self._pending_brief.append(f"{label}: {text}")
                 self._cap_pending_brief_locked()
                 self._cap_pending_brief_locked()
                 periodic_s = self._periodic_s()
                 periodic_s = self._periodic_s()
                 if periodic_s > 0 and self._triage_next_due is None:
                 if periodic_s > 0 and self._triage_next_due is None:
@@ -1037,6 +1047,41 @@ class AssistantService:
                 "max_len": int(getattr(self.cfg.triage, "predict_max_tokens", 1024)),
                 "max_len": int(getattr(self.cfg.triage, "predict_max_tokens", 1024)),
             }
             }
 
 
+    def _apply_dwell(self, signals: dict) -> None:
+        """Lock-HELD: unflag a noul that has not held for ``confirm_windows``.
+
+        Laya answers each window independently, so one noisy window used to flip
+        every marker. A signal now stays flagged only while it keeps clearing its
+        own threshold for consecutive windows; ``confirm_windows <= 1`` keeps the
+        historical single-window behaviour.
+        """
+        try:
+            confirm = int(getattr(self.cfg.triage, "confirm_windows", 2))
+        except (TypeError, ValueError):
+            confirm = 2
+        if confirm <= 1:
+            return
+        items = signals.get("items")
+        if not isinstance(items, dict):
+            return
+        changed = False
+        for qid, item in items.items():
+            if not isinstance(item, dict) or item.get("kind") != "noul":
+                continue
+            prob = item.get("p")
+            threshold = item.get("threshold")
+            if not isinstance(prob, (int, float)) or not isinstance(threshold, (int, float)):
+                continue
+            above = prob >= threshold
+            streak = self._signal_streaks.get(qid, 0) + 1 if above else 0
+            self._signal_streaks[qid] = streak
+            want = above and streak >= confirm
+            if bool(item.get("flagged")) != want:
+                item["flagged"] = want
+                changed = True
+        if changed:
+            signals["escalated"] = self._triage.should_escalate(signals)
+
     def _on_triage_result(self, generation, payload) -> None:
     def _on_triage_result(self, generation, payload) -> None:
         if self._triage is None:
         if self._triage is None:
             return
             return
@@ -1066,6 +1111,8 @@ class AssistantService:
             if generation != self._triage_generation:
             if generation != self._triage_generation:
                 self._triage_dump.discard(ts)
                 self._triage_dump.discard(ts)
                 return
                 return
+            self._apply_dwell(signals)
+            prior_signals = self.last_triage
             prev_brief = (self.last_triage or {}).get("brief")
             prev_brief = (self.last_triage or {}).get("brief")
             retained = prev_brief if isinstance(prev_brief, str) and prev_brief else None
             retained = prev_brief if isinstance(prev_brief, str) and prev_brief else None
             merged = {**signals, "brief": retained} if retained else signals
             merged = {**signals, "brief": retained} if retained else signals
@@ -1080,7 +1127,7 @@ class AssistantService:
         )
         )
         cfg = self.cfg.triage
         cfg = self.cfg.triage
         gated_in = not getattr(cfg, "brief_gate_enabled", True) or (
         gated_in = not getattr(cfg, "brief_gate_enabled", True) or (
-            self._triage.should_update_brief(signals)
+            self._triage.should_update_brief(signals, prior_signals)
         )
         )
         if (
         if (
             self._brief_fn is not None
             self._brief_fn is not None
@@ -1332,6 +1379,7 @@ class AssistantService:
         self._last_triage_window = ()
         self._last_triage_window = ()
         self._triage_next_due = None
         self._triage_next_due = None
         self.triage_history = []
         self.triage_history = []
+        self._signal_streaks.clear()
         if self._fact_ledger is not None:
         if self._fact_ledger is not None:
             self._fact_ledger.clear()
             self._fact_ledger.clear()
         self._last_brief_narrative = ""
         self._last_brief_narrative = ""

+ 121 - 15
src/stt_daemon/assistant/triage.py

@@ -12,6 +12,7 @@ Public API (consumed by the assistant service layer):
     TRIAGE_REASONS = ("ok", "no_questions", "loading", "laya_missing",
     TRIAGE_REASONS = ("ok", "no_questions", "loading", "laya_missing",
                       "disabled", "error")
                       "disabled", "error")
 
 
+    def window_line(label, text, include_speaker=True) -> str
     def load_questions(path: str) -> dict | None
     def load_questions(path: str) -> dict | None
 
 
     class TriageAnalyzer:
     class TriageAnalyzer:
@@ -60,6 +61,8 @@ TRIAGE_REASONS = ("ok", "no_questions", "loading", "laya_missing", "disabled", "
 
 
 _VALID_TYPES = ("choice", "score", "noul")
 _VALID_TYPES = ("choice", "score", "noul")
 
 
+_LOW_SIGNAL_P = 0.30
+
 # Canonical brief sections in model emission order; regression detection and the
 # Canonical brief sections in model emission order; regression detection and the
 # reply template both depend on this exact list.
 # reply template both depend on this exact list.
 BRIEF_HEADINGS = (
 BRIEF_HEADINGS = (
@@ -102,6 +105,11 @@ def load_questions(path: str) -> dict | None:
             )
             )
         if "instructions" not in qdef:
         if "instructions" not in qdef:
             return _invalid(path, qid, "no 'instructions'; add the text the model should answer")
             return _invalid(path, qid, "no 'instructions'; add the text the model should answer")
+        threshold = qdef.get("threshold")
+        if threshold is not None:
+            value = _number(threshold)
+            if value is None or not 0.0 < value <= 1.0:
+                return _invalid(path, qid, "'threshold' must be a number in (0, 1]")
         crit = qdef.get("criteria")
         crit = qdef.get("criteria")
         if qtype == "choice":
         if qtype == "choice":
             if not isinstance(crit, (dict, list)):
             if not isinstance(crit, (dict, list)):
@@ -126,6 +134,14 @@ def load_questions(path: str) -> dict | None:
     return data
     return data
 
 
 
 
+def window_line(label, text, include_speaker: bool = True) -> str:
+    """One classifier-state line; the given name keeps it to one extra token."""
+    if not include_speaker:
+        return str(text)
+    short = str(label or "").strip().split()
+    return "%s: %s" % (short[0] if short else "Unknown", text)
+
+
 def _number(value):
 def _number(value):
     if isinstance(value, bool):
     if isinstance(value, bool):
         return None
         return None
@@ -265,16 +281,36 @@ class TriageAnalyzer:
             if confidence is None or answer_confidence is None:
             if confidence is None or answer_confidence is None:
                 return None
                 return None
             gate = confidence if field == "confidence" else answer_confidence
             gate = confidence if field == "confidence" else answer_confidence
+            threshold = self._question_threshold(qid, qdef)
             return {
             return {
                 "kind": "noul",
                 "kind": "noul",
                 "p": prob,
                 "p": prob,
                 "confidence": confidence,
                 "confidence": confidence,
                 "answer_confidence": answer_confidence,
                 "answer_confidence": answer_confidence,
                 "gate": gate,
                 "gate": gate,
-                "flagged": prob >= self._escalate_threshold(),
+                "threshold": threshold,
+                "flagged": prob >= threshold,
             }
             }
         return None
         return None
 
 
+    @staticmethod
+    def _signal_visible(qid, item) -> bool:
+        """False for a low-probability noul the panel and prompts omit."""
+        if not isinstance(item, dict) or item.get("kind") != "noul":
+            return True
+        if qid in ("substantive_update", "opportunity"):
+            return True
+        prob = _number(item.get("p"))
+        return prob is None or prob >= _LOW_SIGNAL_P
+
+    def _question_threshold(self, qid, qdef) -> float:
+        """Per-question noul threshold, falling back to ``escalate_threshold``."""
+        if isinstance(qdef, dict):
+            value = _number(qdef.get("threshold"))
+            if value is not None and 0.0 < value <= 1.0:
+                return value
+        return self._escalate_threshold()
+
     def normalize(self, payload) -> dict:
     def normalize(self, payload) -> dict:
         """Normalize a full laya result or a bare answers dict into signals."""
         """Normalize a full laya result or a bare answers dict into signals."""
         result = {"order": [], "items": {}, "escalated": False, "input_tokens": 0}
         result = {"order": [], "items": {}, "escalated": False, "input_tokens": 0}
@@ -301,6 +337,8 @@ class TriageAnalyzer:
                 continue
                 continue
             result["order"].append(qid)
             result["order"].append(qid)
             result["items"][qid] = item
             result["items"][qid] = item
+            if not self._signal_visible(qid, item):
+                result["hidden"] = result.get("hidden", 0) + 1
         result["escalated"] = self.should_escalate(result)
         result["escalated"] = self.should_escalate(result)
         return result
         return result
 
 
@@ -325,7 +363,12 @@ class TriageAnalyzer:
             kind = item.get("kind")
             kind = item.get("kind")
             if kind == "noul":
             if kind == "noul":
                 prob = _number(item.get("p"))
                 prob = _number(item.get("p"))
-                if prob is not None and prob >= self._escalate_threshold():
+                if prob is None:
+                    continue
+                threshold = _number(item.get("threshold"))
+                if threshold is None:
+                    threshold = self._escalate_threshold()
+                if prob >= threshold:
                     return True
                     return True
             elif kind == "choice":
             elif kind == "choice":
                 gate = _number(item.get("gate"))
                 gate = _number(item.get("gate"))
@@ -482,13 +525,8 @@ class TriageAnalyzer:
                     item = items.get(qid)
                     item = items.get(qid)
                     if not isinstance(item, dict):
                     if not isinstance(item, dict):
                         continue
                         continue
-                    if item.get("kind") == "noul" and qid not in (
-                        "substantive_update",
-                        "opportunity",
-                    ):
-                        prob = _number(item.get("p"))
-                        if prob is not None and prob < 0.30:
-                            continue
+                    if not self._signal_visible(qid, item):
+                        continue
                     qdef = questions.get(qid) if isinstance(questions, dict) else None
                     qdef = questions.get(qid) if isinstance(questions, dict) else None
                     lines.append(
                     lines.append(
                         "- " + self._signal_words(qid, item, qdef=qdef,
                         "- " + self._signal_words(qid, item, qdef=qdef,
@@ -506,6 +544,41 @@ class TriageAnalyzer:
             pass
             pass
         return lines
         return lines
 
 
+    def _signal_compact(self, signals) -> list[str]:
+        """Render signals as bare ``qid: value`` lines for the brief prompt.
+
+        Deliberately omits each question's instructions and criterion text: the
+        brief model used to copy them into the document as if they were meeting
+        content ("- Type: ...", "- Gate: 0.97"). Only the classifier's verdict
+        belongs in the prompt.
+        """
+        lines: list[str] = []
+        try:
+            items = signals.get("items", {}) if isinstance(signals, dict) else {}
+            order = signals.get("order", []) if isinstance(signals, dict) else []
+            if not isinstance(items, dict) or not isinstance(order, list):
+                return []
+            for qid in order:
+                item = items.get(qid)
+                if not isinstance(item, dict) or not self._signal_visible(qid, item):
+                    continue
+                kind = item.get("kind")
+                if kind == "noul":
+                    prob = _number(item.get("p"))
+                    value = "%.2f" % prob if prob is not None else "?"
+                    if item.get("flagged") is True:
+                        value += " (high)"
+                elif kind in ("choice", "score"):
+                    value = str(item.get("label") if kind == "score" else item.get("choice"))
+                    if item.get("flagged") is True:
+                        value += " (uncertain)"
+                else:
+                    value = "flagged" if item.get("flagged") is True else "?"
+                lines.append("- %s: %s" % (qid, value))
+        except Exception:
+            return []
+        return lines
+
     def build_brief_prompt(self, deltas, signals) -> str:
     def build_brief_prompt(self, deltas, signals) -> str:
         """Render new speaker-labeled utterances + focus words; no JSON keys."""
         """Render new speaker-labeled utterances + focus words; no JSON keys."""
         try:
         try:
@@ -540,9 +613,9 @@ class TriageAnalyzer:
             "CURRENT BRIEF:\n" + (current or "(none yet)"),
             "CURRENT BRIEF:\n" + (current or "(none yet)"),
             "NEW UTTERANCES:\n" + body,
             "NEW UTTERANCES:\n" + body,
         ]
         ]
-        focus = self._signal_lines(signals)
+        focus = self._signal_compact(signals)
         if focus:
         if focus:
-            parts.append("Focus:\n" + "\n".join(focus))
+            parts.append("Focus (classifier verdicts, not meeting content):\n" + "\n".join(focus))
         instructions = [
         instructions = [
             "Reply with exactly these two sections and nothing else:\n"
             "Reply with exactly these two sections and nothing else:\n"
             "NEW FACTS:\n"
             "NEW FACTS:\n"
@@ -557,7 +630,9 @@ class TriageAnalyzer:
             " Lead the Summary with currently-open opportunities first, and keep "
             " Lead the Summary with currently-open opportunities first, and keep "
             "the Opportunities section the most complete and current without "
             "the Opportunities section the most complete and current without "
             "dropping other sections. Emit every section heading above, even when "
             "dropping other sections. Emit every section heading above, even when "
-            "a section is empty.",
+            "a section is empty. Never copy the Focus block, its field names, or "
+            "its numbers into the brief: the brief records what was said, not how "
+            "the segment was classified.",
         ]
         ]
         if trimmed:
         if trimmed:
             instructions.append(
             instructions.append(
@@ -825,20 +900,51 @@ class TriageAnalyzer:
                 return None
                 return None
         return cleaned
         return cleaned
 
 
-    def should_update_brief(self, signals) -> bool:
-        """Fail-open gate on the substantive_update noul arm."""
+    def should_update_brief(self, signals, prior=None) -> bool:
+        """Fail-open gate: the substantive_update arm, or any changed signal.
+
+        A single arm decided this before, so a low reading there froze the brief
+        while the panel kept moving. A newly hot noul or a flipped choice label
+        is exactly the kind of turn a brief must record, so those fire too.
+        """
         if not isinstance(signals, dict):
         if not isinstance(signals, dict):
             return True
             return True
         items = signals.get("items", {})
         items = signals.get("items", {})
         if not isinstance(items, dict):
         if not isinstance(items, dict):
             return True
             return True
+        if "substantive_update" not in items:
+            return True
         item = items.get("substantive_update")
         item = items.get("substantive_update")
         if not isinstance(item, dict):
         if not isinstance(item, dict):
             return True
             return True
         prob = _number(item.get("p"))
         prob = _number(item.get("p"))
         if prob is None:
         if prob is None:
             return True
             return True
-        return prob >= self._brief_gate_threshold()
+        threshold = _number(item.get("threshold"))
+        if threshold is None:
+            threshold = self._brief_gate_threshold()
+        if prob >= threshold:
+            return True
+        prior_items = {}
+        if isinstance(prior, dict):
+            candidate = prior.get("items")
+            if isinstance(candidate, dict):
+                prior_items = candidate
+        if not prior_items:
+            return False
+        for qid, current in items.items():
+            if not isinstance(current, dict):
+                continue
+            previous = prior_items.get(qid)
+            before = previous if isinstance(previous, dict) else {}
+            kind = current.get("kind")
+            if kind == "noul":
+                if current.get("flagged") is True and before.get("flagged") is not True:
+                    return True
+            elif kind == "choice":
+                if current.get("choice") != before.get("choice"):
+                    return True
+        return False
 
 
     def is_regression(self, prior_brief, new_brief) -> bool:
     def is_regression(self, prior_brief, new_brief) -> bool:
         """True when ``new_brief`` lost a canonical section the prior one had.
         """True when ``new_brief`` lost a canonical section the prior one had.

+ 94 - 0
src/stt_daemon/cli.py

@@ -288,6 +288,36 @@ def main() -> int:
         help="Fail (exit 1) when accuracy is below this fraction",
         help="Fail (exit 1) when accuracy is below this fraction",
     )
     )
 
 
+    triage_eval_parser = subparsers.add_parser(
+        "triage-eval",
+        parents=[common],
+        help="Replay a saved session through the triage questions to calibrate thresholds",
+    )
+    triage_eval_parser.add_argument(
+        "--session",
+        type=str,
+        required=True,
+        help="Session directory containing transcripts.jsonl",
+    )
+    triage_eval_parser.add_argument(
+        "--window",
+        type=int,
+        default=12,
+        help="Utterances per classifier window (default: 12)",
+    )
+    triage_eval_parser.add_argument(
+        "--stride",
+        type=int,
+        default=None,
+        help="Utterances between windows (default: the window size)",
+    )
+    triage_eval_parser.add_argument(
+        "--thresholds",
+        type=str,
+        default="0.50,0.55,0.60,0.65,0.70,0.75,0.80,0.85,0.90,0.95",
+        help="Comma-separated thresholds to sweep (default: 0.50 to 0.95 in 0.05 steps)",
+    )
+
     args = parser.parse_args()
     args = parser.parse_args()
 
 
     # Handle legacy compatibility: --list-sources at top level
     # Handle legacy compatibility: --list-sources at top level
@@ -315,6 +345,8 @@ def main() -> int:
         return _handle_speaker_enroll_seed(args)
         return _handle_speaker_enroll_seed(args)
     elif args.command == "speaker-eval":
     elif args.command == "speaker-eval":
         return _handle_speaker_eval(args)
         return _handle_speaker_eval(args)
+    elif args.command == "triage-eval":
+        return _handle_triage_eval(args)
     else:
     else:
         # No subcommand given: run daemon with optional check
         # No subcommand given: run daemon with optional check
         if hasattr(args, "check") and args.check:
         if hasattr(args, "check") and args.check:
@@ -720,6 +752,68 @@ def _handle_speaker_enroll_seed(args) -> int:
     return 0
     return 0
 
 
 
 
+def _handle_triage_eval(args) -> int:
+    """Replay a saved session through Laya and print per-question fire rates."""
+    from .assistant.triage import TriageAnalyzer
+    from .triage_eval import analyze_states, format_report, load_records, replay_states
+
+    try:
+        cfg = load_config(args.config if hasattr(args, "config") else None)
+    except Exception as e:
+        print(f"Error: {e}", file=sys.stderr)
+        return 1
+    analyzer = TriageAnalyzer(cfg.triage)
+    if not analyzer.questions:
+        print(
+            "No questions loaded: check [triage] questions_file in config.toml",
+            file=sys.stderr,
+        )
+        return 1
+    records = load_records(args.session)
+    if not records:
+        print(f"No transcripts found under {args.session}", file=sys.stderr)
+        return 1
+    try:
+        cuts = tuple(float(part) for part in str(args.thresholds).split(",") if part.strip())
+    except ValueError:
+        print("Error: --thresholds must be a comma-separated list of numbers", file=sys.stderr)
+        return 1
+    states = replay_states(records, analyzer, window=args.window, stride=args.stride)
+    if not states:
+        print("No classifier windows could be built from this session", file=sys.stderr)
+        return 1
+    try:
+        from .assistant.laya_lane import device_or_none
+        from laya import Router
+    except Exception as e:
+        print(
+            f"Laya is not installed ({e}); run `uv sync --extra triage`",
+            file=sys.stderr,
+        )
+        return 1
+    try:
+        router = Router(
+            default=cfg.triage.default_checkpoint,
+            device=device_or_none(cfg.triage.device),
+        )
+        router.preload(list(cfg.triage.preload))
+    except Exception as e:
+        print(f"Error loading the Laya router: {e}", file=sys.stderr)
+        return 1
+    questions = analyzer.questions
+
+    def predict(state: str):
+        return router.predict(
+            state,
+            questions,
+            max_len=getattr(cfg.triage, "predict_max_tokens", 1024),
+        )
+
+    reports = analyze_states(states, analyzer, predict, thresholds=cuts)
+    print(format_report(reports, len(states), thresholds=cuts))
+    return 0
+
+
 def _handle_speaker_eval(args) -> int:
 def _handle_speaker_eval(args) -> int:
     """Score the speaker store against a labeled WAV tree (read-only)."""
     """Score the speaker store against a labeled WAV tree (read-only)."""
     try:
     try:

+ 8 - 0
src/stt_daemon/config.py

@@ -202,6 +202,8 @@ class TriageConfig:
     questions_file: str = "questions.json"
     questions_file: str = "questions.json"
     window: int = 500
     window: int = 500
     state_char_budget: int = 3000
     state_char_budget: int = 3000
+    state_include_speaker: bool = True
+    confirm_windows: int = 2
     periodic_s: float = 30.0
     periodic_s: float = 30.0
     predict_max_tokens: int = 1024
     predict_max_tokens: int = 1024
     confidence_field: str = "answer_confidence"
     confidence_field: str = "answer_confidence"
@@ -570,6 +572,12 @@ def load_config(path: str | None = None) -> Config:
                 state_char_budget=triage_data.get(
                 state_char_budget=triage_data.get(
                     "state_char_budget", cfg.triage.state_char_budget
                     "state_char_budget", cfg.triage.state_char_budget
                 ),
                 ),
+                state_include_speaker=triage_data.get(
+                    "state_include_speaker", cfg.triage.state_include_speaker
+                ),
+                confirm_windows=triage_data.get(
+                    "confirm_windows", cfg.triage.confirm_windows
+                ),
                 periodic_s=triage_data.get("periodic_s", cfg.triage.periodic_s),
                 periodic_s=triage_data.get("periodic_s", cfg.triage.periodic_s),
                 predict_max_tokens=triage_data.get(
                 predict_max_tokens=triage_data.get(
                     "predict_max_tokens", cfg.triage.predict_max_tokens
                     "predict_max_tokens", cfg.triage.predict_max_tokens

+ 189 - 0
src/stt_daemon/triage_eval.py

@@ -0,0 +1,189 @@
+"""Calibration report for the Laya triage questions over a saved session.
+
+Pure and stdlib-only: the predictor is injected, so the tests never load a model.
+The report answers the question threshold tuning actually needs — how often each
+question fires across real windows, and at which threshold that changes.
+"""
+
+from __future__ import annotations
+
+import json
+from dataclasses import dataclass, field
+from pathlib import Path
+
+from .assistant.triage import TriageAnalyzer, _number, window_line
+
+DEFAULT_THRESHOLDS = (0.50, 0.55, 0.60, 0.65, 0.70, 0.75, 0.80, 0.85, 0.90, 0.95)
+
+
+@dataclass
+class QuestionReport:
+    qid: str
+    kind: str
+    windows: int = 0
+    mean_p: float = 0.0
+    fires: dict[float, int] = field(default_factory=dict)
+    labels: dict[str, int] = field(default_factory=dict)
+
+
+def load_records(session_dir: str | Path) -> list[dict]:
+    """Read a session's JSONL transcripts; [] when the file is missing or empty."""
+    path = Path(session_dir) / "transcripts.jsonl"
+    records: list[dict] = []
+    try:
+        with open(path, encoding="utf-8") as handle:
+            for line in handle:
+                line = line.strip()
+                if not line:
+                    continue
+                try:
+                    record = json.loads(line)
+                except ValueError:
+                    continue
+                if isinstance(record, dict) and str(record.get("text") or "").strip():
+                    records.append(record)
+    except OSError:
+        return []
+    return records
+
+
+def replay_states(
+    records: list[dict],
+    analyzer: TriageAnalyzer,
+    window: int = 12,
+    stride: int | None = None,
+) -> list[str]:
+    """Non-overlapping (or strided) classifier states in chronological order."""
+    if window <= 0:
+        return []
+    step = stride if isinstance(stride, int) and stride > 0 else window
+    states: list[str] = []
+    index = 0
+    while index < len(records):
+        chunk = records[index : index + window]
+        lines = [
+            window_line(_label(record), str(record.get("text") or ""))
+            for record in chunk
+        ]
+        state = analyzer.build_state(lines)
+        if state:
+            states.append(state)
+        index += step
+    return states
+
+
+def _label(record: dict) -> str:
+    if record.get("speaker_status") != "matched":
+        return "Unknown"
+    name = record.get("speaker")
+    return name if isinstance(name, str) and name.strip() else "Unknown"
+
+
+def analyze_states(
+    states: list[str],
+    analyzer: TriageAnalyzer,
+    predict,
+    thresholds=DEFAULT_THRESHOLDS,
+) -> list[QuestionReport]:
+    """Run ``predict`` over each state and aggregate per-question behaviour."""
+    cuts = tuple(sorted({float(t) for t in thresholds}))
+    reports: dict[str, QuestionReport] = {}
+    sums: dict[str, float] = {}
+    for state in states:
+        try:
+            signals = analyzer.normalize(predict(state))
+        except Exception:
+            continue
+        items = signals.get("items") if isinstance(signals, dict) else None
+        order = signals.get("order") if isinstance(signals, dict) else None
+        if not isinstance(items, dict) or not isinstance(order, list):
+            continue
+        for qid in order:
+            item = items.get(qid)
+            if not isinstance(item, dict):
+                continue
+            kind = str(item.get("kind") or "?")
+            report = reports.get(qid)
+            if report is None:
+                report = QuestionReport(
+                    qid=qid, kind=kind, fires={cut: 0 for cut in cuts}
+                )
+                reports[qid] = report
+            report.windows += 1
+            if kind == "noul":
+                prob = _number(item.get("p"))
+                if prob is None:
+                    continue
+                sums[qid] = sums.get(qid, 0.0) + prob
+                for cut in cuts:
+                    if prob >= cut:
+                        report.fires[cut] += 1
+            elif kind == "choice":
+                label = item.get("choice")
+                if isinstance(label, str):
+                    report.labels[label] = report.labels.get(label, 0) + 1
+    for qid, report in reports.items():
+        if report.kind == "noul" and report.windows:
+            report.mean_p = sums.get(qid, 0.0) / report.windows
+    order = [qid for qid in (analyzer.questions or {}) if qid in reports]
+    order.extend(sorted(qid for qid in reports if qid not in order))
+    return [reports[qid] for qid in order]
+
+
+def suggest_thresholds(
+    reports: list[QuestionReport], cuts, target: float = 0.25
+) -> dict[str, float]:
+    """Lowest threshold whose fire share stays at or below ``target``.
+
+    A flag that fires on most windows carries no information, so the suggestion
+    answers "what number should this question's ``threshold`` be" from real data.
+    """
+    ordered = tuple(sorted(float(cut) for cut in cuts))
+    suggested: dict[str, float] = {}
+    for report in reports:
+        if report.kind != "noul" or not report.windows:
+            continue
+        for cut in ordered:
+            if report.fires.get(cut, 0) / report.windows <= target:
+                suggested[report.qid] = cut
+                break
+    return suggested
+
+
+def format_report(
+    reports: list[QuestionReport], windows: int, thresholds=DEFAULT_THRESHOLDS
+) -> str:
+    """Fixed-width table of fire rates per threshold plus choice label spread."""
+    cuts = tuple(sorted({float(t) for t in thresholds}))
+    head = ["question", "kind", "windows", "mean p"]
+    head.extend("fires@%.2f" % cut for cut in cuts)
+    lines = ["windows replayed: %d" % windows]
+    lines.append("  ".join(head))
+    for report in reports:
+        cells = [
+            report.qid,
+            report.kind,
+            str(report.windows),
+            ("%.3f" % report.mean_p) if report.kind == "noul" else "-",
+        ]
+        if report.kind == "noul":
+            for cut in cuts:
+                fired = report.fires.get(cut, 0)
+                share = (100.0 * fired / report.windows) if report.windows else 0.0
+                cells.append("%d (%d%%)" % (fired, round(share)))
+        else:
+            cells.extend("-" for _ in cuts)
+        lines.append("  ".join(cells))
+        if report.labels:
+            spread = ", ".join(
+                "%s=%d" % (label, count)
+                for label, count in sorted(report.labels.items(), key=lambda kv: -kv[1])
+            )
+            lines.append("    labels: %s" % (spread,))
+    suggested = suggest_thresholds(reports, cuts)
+    if suggested:
+        lines.append("")
+        lines.append("suggested per-question thresholds (fire on <=25% of windows):")
+        for qid, cut in suggested.items():
+            lines.append("  %s %.2f" % (qid, cut))
+    return "\n".join(lines)

+ 46 - 3
src/stt_daemon/tui/state.py

@@ -63,6 +63,32 @@ def _fmt_clock(ts) -> str:
     return "--:--:--"
     return "--:--:--"
 
 
 
 
+def _delta_mark(before: dict | None, item: dict, threshold: float = 0.05) -> str:
+    """Movement of a noul probability since the previous triage pass."""
+    if not isinstance(before, dict):
+        return ""
+    old = _num(before.get("p"))
+    new = _num(item.get("p"))
+    if old is None or new is None:
+        return ""
+    if new - old >= threshold:
+        return " \u2191"
+    if old - new >= threshold:
+        return " \u2193"
+    return ""
+
+
+def _transition_note(before: dict | None, item: dict) -> str:
+    """Previous choice label when a choice question flipped."""
+    if not isinstance(before, dict):
+        return ""
+    old = before.get("choice")
+    new = item.get("choice")
+    if isinstance(old, str) and isinstance(new, str) and old != new:
+        return f" \u2190 {old}"
+    return ""
+
+
 def _fmt_gate(item: dict) -> str:
 def _fmt_gate(item: dict) -> str:
     """Format the gating confidence defensively; '?' when absent."""
     """Format the gating confidence defensively; '?' when absent."""
     for key in ("answer_confidence", "confidence"):
     for key in ("answer_confidence", "confidence"):
@@ -101,6 +127,7 @@ class ViewState:
     last_error: dict | None = None
     last_error: dict | None = None
     chat_epoch: int = 0
     chat_epoch: int = 0
     triage: dict | None = None
     triage: dict | None = None
+    triage_prev: dict | None = None
     triage_state: str = "disabled"
     triage_state: str = "disabled"
     triage_reason: str = ""
     triage_reason: str = ""
     triage_epoch: int = 0
     triage_epoch: int = 0
@@ -130,6 +157,7 @@ class ViewState:
                 self.speech_open = bool(event["speech_open"])
                 self.speech_open = bool(event["speech_open"])
             changed = False
             changed = False
             if "triage" in event:
             if "triage" in event:
+                self.triage_prev = self.triage
                 self.triage = event["triage"]
                 self.triage = event["triage"]
                 changed = True
                 changed = True
             if "triage_state" in event:
             if "triage_state" in event:
@@ -156,6 +184,7 @@ class ViewState:
                 # A triage payload can only come from a ready lane: authoritative.
                 # A triage payload can only come from a ready lane: authoritative.
                 self.triage_state = "ok"
                 self.triage_state = "ok"
                 self.triage_reason = ""
                 self.triage_reason = ""
+                self.triage_prev = self.triage
                 self.triage = event.get("triage")
                 self.triage = event.get("triage")
                 self.triage_ts = event.get("ts", self.triage_ts)
                 self.triage_ts = event.get("ts", self.triage_ts)
             self.triage_epoch += 1
             self.triage_epoch += 1
@@ -308,6 +337,11 @@ class ViewState:
 
 
         order = self.triage.get("order")
         order = self.triage.get("order")
         items = self.triage.get("items")
         items = self.triage.get("items")
+        prev_items: dict = {}
+        if isinstance(self.triage_prev, dict):
+            candidate = self.triage_prev.get("items")
+            if isinstance(candidate, dict):
+                prev_items = candidate
         lines: list[str] = []
         lines: list[str] = []
         if isinstance(order, list) and isinstance(items, dict):
         if isinstance(order, list) and isinstance(items, dict):
             for qid in order:
             for qid in order:
@@ -316,16 +350,21 @@ class ViewState:
                     lines.append(f"{qid} ⚠" if item else str(qid))
                     lines.append(f"{qid} ⚠" if item else str(qid))
                     continue
                     continue
                 kind = item.get("kind")
                 kind = item.get("kind")
+                previous = prev_items.get(qid)
+                before = previous if isinstance(previous, dict) else None
                 flagged = bool(item.get("flagged"))
                 flagged = bool(item.get("flagged"))
-                mark = " ⚠" if flagged else ""
+                mark = " ⚠" if flagged and not (before or {}).get("flagged") else ""
                 if kind == "choice":
                 if kind == "choice":
-                    line = f"{qid}: {item.get('choice', '?')} ({_fmt_gate(item)}){mark}"
+                    line = (
+                        f"{qid}: {item.get('choice', '?')} ({_fmt_gate(item)})"
+                        f"{_transition_note(before, item)}{mark}"
+                    )
                 elif kind == "noul":
                 elif kind == "noul":
                     p = _num(item.get("p"))
                     p = _num(item.get("p"))
                     bar = "█" * int(p * 10) if p is not None else ""
                     bar = "█" * int(p * 10) if p is not None else ""
                     bar = bar.ljust(10)
                     bar = bar.ljust(10)
                     ptxt = f"{p:.2f}" if p is not None else "?"
                     ptxt = f"{p:.2f}" if p is not None else "?"
-                    line = f"{bar} {qid}: {ptxt}{mark}"
+                    line = f"{bar} {qid}: {ptxt}{_delta_mark(before, item)}{mark}"
                 elif kind == "score":
                 elif kind == "score":
                     level = _num(item.get("level"))
                     level = _num(item.get("level"))
                     bar = ("█" * int(level)).ljust(10) if level is not None else " " * 10
                     bar = ("█" * int(level)).ljust(10) if level is not None else " " * 10
@@ -334,6 +373,10 @@ class ViewState:
                 else:
                 else:
                     line = f"{qid}{mark}" if flagged else str(qid)
                     line = f"{qid}{mark}" if flagged else str(qid)
                 lines.append(line)
                 lines.append(line)
+        hidden = _num(self.triage.get("hidden"))
+        if hidden is not None and hidden > 0:
+            plural = "s" if hidden != 1 else ""
+            lines.append(f"({int(hidden)} low signal{plural} hidden)")
 
 
         brief = self.triage.get("brief")
         brief = self.triage.get("brief")
         if isinstance(brief, str) and brief:
         if isinstance(brief, str) and brief:

+ 82 - 21
tests/test_assistant_service.py

@@ -316,7 +316,7 @@ def test_chat_capture_off_still_allows_typed_turns_and_triage():
 
 
     assert service.submit_chat_text("typed") is True
     assert service.submit_chat_text("typed") is True
     assert service.chat_history == ["typed"]
     assert service.chat_history == ["typed"]
-    assert service.triage_history == ["spoken"]
+    assert service.triage_history == ["Unknown: spoken"]
 
 
 
 
 def test_set_chat_capture_emits_once_and_reports_status():
 def test_set_chat_capture_emits_once_and_reports_status():
@@ -782,6 +782,9 @@ class FakeTriage:
     def is_regression(self, prior_brief, new_brief):
     def is_regression(self, prior_brief, new_brief):
         return _real_analyzer.is_regression(prior_brief, new_brief)
         return _real_analyzer.is_regression(prior_brief, new_brief)
 
 
+    def should_escalate(self, signals):
+        return _real_analyzer.should_escalate(signals)
+
     def parse_facts(self, block):
     def parse_facts(self, block):
         return _real_analyzer.parse_facts(block)
         return _real_analyzer.parse_facts(block)
 
 
@@ -790,8 +793,10 @@ class FakeTriage:
             return text if text else None
             return text if text else None
         return self._parsed
         return self._parsed
 
 
-    def should_update_brief(self, signals):
+    def should_update_brief(self, signals, prior=None):
         self.gate_calls.append(signals)
         self.gate_calls.append(signals)
+        if self._gate is _AUTO:
+            return _real_analyzer.should_update_brief(signals, prior)
         return self._gate
         return self._gate
 
 
 
 
@@ -1017,12 +1022,12 @@ def test_maybe_triage_runs_in_idle_mode():
 
 
     assert len(lane.submits) == 1
     assert len(lane.submits) == 1
     state, questions, lang, gen = lane.submits[0]
     state, questions, lang, gen = lane.submits[0]
-    assert state == "- hello"
+    assert state == "- Unknown: hello"
     assert questions is triage.questions
     assert questions is triage.questions
     assert lang is None
     assert lang is None
     assert gen == 0
     assert gen == 0
-    assert triage.build_state_calls == [("hello",)]
-    assert service.triage_history == ["hello"]
+    assert triage.build_state_calls == [("Unknown: hello",)]
+    assert service.triage_history == ["Unknown: hello"]
 
 
 
 
 def test_maybe_triage_uses_transcript_buffer_not_chat_history():
 def test_maybe_triage_uses_transcript_buffer_not_chat_history():
@@ -1039,7 +1044,7 @@ def test_maybe_triage_uses_transcript_buffer_not_chat_history():
     service.tick()
     service.tick()
 
 
     assert len(lane.submits) == 1
     assert len(lane.submits) == 1
-    assert triage.build_state_calls == [("hello",)]
+    assert triage.build_state_calls == [("Unknown: hello",)]
 
 
 
 
 def test_maybe_triage_requires_settled_turn():
 def test_maybe_triage_requires_settled_turn():
@@ -1061,7 +1066,7 @@ def test_maybe_triage_periodic_fires_before_turn_settles():
 
 
     assert service.turn_ended() is False
     assert service.turn_ended() is False
     assert len(lane.submits) == 1
     assert len(lane.submits) == 1
-    assert lane.submits[0][0] == "- hello"
+    assert lane.submits[0][0] == "- Unknown: hello"
 
 
 
 
 def test_maybe_triage_periodic_disabled_ignores_accumulating_speech():
 def test_maybe_triage_periodic_disabled_ignores_accumulating_speech():
@@ -1092,7 +1097,7 @@ def test_maybe_triage_periodic_rearms_after_submit():
     clock.advance(0.25)
     clock.advance(0.25)
     service.tick()
     service.tick()
     assert len(lane.submits) == 2
     assert len(lane.submits) == 2
-    assert lane.submits[1][0] == "- first\n- second"
+    assert lane.submits[1][0] == "- Unknown: first\n- Unknown: second"
 
 
 
 
 def test_triage_debug_emits_submit_and_result():
 def test_triage_debug_emits_submit_and_result():
@@ -1191,7 +1196,7 @@ def test_maybe_triage_dedupes_identical_window():
 
 
     assert len(lane.submits) == 1
     assert len(lane.submits) == 1
     state, questions, lang, gen = lane.submits[0]
     state, questions, lang, gen = lane.submits[0]
-    assert state == "- hello"
+    assert state == "- Unknown: hello"
     assert questions is triage.questions
     assert questions is triage.questions
     assert lang is None
     assert lang is None
     assert gen == 0
     assert gen == 0
@@ -1204,7 +1209,7 @@ def test_maybe_triage_dedupes_identical_window():
     clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
     clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
     service.tick()
     service.tick()
     assert len(lane.submits) == 2
     assert len(lane.submits) == 2
-    assert lane.submits[1][0] == "- hello\n- second"
+    assert lane.submits[1][0] == "- Unknown: hello\n- Unknown: second"
 
 
 
 
 def test_second_transcript_produces_second_submit():
 def test_second_transcript_produces_second_submit():
@@ -1219,7 +1224,7 @@ def test_second_transcript_produces_second_submit():
     service.tick()
     service.tick()
 
 
     assert len(lane.submits) == 2
     assert len(lane.submits) == 2
-    assert lane.submits[1][0] == "- first\n- second"
+    assert lane.submits[1][0] == "- Unknown: first\n- Unknown: second"
 
 
 
 
 def test_tick_under_50ms_with_blocked_lane():
 def test_tick_under_50ms_with_blocked_lane():
@@ -1319,7 +1324,7 @@ def test_pending_brief_capped_by_char_budget():
     service.handle_event(_transcript(clock, "ccccc"))
     service.handle_event(_transcript(clock, "ccccc"))
 
 
     assert service._pending_brief == ["Unknown: bbbb", "Unknown: ccccc"]
     assert service._pending_brief == ["Unknown: bbbb", "Unknown: ccccc"]
-    assert service.triage_history == ["aaa", "bbbb", "ccccc"]
+    assert service.triage_history == ["Unknown: aaa", "Unknown: bbbb", "Unknown: ccccc"]
 
 
 
 
 def test_pending_brief_untouched_without_triage():
 def test_pending_brief_untouched_without_triage():
@@ -1327,7 +1332,7 @@ def test_pending_brief_untouched_without_triage():
     service.handle_event(_transcript(clock, "hello"))
     service.handle_event(_transcript(clock, "hello"))
 
 
     assert service._pending_brief == []
     assert service._pending_brief == []
-    assert service.triage_history == ["hello"]
+    assert service.triage_history == ["Unknown: hello"]
 
 
 
 
 def test_on_triage_result_accepts_non_chat_mode():
 def test_on_triage_result_accepts_non_chat_mode():
@@ -2057,8 +2062,8 @@ def test_exit_chat_does_not_reset_triage():
     assert service.modes.mode.value == "idle"
     assert service.modes.mode.value == "idle"
     assert service._triage_generation == 0
     assert service._triage_generation == 0
     assert service.last_triage is not None
     assert service.last_triage is not None
-    assert service._last_triage_window == ("hello",)
-    assert service.triage_history == ["hello"]
+    assert service._last_triage_window == ("Unknown: hello",)
+    assert service.triage_history == ["Unknown: hello"]
     assert service._pending_brief == ["Unknown: hello"]
     assert service._pending_brief == ["Unknown: hello"]
     assert service._last_brief_signals is not None
     assert service._last_brief_signals is not None
     assert service._brief_outstanding is True
     assert service._brief_outstanding is True
@@ -2092,8 +2097,8 @@ def test_toggle_chat_enter_does_not_touch_triage():
     assert service.modes.mode.value == "chat"
     assert service.modes.mode.value == "chat"
     assert service._triage_generation == 0
     assert service._triage_generation == 0
     assert service.last_triage is triage_before
     assert service.last_triage is triage_before
-    assert service._last_triage_window == ("hello",)
-    assert service.triage_history == ["hello"]
+    assert service._last_triage_window == ("Unknown: hello",)
+    assert service.triage_history == ["Unknown: hello"]
     assert service._pending_brief == pending_before
     assert service._pending_brief == pending_before
     assert service._last_brief_signals is not None
     assert service._last_brief_signals is not None
     assert service._brief_outstanding is True
     assert service._brief_outstanding is True
@@ -2247,7 +2252,7 @@ def test_triage_end_to_end_signals_content_cleared():
     clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
     clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
     service.tick()
     service.tick()
     assert len(lane.submits) == 3
     assert len(lane.submits) == 3
-    assert lane.submits[2][0].endswith("- idle thought")
+    assert lane.submits[2][0].endswith("- Unknown: idle thought")
 
 
 
 
 def test_tick_emits_status_on_triage_state_change():
 def test_tick_emits_status_on_triage_state_change():
@@ -2428,7 +2433,7 @@ def test_dump_writes_full_laya_and_llm_io(tmp_path):
     data = json.loads(files[0].read_text(encoding="utf-8"))
     data = json.loads(files[0].read_text(encoding="utf-8"))
     assert data["ts"] == clock()
     assert data["ts"] == clock()
     assert isinstance(data["iso"], str) and data["iso"]
     assert isinstance(data["iso"], str) and data["iso"]
-    assert data["laya"]["request"]["state"] == "- hello"
+    assert data["laya"]["request"]["state"] == "- Unknown: hello"
     assert data["laya"]["request"]["questions"] == triage.questions
     assert data["laya"]["request"]["questions"] == triage.questions
     assert data["laya"]["request"]["model"] == "multilingual"
     assert data["laya"]["request"]["model"] == "multilingual"
     assert data["laya"]["request"]["max_len"] == 1024
     assert data["laya"]["request"]["max_len"] == 1024
@@ -2742,7 +2747,7 @@ def test_live_transcript_session_sets_session_without_wiping_utterance():
 
 
     assert service.status()["session_dir"] == session
     assert service.status()["session_dir"] == session
     assert brief_file.directories == [session]
     assert brief_file.directories == [session]
-    assert service.triage_history == ["hello"]
+    assert service.triage_history == ["Unknown: hello"]
     assert service._pending_brief == ["Unknown: hello"]
     assert service._pending_brief == ["Unknown: hello"]
     assert service._triage_generation == 0
     assert service._triage_generation == 0
 
 
@@ -2835,7 +2840,7 @@ def test_replay_single_session_adopts_without_reset():
     assert brief_file.directories == [session]
     assert brief_file.directories == [session]
     assert service._triage_generation == 0
     assert service._triage_generation == 0
     assert _cleared(emitted) == []
     assert _cleared(emitted) == []
-    assert service.triage_history == ["hello"]
+    assert service.triage_history == ["Unknown: hello"]
 
 
 
 
 def test_replay_two_sessions_commits_newest_and_resets_once():
 def test_replay_two_sessions_commits_newest_and_resets_once():
@@ -2961,3 +2966,59 @@ def test_daemon_restart_new_session_resets_once():
     assert service._triage_generation == 1
     assert service._triage_generation == 1
     assert service.last_triage is None
     assert service.last_triage is None
     assert brief_file.directories == ["transcripts/a", "transcripts/b"]
     assert brief_file.directories == ["transcripts/a", "transcripts/b"]
+
+
+def _dwell_service(confirm):
+    service, emitted, clock, _, lane = _triage_service(
+        triage=FakeTriage(questions={"blocker": {"type": "noul", "instructions": "x"}})
+    )
+    service.cfg.triage.confirm_windows = confirm
+    return service, emitted, clock, lane
+
+
+def _blocker_payload(p):
+    return {
+        "order": ["blocker"],
+        "items": {
+            "blocker": {
+                "kind": "noul",
+                "p": p,
+                "threshold": 0.5,
+                "flagged": p >= 0.5,
+            }
+        },
+        "escalated": p >= 0.5,
+        "input_tokens": 0,
+    }
+
+
+def test_dwell_holds_a_single_window_spike_back():
+    service, emitted, _, _ = _dwell_service(2)
+    service._on_triage_result(0, _blocker_payload(0.95))
+    assert service.last_triage["items"]["blocker"]["flagged"] is False
+    service._on_triage_result(0, _blocker_payload(0.95))
+    assert service.last_triage["items"]["blocker"]["flagged"] is True
+
+
+def test_dwell_resets_when_the_signal_drops():
+    service, _, _, _ = _dwell_service(2)
+    service._on_triage_result(0, _blocker_payload(0.95))
+    service._on_triage_result(0, _blocker_payload(0.10))
+    service._on_triage_result(0, _blocker_payload(0.95))
+    assert service.last_triage["items"]["blocker"]["flagged"] is False
+    service._on_triage_result(0, _blocker_payload(0.95))
+    assert service.last_triage["items"]["blocker"]["flagged"] is True
+
+
+def test_dwell_disabled_with_one_confirm_window():
+    service, _, _, _ = _dwell_service(1)
+    service._on_triage_result(0, _blocker_payload(0.95))
+    assert service.last_triage["items"]["blocker"]["flagged"] is True
+
+
+def test_clear_resets_dwell_streaks():
+    service, _, _, _ = _dwell_service(2)
+    service._on_triage_result(0, _blocker_payload(0.95))
+    assert service._signal_streaks["blocker"] == 1
+    service.clear()
+    assert service._signal_streaks == {}

+ 1 - 1
tests/test_assistant_speaker.py

@@ -212,7 +212,7 @@ def test_chat_context_uses_display_labels(tmp_path):
 
 
     assert service.chat_history == ["Efren: hi", "Unknown: yo"]
     assert service.chat_history == ["Efren: hi", "Unknown: yo"]
     assert service._pending_chat == ["Efren: hi", "Unknown: yo"]
     assert service._pending_chat == ["Efren: hi", "Unknown: yo"]
-    assert service.triage_history == ["hi", "yo"]
+    assert service.triage_history == ["Efren: hi", "Unknown: yo"]
 
 
 
 
 def _dispatch(service, message):
 def _dispatch(service, message):

+ 114 - 1
tests/test_triage.py

@@ -221,6 +221,7 @@ def test_normalize_noul_and_bare_payload(tmp_path):
         "confidence": 0.9,
         "confidence": 0.9,
         "answer_confidence": 0.85,
         "answer_confidence": 0.85,
         "gate": 0.85,
         "gate": 0.85,
+        "threshold": 0.6,
         "flagged": True,
         "flagged": True,
     }
     }
     assert result["input_tokens"] == 0
     assert result["input_tokens"] == 0
@@ -420,11 +421,23 @@ def test_build_ledger_prompt_inlines_ledger_narrative_and_deltas(tmp_path):
     assert "NEW UTTERANCES:\n- Alice: let's ship Friday" in prompt
     assert "NEW UTTERANCES:\n- Alice: let's ship Friday" in prompt
     assert prompt.index("CURRENT LEDGER") < prompt.index("CURRENT BRIEF:")
     assert prompt.index("CURRENT LEDGER") < prompt.index("CURRENT BRIEF:")
     assert prompt.index("CURRENT BRIEF:") < prompt.index("NEW UTTERANCES:")
     assert prompt.index("CURRENT BRIEF:") < prompt.index("NEW UTTERANCES:")
-    assert prompt.index("NEW UTTERANCES:") < prompt.index("Focus:")
+    assert prompt.index("NEW UTTERANCES:") < prompt.index("Focus")
     assert "churn_risk" in prompt
     assert "churn_risk" in prompt
     assert "NEW FACTS:" in prompt and "BRIEF:" in prompt
     assert "NEW FACTS:" in prompt and "BRIEF:" in prompt
 
 
 
 
+def test_build_ledger_prompt_never_leaks_question_definitions(tmp_path):
+    """The brief model used to copy Focus wording and numbers into the brief."""
+    analyzer = _analyzer(tmp_path, _score_questions())
+    prompt = analyzer.build_ledger_prompt("Summary: x", [], ["Alice: hi"], _signals(noul_p=0.8))
+    focus = prompt.split("Focus (classifier verdicts, not meeting content):\n", 1)[1]
+    focus = focus.split("\n\n", 1)[0]
+    assert "churn_risk: 0.80" in focus
+    assert "instruction" not in focus.lower()
+    assert "gate=" not in focus
+    assert "Does the customer" not in prompt
+
+
 def test_build_ledger_prompt_empty_ledger_and_narrative(tmp_path):
 def test_build_ledger_prompt_empty_ledger_and_narrative(tmp_path):
     analyzer = _analyzer(tmp_path, _score_questions())
     analyzer = _analyzer(tmp_path, _score_questions())
     prompt = analyzer.build_ledger_prompt("", [], ["solo"], None)
     prompt = analyzer.build_ledger_prompt("", [], ["solo"], None)
@@ -1351,3 +1364,103 @@ def test_build_ledger_prompt_contains_opportunity_lead_rule(tmp_path):
     prompt = analyzer.build_ledger_prompt("Summary: x", [], ["hi"], None)
     prompt = analyzer.build_ledger_prompt("Summary: x", [], ["hi"], None)
     assert "currently-open opportunities first" in prompt
     assert "currently-open opportunities first" in prompt
     assert "Opportunities section the most complete" in prompt
     assert "Opportunities section the most complete" in prompt
+
+
+def test_normalize_uses_per_question_threshold(tmp_path):
+    questions = {
+        "strict": {"type": "noul", "instructions": "a", "threshold": 0.9},
+        "lax": {"type": "noul", "instructions": "b", "threshold": 0.2},
+        "default": {"type": "noul", "instructions": "c"},
+    }
+    analyzer = _analyzer(tmp_path, questions)
+    payload = {
+        qid: {"noul": 0.5, "confidence": 0.5, "answer_confidence": 0.5}
+        for qid in questions
+    }
+    result = analyzer.normalize(payload)
+    items = result["items"]
+    assert items["strict"]["threshold"] == 0.9
+    assert items["strict"]["flagged"] is False
+    assert items["lax"]["threshold"] == 0.2
+    assert items["lax"]["flagged"] is True
+    assert items["default"]["threshold"] == 0.6
+
+
+def test_normalize_counts_hidden_low_signals(tmp_path):
+    analyzer = _analyzer(tmp_path, _score_questions())
+    payload = {
+        "severity": {"score": 0.1, "confidence": 0.9, "answer_confidence": 0.9},
+        "category": {"choice": "sales", "confidence": 0.9, "answer_confidence": 0.9},
+        "churn_risk": {"noul": 0.05, "confidence": 0.95, "answer_confidence": 0.95},
+    }
+    result = analyzer.normalize(payload)
+    assert result["hidden"] == 1
+    assert "churn_risk" in result["items"]
+
+
+def _gate_signals(choice="sales", hot=False):
+    return {
+        "order": ["substantive_update", "category", "churn_risk"],
+        "items": {
+            "substantive_update": {
+                "kind": "noul",
+                "p": 0.1,
+                "threshold": 0.5,
+                "flagged": False,
+            },
+            "category": {
+                "kind": "choice",
+                "choice": choice,
+                "gate": 0.9,
+                "flagged": False,
+            },
+            "churn_risk": {
+                "kind": "noul",
+                "p": 0.9 if hot else 0.1,
+                "threshold": 0.5,
+                "flagged": hot,
+            },
+        },
+        "escalated": False,
+    }
+
+
+def test_should_update_brief_fires_on_a_choice_transition(tmp_path):
+    analyzer = _analyzer(tmp_path, _score_questions())
+    assert analyzer.should_update_brief(_gate_signals("support"), _gate_signals("sales")) is True
+    assert analyzer.should_update_brief(_gate_signals("sales"), _gate_signals("sales")) is False
+
+
+def test_should_update_brief_fires_on_a_newly_hot_signal(tmp_path):
+    analyzer = _analyzer(tmp_path, _score_questions())
+    assert analyzer.should_update_brief(_gate_signals(hot=True), _gate_signals()) is True
+    current = _gate_signals(hot=True)
+    assert analyzer.should_update_brief(current, current) is False
+
+
+def test_should_update_brief_ignores_transitions_without_prior(tmp_path):
+    analyzer = _analyzer(tmp_path, _score_questions())
+    assert analyzer.should_update_brief(_gate_signals(hot=True), None) is False
+
+
+def test_should_update_brief_fail_open_when_arm_absent(tmp_path):
+    analyzer = _analyzer(tmp_path, _score_questions())
+    assert analyzer.should_update_brief(_signals(noul_p=0.1), _signals(noul_p=0.1)) is True
+
+
+def test_window_line_shortens_and_respects_toggle():
+    assert triage.window_line("Elvis Avelardo García", "hola") == "Elvis: hola"
+    assert triage.window_line("", "hola") == "Unknown: hola"
+    assert triage.window_line("Elvis", "hola", include_speaker=False) == "hola"
+
+
+def test_load_questions_accepts_and_validates_threshold(tmp_path):
+    good = {"q": {"type": "noul", "instructions": "x", "threshold": 0.85}}
+    analyzer = _analyzer(tmp_path, good)
+    assert analyzer.questions is not None
+    assert analyzer.questions["q"]["threshold"] == 0.85
+
+    for bad in (0, -0.1, 1.5, "high", True):
+        definition = {"type": "noul", "instructions": "x", "threshold": bad}
+        path = _write_questions(tmp_path, {"q": definition})
+        assert load_questions(path) is None

+ 149 - 0
tests/test_triage_eval.py

@@ -0,0 +1,149 @@
+"""Tests for the triage calibration report. Pure, hardware-free."""
+
+import json
+from types import SimpleNamespace
+
+from stt_daemon.assistant.triage import TriageAnalyzer
+from stt_daemon.triage_eval import (
+    analyze_states,
+    format_report,
+    load_records,
+    replay_states,
+    suggest_thresholds,
+)
+
+
+def _analyzer(tmp_path, questions):
+    path = tmp_path / "questions.json"
+    path.write_text(json.dumps(questions), encoding="utf-8")
+    return TriageAnalyzer(
+        SimpleNamespace(
+            questions_file=str(path),
+            state_char_budget=1200,
+            confidence_field="answer_confidence",
+            escalate_threshold=0.6,
+            choice_min_confidence=0.85,
+        )
+    )
+
+
+def _questions():
+    return {
+        "blocker": {"type": "noul", "instructions": "Something blocking was raised."},
+        "decision_status": {
+            "type": "choice",
+            "instructions": "Where is the decision?",
+            "criteria": {"none": "nothing", "made": "settled"},
+        },
+    }
+
+
+def _record(text, speaker="Efren Yevale", status="matched"):
+    return {"text": text, "speaker": speaker, "speaker_status": status}
+
+
+def _write_session(tmp_path, records):
+    session = tmp_path / "2026-01-01-00-00-00"
+    session.mkdir()
+    lines = [json.dumps(record) for record in records]
+    (session / "transcripts.jsonl").write_text("\n".join(lines) + "\n", encoding="utf-8")
+    return session
+
+
+def test_load_records_skips_missing_bad_and_empty(tmp_path):
+    session = _write_session(tmp_path, [_record("hola")])
+    with open(session / "transcripts.jsonl", "a", encoding="utf-8") as handle:
+        handle.write("not json\n")
+        handle.write(json.dumps({"text": "   "}) + "\n")
+    records = load_records(session)
+    assert [r["text"] for r in records] == ["hola"]
+    assert load_records(tmp_path / "absent") == []
+
+
+def test_replay_states_labels_speaker_and_honours_stride(tmp_path):
+    analyzer = _analyzer(tmp_path, _questions())
+    records = [_record(f"line {i}") for i in range(5)]
+    states = replay_states(records, analyzer, window=2, stride=2)
+    assert states == [
+        "- Efren: line 0\n- Efren: line 1",
+        "- Efren: line 2\n- Efren: line 3",
+        "- Efren: line 4",
+    ]
+
+
+def test_replay_states_marks_unmatched_as_unknown(tmp_path):
+    analyzer = _analyzer(tmp_path, _questions())
+    states = replay_states([_record("hola", status="unknown")], analyzer, window=4)
+    assert states == ["- Unknown: hola"]
+
+
+def test_analyze_states_reports_fire_rates_and_choice_labels(tmp_path):
+    analyzer = _analyzer(tmp_path, _questions())
+    payloads = iter(
+        [
+            {
+                "blocker": {"noul": 0.9, "confidence": 0.9, "answer_confidence": 0.9},
+                "decision_status": {
+                    "choice": "made",
+                    "confidence": 0.9,
+                    "answer_confidence": 0.9,
+                },
+            },
+            {
+                "blocker": {"noul": 0.1, "confidence": 0.9, "answer_confidence": 0.9},
+                "decision_status": {
+                    "choice": "none",
+                    "confidence": 0.9,
+                    "answer_confidence": 0.9,
+                },
+            },
+        ]
+    )
+    reports = analyze_states(
+        ["s1", "s2"], analyzer, lambda state: next(payloads), thresholds=(0.5, 0.8)
+    )
+    by_qid = {report.qid: report for report in reports}
+    assert by_qid["blocker"].windows == 2
+    assert by_qid["blocker"].fires[0.5] == 1
+    assert by_qid["blocker"].fires[0.8] == 1
+    assert round(by_qid["blocker"].mean_p, 3) == 0.5
+    assert by_qid["decision_status"].labels == {"made": 1, "none": 1}
+
+
+def test_analyze_states_survives_a_failing_predictor(tmp_path):
+    analyzer = _analyzer(tmp_path, _questions())
+
+    def boom(state):
+        raise RuntimeError("model gone")
+
+    assert analyze_states(["s1"], analyzer, boom) == []
+
+
+def test_format_report_prints_fire_rates_and_suggestions(tmp_path):
+    analyzer = _analyzer(tmp_path, _questions())
+    payload = {
+        "blocker": {"noul": 0.70, "confidence": 0.70, "answer_confidence": 0.70},
+    }
+    reports = analyze_states(["s1", "s2"], analyzer, lambda state: payload, thresholds=(0.5, 0.9))
+    text = format_report(reports, 2, thresholds=(0.5, 0.9))
+    assert "windows replayed: 2" in text
+    assert "fires@0.50" in text
+    assert "100%" in text
+    assert "suggested per-question thresholds" in text
+    assert "blocker 0.90" in text
+
+
+def test_suggest_thresholds_picks_the_least_restrictive_cut_that_is_rare(tmp_path):
+    payloads = iter(
+        [
+            {"q": {"noul": p, "confidence": p, "answer_confidence": p}}
+            for p in (0.95, 0.55, 0.55, 0.55)
+        ]
+    )
+    reports = analyze_states(
+        ["s1", "s2", "s3", "s4"],
+        _analyzer(tmp_path, {"q": {"type": "noul", "instructions": "x"}}),
+        lambda state: next(payloads),
+        thresholds=(0.5, 0.7, 0.9),
+    )
+    assert suggest_thresholds(reports, (0.5, 0.7, 0.9), target=0.25) == {"q": 0.7}

+ 70 - 0
tests/test_tui_state.py

@@ -1034,3 +1034,73 @@ def test_cleared_keeps_session_dir():
     state.ingest({"type": "cleared"})
     state.ingest({"type": "cleared"})
     assert state.session_dir == "transcripts/2026-09-30-12-47-29"
     assert state.session_dir == "transcripts/2026-09-30-12-47-29"
     assert state.chat_lines() == []
     assert state.chat_lines() == []
+
+
+def test_triage_lines_marks_probability_movement_against_previous():
+    state = ViewState()
+    state.triage_prev = {
+        "order": ["q"],
+        "items": {"q": {"kind": "noul", "p": 0.20, "flagged": False}},
+    }
+    state.triage = {
+        "order": ["q"],
+        "items": {"q": {"kind": "noul", "p": 0.75, "flagged": True}},
+    }
+    state.triage_state = "ok"
+    line = state.triage_lines()[0]
+    assert line.endswith("\u2191 \u26a0")
+    assert "0.75" in line
+
+
+def test_triage_lines_marks_downward_movement():
+    state = ViewState()
+    state.triage_prev = {
+        "order": ["q"],
+        "items": {"q": {"kind": "noul", "p": 0.80, "flagged": True}},
+    }
+    state.triage = {
+        "order": ["q"],
+        "items": {"q": {"kind": "noul", "p": 0.30, "flagged": False}},
+    }
+    state.triage_state = "ok"
+    assert state.triage_lines()[0].endswith("\u2193")
+
+
+def test_triage_lines_only_warns_on_a_new_flag():
+    state = ViewState()
+    prev = {"order": ["q"], "items": {"q": {"kind": "noul", "p": 0.9, "flagged": True}}}
+    state.triage_prev = prev
+    state.triage = {
+        "order": ["q"],
+        "items": {"q": {"kind": "noul", "p": 0.9, "flagged": True}},
+    }
+    state.triage_state = "ok"
+    assert "\u26a0" not in state.triage_lines()[0]
+
+
+def test_triage_lines_shows_the_previous_choice_label():
+    state = ViewState()
+    state.triage_prev = {
+        "order": ["q"],
+        "items": {"q": {"kind": "choice", "choice": "proposed", "answer_confidence": 0.9}},
+    }
+    state.triage = {
+        "order": ["q"],
+        "items": {"q": {"kind": "choice", "choice": "made", "answer_confidence": 0.9}},
+    }
+    state.triage_state = "ok"
+    assert state.triage_lines()[0] == "q: made (0.90) \u2190 proposed"
+
+
+def test_triage_lines_reports_hidden_low_signals():
+    state = ViewState()
+    state.triage = {
+        "order": ["q1"],
+        "items": {"q1": {"kind": "choice", "choice": "yes", "answer_confidence": 0.9}},
+        "hidden": 2,
+    }
+    state.triage_state = "ok"
+    assert state.triage_lines()[-1] == "(2 low signals hidden)"
+
+    state.triage = {**state.triage, "hidden": 1}
+    assert state.triage_lines()[-1] == "(1 low signal hidden)"