Ver Fonte

Improved process

Efren Yevale Varela há 4 dias atrás
pai
commit
b93e461c3f

+ 2 - 0
config.example.toml

@@ -106,6 +106,8 @@ enabled                   = false
 questions_file            = "questions.json"
 window                    = 500
 state_char_budget         = 3000
+state_include_speaker     = true
+confirm_windows           = 2
 periodic_s                = 30.0
 predict_max_tokens        = 1024
 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
 ```
 
+### `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
 
 ```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
 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_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
 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"

+ 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)`.
   - `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.
-  - 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
   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.
@@ -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 |
 | `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
 `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
@@ -308,6 +320,8 @@ file.
 | `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. |
 | `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. |
 | `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). |
@@ -318,7 +332,7 @@ file.
 | `extract_timeout_s` | `30.0` | Timeout for the brief request. |
 | `extract_temperature` | `0.2` | Temperature for the brief request (deterministic extraction; chat keeps `[llm].temperature`). |
 | `brief_slot_id` | unset | Optional llama.cpp `id_slot` for the brief engine; when unset the `id_slot` field is omitted (needs a server started with `-np 2`). |
-| `brief_gate_enabled` | `true` | Fail-open gate: update the brief only when the `substantive_update` `p` is at least `brief_gate_threshold`. |
+| `brief_gate_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_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. |

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

@@ -19,7 +19,7 @@ from ..config import Config
 from .executor import ExecutionResult
 from .inference import InferenceJob, InferenceQueue
 from .modes import Mode, ModeMachine
-from .triage import FactLedger
+from .triage import FactLedger, window_line
 from .triage_dump import TriageDumper
 
 logger = logging.getLogger(__name__)
@@ -170,6 +170,7 @@ class AssistantService:
         self._last_triage_state: tuple[str, str] | None = None
         self.last_triage: dict | None = None
         self._last_triage_window: tuple = ()
+        self._signal_streaks: dict[str, int] = {}
         # Periodic cadence for accumulating speech (no settled turn): armed to
         # `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
@@ -630,6 +631,14 @@ class AssistantService:
         )
         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
 
     def handle_event(self, event: dict) -> None:
@@ -846,12 +855,13 @@ class AssistantService:
         if not text:
             return
         self._last_activity_ts = now
+        label = self._speaker_display(event)
         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]
         if self._triage is not None:
             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()
                 periodic_s = self._periodic_s()
                 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)),
             }
 
+    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:
         if self._triage is None:
             return
@@ -1066,6 +1111,8 @@ class AssistantService:
             if generation != self._triage_generation:
                 self._triage_dump.discard(ts)
                 return
+            self._apply_dwell(signals)
+            prior_signals = self.last_triage
             prev_brief = (self.last_triage or {}).get("brief")
             retained = prev_brief if isinstance(prev_brief, str) and prev_brief else None
             merged = {**signals, "brief": retained} if retained else signals
@@ -1080,7 +1127,7 @@ class AssistantService:
         )
         cfg = self.cfg.triage
         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 (
             self._brief_fn is not None
@@ -1332,6 +1379,7 @@ class AssistantService:
         self._last_triage_window = ()
         self._triage_next_due = None
         self.triage_history = []
+        self._signal_streaks.clear()
         if self._fact_ledger is not None:
             self._fact_ledger.clear()
         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",
                       "disabled", "error")
 
+    def window_line(label, text, include_speaker=True) -> str
     def load_questions(path: str) -> dict | None
 
     class TriageAnalyzer:
@@ -60,6 +61,8 @@ TRIAGE_REASONS = ("ok", "no_questions", "loading", "laya_missing", "disabled", "
 
 _VALID_TYPES = ("choice", "score", "noul")
 
+_LOW_SIGNAL_P = 0.30
+
 # Canonical brief sections in model emission order; regression detection and the
 # reply template both depend on this exact list.
 BRIEF_HEADINGS = (
@@ -102,6 +105,11 @@ def load_questions(path: str) -> dict | None:
             )
         if "instructions" not in qdef:
             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")
         if qtype == "choice":
             if not isinstance(crit, (dict, list)):
@@ -126,6 +134,14 @@ def load_questions(path: str) -> dict | None:
     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):
     if isinstance(value, bool):
         return None
@@ -265,16 +281,36 @@ class TriageAnalyzer:
             if confidence is None or answer_confidence is None:
                 return None
             gate = confidence if field == "confidence" else answer_confidence
+            threshold = self._question_threshold(qid, qdef)
             return {
                 "kind": "noul",
                 "p": prob,
                 "confidence": confidence,
                 "answer_confidence": answer_confidence,
                 "gate": gate,
-                "flagged": prob >= self._escalate_threshold(),
+                "threshold": threshold,
+                "flagged": prob >= threshold,
             }
         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:
         """Normalize a full laya result or a bare answers dict into signals."""
         result = {"order": [], "items": {}, "escalated": False, "input_tokens": 0}
@@ -301,6 +337,8 @@ class TriageAnalyzer:
                 continue
             result["order"].append(qid)
             result["items"][qid] = item
+            if not self._signal_visible(qid, item):
+                result["hidden"] = result.get("hidden", 0) + 1
         result["escalated"] = self.should_escalate(result)
         return result
 
@@ -325,7 +363,12 @@ class TriageAnalyzer:
             kind = item.get("kind")
             if kind == "noul":
                 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
             elif kind == "choice":
                 gate = _number(item.get("gate"))
@@ -482,13 +525,8 @@ class TriageAnalyzer:
                     item = items.get(qid)
                     if not isinstance(item, dict):
                         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
                     lines.append(
                         "- " + self._signal_words(qid, item, qdef=qdef,
@@ -506,6 +544,41 @@ class TriageAnalyzer:
             pass
         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:
         """Render new speaker-labeled utterances + focus words; no JSON keys."""
         try:
@@ -540,9 +613,9 @@ class TriageAnalyzer:
             "CURRENT BRIEF:\n" + (current or "(none yet)"),
             "NEW UTTERANCES:\n" + body,
         ]
-        focus = self._signal_lines(signals)
+        focus = self._signal_compact(signals)
         if focus:
-            parts.append("Focus:\n" + "\n".join(focus))
+            parts.append("Focus (classifier verdicts, not meeting content):\n" + "\n".join(focus))
         instructions = [
             "Reply with exactly these two sections and nothing else:\n"
             "NEW FACTS:\n"
@@ -557,7 +630,9 @@ class TriageAnalyzer:
             " Lead the Summary with currently-open opportunities first, and keep "
             "the Opportunities section the most complete and current without "
             "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:
             instructions.append(
@@ -825,20 +900,51 @@ class TriageAnalyzer:
                 return None
         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):
             return True
         items = signals.get("items", {})
         if not isinstance(items, dict):
             return True
+        if "substantive_update" not in items:
+            return True
         item = items.get("substantive_update")
         if not isinstance(item, dict):
             return True
         prob = _number(item.get("p"))
         if prob is None:
             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:
         """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",
     )
 
+    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()
 
     # Handle legacy compatibility: --list-sources at top level
@@ -315,6 +345,8 @@ def main() -> int:
         return _handle_speaker_enroll_seed(args)
     elif args.command == "speaker-eval":
         return _handle_speaker_eval(args)
+    elif args.command == "triage-eval":
+        return _handle_triage_eval(args)
     else:
         # No subcommand given: run daemon with optional check
         if hasattr(args, "check") and args.check:
@@ -720,6 +752,68 @@ def _handle_speaker_enroll_seed(args) -> int:
     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:
     """Score the speaker store against a labeled WAV tree (read-only)."""
     try:

+ 8 - 0
src/stt_daemon/config.py

@@ -202,6 +202,8 @@ class TriageConfig:
     questions_file: str = "questions.json"
     window: int = 500
     state_char_budget: int = 3000
+    state_include_speaker: bool = True
+    confirm_windows: int = 2
     periodic_s: float = 30.0
     predict_max_tokens: int = 1024
     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", 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),
                 predict_max_tokens=triage_data.get(
                     "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 "--:--:--"
 
 
+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:
     """Format the gating confidence defensively; '?' when absent."""
     for key in ("answer_confidence", "confidence"):
@@ -101,6 +127,7 @@ class ViewState:
     last_error: dict | None = None
     chat_epoch: int = 0
     triage: dict | None = None
+    triage_prev: dict | None = None
     triage_state: str = "disabled"
     triage_reason: str = ""
     triage_epoch: int = 0
@@ -130,6 +157,7 @@ class ViewState:
                 self.speech_open = bool(event["speech_open"])
             changed = False
             if "triage" in event:
+                self.triage_prev = self.triage
                 self.triage = event["triage"]
                 changed = True
             if "triage_state" in event:
@@ -156,6 +184,7 @@ class ViewState:
                 # A triage payload can only come from a ready lane: authoritative.
                 self.triage_state = "ok"
                 self.triage_reason = ""
+                self.triage_prev = self.triage
                 self.triage = event.get("triage")
                 self.triage_ts = event.get("ts", self.triage_ts)
             self.triage_epoch += 1
@@ -308,6 +337,11 @@ class ViewState:
 
         order = self.triage.get("order")
         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] = []
         if isinstance(order, list) and isinstance(items, dict):
             for qid in order:
@@ -316,16 +350,21 @@ class ViewState:
                     lines.append(f"{qid} ⚠" if item else str(qid))
                     continue
                 kind = item.get("kind")
+                previous = prev_items.get(qid)
+                before = previous if isinstance(previous, dict) else None
                 flagged = bool(item.get("flagged"))
-                mark = " ⚠" if flagged else ""
+                mark = " ⚠" if flagged and not (before or {}).get("flagged") else ""
                 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":
                     p = _num(item.get("p"))
                     bar = "█" * int(p * 10) if p is not None else ""
                     bar = bar.ljust(10)
                     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":
                     level = _num(item.get("level"))
                     bar = ("█" * int(level)).ljust(10) if level is not None else " " * 10
@@ -334,6 +373,10 @@ class ViewState:
                 else:
                     line = f"{qid}{mark}" if flagged else str(qid)
                 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")
         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.chat_history == ["typed"]
-    assert service.triage_history == ["spoken"]
+    assert service.triage_history == ["Unknown: spoken"]
 
 
 def test_set_chat_capture_emits_once_and_reports_status():
@@ -782,6 +782,9 @@ class FakeTriage:
     def is_regression(self, 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):
         return _real_analyzer.parse_facts(block)
 
@@ -790,8 +793,10 @@ class FakeTriage:
             return text if text else None
         return self._parsed
 
-    def should_update_brief(self, signals):
+    def should_update_brief(self, signals, prior=None):
         self.gate_calls.append(signals)
+        if self._gate is _AUTO:
+            return _real_analyzer.should_update_brief(signals, prior)
         return self._gate
 
 
@@ -1017,12 +1022,12 @@ def test_maybe_triage_runs_in_idle_mode():
 
     assert len(lane.submits) == 1
     state, questions, lang, gen = lane.submits[0]
-    assert state == "- hello"
+    assert state == "- Unknown: hello"
     assert questions is triage.questions
     assert lang is None
     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():
@@ -1039,7 +1044,7 @@ def test_maybe_triage_uses_transcript_buffer_not_chat_history():
     service.tick()
 
     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():
@@ -1061,7 +1066,7 @@ def test_maybe_triage_periodic_fires_before_turn_settles():
 
     assert service.turn_ended() is False
     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():
@@ -1092,7 +1097,7 @@ def test_maybe_triage_periodic_rearms_after_submit():
     clock.advance(0.25)
     service.tick()
     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():
@@ -1191,7 +1196,7 @@ def test_maybe_triage_dedupes_identical_window():
 
     assert len(lane.submits) == 1
     state, questions, lang, gen = lane.submits[0]
-    assert state == "- hello"
+    assert state == "- Unknown: hello"
     assert questions is triage.questions
     assert lang is None
     assert gen == 0
@@ -1204,7 +1209,7 @@ def test_maybe_triage_dedupes_identical_window():
     clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
     service.tick()
     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():
@@ -1219,7 +1224,7 @@ def test_second_transcript_produces_second_submit():
     service.tick()
 
     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():
@@ -1319,7 +1324,7 @@ def test_pending_brief_capped_by_char_budget():
     service.handle_event(_transcript(clock, "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():
@@ -1327,7 +1332,7 @@ def test_pending_brief_untouched_without_triage():
     service.handle_event(_transcript(clock, "hello"))
 
     assert service._pending_brief == []
-    assert service.triage_history == ["hello"]
+    assert service.triage_history == ["Unknown: hello"]
 
 
 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._triage_generation == 0
     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._last_brief_signals is not None
     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._triage_generation == 0
     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._last_brief_signals is not None
     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)
     service.tick()
     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():
@@ -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"))
     assert data["ts"] == clock()
     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"]["model"] == "multilingual"
     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 brief_file.directories == [session]
-    assert service.triage_history == ["hello"]
+    assert service.triage_history == ["Unknown: hello"]
     assert service._pending_brief == ["Unknown: hello"]
     assert service._triage_generation == 0
 
@@ -2835,7 +2840,7 @@ def test_replay_single_session_adopts_without_reset():
     assert brief_file.directories == [session]
     assert service._triage_generation == 0
     assert _cleared(emitted) == []
-    assert service.triage_history == ["hello"]
+    assert service.triage_history == ["Unknown: hello"]
 
 
 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.last_triage is None
     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._pending_chat == ["Efren: hi", "Unknown: yo"]
-    assert service.triage_history == ["hi", "yo"]
+    assert service.triage_history == ["Efren: hi", "Unknown: yo"]
 
 
 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,
         "answer_confidence": 0.85,
         "gate": 0.85,
+        "threshold": 0.6,
         "flagged": True,
     }
     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 prompt.index("CURRENT LEDGER") < prompt.index("CURRENT BRIEF:")
     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 "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):
     analyzer = _analyzer(tmp_path, _score_questions())
     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)
     assert "currently-open opportunities first" 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"})
     assert state.session_dir == "transcripts/2026-09-30-12-47-29"
     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)"