# Triage Panel The triage panel is a display-only signal readout in the assistant TUI. On each settled turn (no chat mode required) — and on each chat turn when the `send to triage` toggle is on — the assistant classifies the most recent utterances (transcribed speech, plus labeled chat turns when enabled) with a local Laya **multilingual** model running in-process, then folds the turn into a living plain-text brief on the existing `[llm]` lane through a dedicated brief conversation. Signals and brief land in a right-hand pane. Nothing here triggers a mode, a command, or a tool: the panel only shows what the model inferred. The feature is off by default (`[triage].enabled = false`), and the extra it needs is opt-in, so a plain `uv sync` and the existing test suite behave exactly as before. ## What the panel shows - **A right-hand TUI pane**, composed only when `[triage].enabled = true`. With the default `false` there is no widget at all. - **`signals`** after a settled turn: one line per question, rendered from the normalized answer. - `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 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. - **A reason line** while the lane is not `ok`, for example when the model is still loading or the `questions.json` file is missing. The panel never renders blank. - **A last-triage clock** as the first line whenever a real triage is shown: the server-side time of the last triage in the transcript pane's `HH:MM:SS` format and `bold cyan` style. - **Cleared on reset**: `clear` empties the pane. Leaving chat mode does not. The panel is a view over events (`stage` of `signals`, `content`, or `cleared`) plus the cached `triage_state` / `triage_reason` / `questions` in `status()`. It never feeds anything back into the assistant. Pressing `s` in the TUI swaps the pane to a structured view of the loaded `questions.json` and pressing it again swaps back. The view renders one block per qid in file order — the qid, its type, the instructions, and the criteria (choice labels, a label-to-description map, ordered score levels, or the `true`/`false` descriptions of a `noul` question). The questions come from the assistant's validated payload (the same dict Laya uses), not from disk, and it is a no-op when `[triage].enabled = false`. ## How the brief works ``` settled turn (any mode), every periodic_s while speech keeps accumulating, or a chat turn -> newest utterance window (window entries, condensed under state_char_budget; transcribed speech, plus labeled chat turns when send-to-triage is on) -> Laya multilingual classification (in-process lane) -> signals (emitted; flagged per should_escalate) -> extract_enabled and the fail-open gate pass? -> one brief-update job on the LLM InferenceQueue (pending speaker-labeled deltas + Focus signal words; a Laya error still runs with the deltas only) -> plain-text brief merged as content ``` 1. **Trigger.** After the chat dispatch block in the assistant tick, triage runs on any settled turn in any mode (idle, chat, or command), independent of chat. It runs when the triage components are wired and the turn has ended (`turn_ended()`: speech stopped plus quiet for `turn_gap_s`). Because continuous speech never produces a settled turn, triage also re-runs every `periodic_s` seconds (default `30`) while new utterances keep arriving: the timer arms on the first transcript of a burst and re-arms on every accepted submit, so a long monologue is classified roughly every `periodic_s` instead of only when it stops. Set `periodic_s = 0` to disable the periodic trigger and require a settled turn. A repeated window is skipped, so an identical set of utterances never re-gates. When the TUI's `send to triage` toggle is on (see [ASSISTANT.md](ASSISTANT.md)), each chat turn also forces a pass through the same guards; the assistant reply is appended, plus the user's message when `send transcripts` is off. 2. **Window.** The window is the newest `window` entries: transcribed utterances from the STT stream, plus labeled chat turns (`You:`/`Assistant:`) when `send to triage` is on — never the raw chat conversation history. `build_state` condenses it newest-first under `state_char_budget` and restores chronological order, with `- ` prefixed lines. 3. **Classification.** The condensed state, the loaded questions, the last transcript language, and the current triage generation go to the Laya lane. The lane runs one multilingual `predict` at a time in the background. The raw answers come back on the lane thread, and the analyzer normalizes them into `signals` (`{order, items, escalated, input_tokens}`). Stale generations are dropped before the service stores or emits anything. 4. **Escalation.** `should_escalate` is true when any one of three arms fires: - `noul`: `p >= escalate_threshold` - `choice`: the gating confidence is `< choice_min_confidence` - `score`: `expected >= (levels - 1) * 0.5`, where `levels` is the number of score criteria Escalation only drives the per-item `flagged` flag and the `⚠` marker in the panel; it drives neither the gate nor the brief. 5. **Brief update (code-owned ledger).** The service queues **one** `brief-update` job on the existing LLM lane. The job carries the speaker-labeled pending deltas (the transcripts, plus labeled chat turns when `send to triage` is on, that arrived since the last accepted update) plus the current signal words under a `Focus:` header; a Laya error still runs with the deltas only. Each `Focus:` line carries the question `instructions` (clipped to ~160 chars), the measured value (`p`, selected `choice`, or `expected`/`level`/`label`), the gating confidence, and `flagged` / `[escalated]` markers — e.g. `- blocker (p=0.91, flagged, gate=0.90): An obstacle ...` or `- decision_status (choice): selected made (conf 0.90) — What is the state ...? Options: a decision was reached`. Low-value `noul` noise (`p < 0.30`) is omitted, except `substantive_update` and `opportunity` which are always kept; `opportunity` lines sort first so the brief leads with open opportunities. `choice`/`score` items are always kept with their uncertainty flagged. The brief engine is **stateless**: it sends exactly two messages per call — a static system prompt and ONE user turn containing `CURRENT LEDGER` (the recorded facts), `CURRENT BRIEF` (the volatile narrative), and `NEW UTTERANCES` (the deltas). No conversation history is replayed, so the prompt stays flat instead of growing and the model never competes with stale copies of its own output. The model returns exactly two sections: `NEW FACTS:` (only genuinely-new durable facts, one per `- ` line, or `none`) and `BRIEF:` (the volatile narrative: Summary, Opportunities, Blockers/Risks, Decisions, Open Questions). `extract_enabled` is the brief engine's master switch: with it false the panel still classifies and shows signals, but no brief is requested. The rendered brief is emitted as the `content` stage, whose payload key is `brief` (the event carries the merged signals plus `brief`), and, when `[brief].write = true`, snapshotted to disk. 6. **Ledger ownership.** Code — not the model — owns the durable facts. The `FactLedger` appends the reply's `NEW FACTS:` with conservative dedupe (exact normalized match, or a fuzzy near-match only when its **numbers and entities are identical**), evicts the oldest facts past `ledger_max_facts` (logged), and truncates each fact past `fact_max_chars`. It then renders the final brief as the narrative plus a `Prior Details:` section. A recorded fact can therefore only disappear via a logged cap eviction — never because the model dropped it, and never because it wrote prose instead of bullets. Resolved work is never deleted: the prompt requires completed actions / answered questions to move to `Decisions` as `Done: — resolved: ` plus a matching `NEW FACTS:` bullet, and `restore_missing_bullets` re-appends any `- ` bullet the model still dropped as a backstop. The guarantee covers *recorded* facts: a fact the model never lists under `NEW FACTS:` was never recorded. The ledger resets on `clear` and on a new session, alongside the rest of the triage state. 7. **Truncation safety.** `NEW FACTS:` is emitted before `BRIEF:` so a token-cap cutoff loses narrative (regenerated every pass) rather than a durable fact; the final fact line is discarded when the reply reports `finish_reason == "length"`. 8. **No compaction.** The brief engine does not use `ConversationEngine`, so there is no history to compact and `[llm].brief_compaction_system_prompt` is unused by the brief path. `[llm]` compaction settings still govern the chat/command session. 9. **Reset.** `clear` bumps the triage generation, clears the stored signals and the ambient buffer, drops any pending lane request and any queued `brief-update` job, truncates the optional durable brief file, and emits the `cleared` stage. An in-flight result that lands after the bump is suppressed, so a stale brief writes neither the pane nor the file. Leaving chat mode does not reset the panel or its buffer. A new conversation also resets the brief, which starts fresh in the new session folder, without clearing chat or command state. ## Single-outstanding job and coalescing Only one `brief-update` job may exist at a time. The service tracks a token and an outstanding flag under its lock: - If a job is already outstanding, the new turn is refused: its signals still show in the panel, but no second job is stacked and the turn's deltas stay in the pending buffer. - The job is submitted with `submit_if_room`, so a full queue never evicts a queued chat turn just to run a brief. - On refusal, an empty `""` reply, or an exception, the deltas are re-queued at the front of the buffer and the outstanding flag is cleared, so the next settled turn can retry. - On success, if deltas accumulated while the job was in flight, a coalesced flush runs immediately and dispatches the next job with the latest signals. The buffer is bounded by `brief_pending_char_budget` (default 3000 characters): once full, the oldest deltas are dropped, so a long quiet stretch can't grow it without limit. A `clear` mid-flight makes the in-flight result stale; it is discarded and writes nothing. ## Lanes: nothing is parallelized The assistant has exactly four single-worker lanes, and you should not parallelize any of them: | lane | what runs on it | |---|---| | transcription | one worker; faster-whisper is not thread-safe | | speaker embedding | one worker in-process in the daemon; owns the `SpeakerStore` | | LLM `InferenceQueue` | one worker; all chat, command, and `brief-update` calls | | Laya | one worker plus one loader thread; one `predict` at a time | The triage feature adds no process, no UNIX socket, and no supervisor child. The Laya part is a dedicated in-process lane with a background loader thread (lazy import, `preload`, one throwaway warmup predict) and a single-flight worker. That keeps the 0.1 s tick and the LLM lane unblocked, which multiplexing Laya onto the `InferenceQueue` would not. The supervisor still spawns 2 children headless and 3 with the TUI. The brief engine runs on the shared LLM lane, so its work is visible in the TUI: the triage pane border shows `[inf N]` with the queue depth and `· busy` while a job is running, the same counter the chat/command pane shows. Brief jobs are non-evictable, so a full queue refuses the brief instead of dropping a queued chat turn. ## Questions file Triage reads a bare Laya `questions` dict: a JSON object whose keys are question ids (`qid`) and whose values are question definitions. ### Schema ```json { "category": { "type": "choice", "instructions": "What is the user mainly talking about?", "criteria": { "support": "the user needs help", "sales": "the user asks about pricing", "other": "anything else" } }, "urgency": { "type": "choice", "instructions": "How urgent does the user sound?", "criteria": ["low", "medium", "high"] }, "churn_risk": { "type": "noul", "instructions": "The user is thinking about leaving or cancelling." } } ``` Each definition needs `type` and `instructions`. `criteria` depends on the type: | type | `criteria` | rules | |---|---|---| | `choice` | dict or list, required | non-empty; a dict maps each label to a description | | `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 missing, or when the `criteria` above are malformed. `load_questions` returns `None` instead of raising, so a bad file never takes the assistant down. ### Getting a valid file A ready-made file ships in the repo: ```bash cp questions.example.json questions.json ``` The example is the eight-question brief set, in file order: | qid | type | brief mapping | |---|---|---| | `substantive_update` | `noul` | the fail-open gate; not a brief section itself | | `opportunity` | `noul` | Opportunities | | `blocker` | `noul` | Blockers/Risks | | `decision_status` | `choice` | Decisions | | `action_item` | `noul` | Action Items | | `responsibility_clarity` | `choice` | Responsibilities | | `deadline` | `noul` | Deadlines | | `open_question` | `noul` | Open Questions | Each `noul` question in the example also ships a `threshold` (0.70–0.90), measured across real sessions with `triage-eval` so that a flag means something instead of matching most of a meeting. Delete a key to fall back to `[triage].escalate_threshold`; re-measure before trusting them on a different kind of conversation. `substantive_update` is the gate: when the model returns a `p` below `brief_gate_threshold`, the turn is still classified and shown, but the brief isn't asked to update. The gate is fail-open, so a missing or non-numeric answer passes. `questions.json` is gitignored user state; edit it in place. The file path is relative to the assistant's working directory (`questions_file`, default `questions.json`). ## Install and enable The Laya dependency lives behind an optional extra, pinned to one exact revision: ```bash # 1. Install the triage extra (adds laya) uv sync --extra triage # 2. Provide the questions file cp questions.example.json questions.json # 3. Turn it on # [triage] # enabled = true ``` The extra (`triage = ["laya"]`) resolves through `[tool.uv.sources]`, which pins Laya to revision `970dc8c5f63d7b886a68409493f37d569424f933` (version `0.3.20`). This revision is recorded in the plan and asserted by the measurement test below. The pin is exact on purpose: Laya is an early project, and API drift between revisions is real. The multilingual checkpoint is pinned for every call (`default_checkpoint = "multilingual"`, `preload = ["multilingual"]`, and a per-request `model="multilingual"`). There is no English-only `.en` variant and none is selected. Run triage-enabled from the normal supervised flow, `uv run stt-daemon up --with-tui`; no extra command is needed. ## Multilingual pin and the calibration caveat Because the multilingual checkpoint is pinned, every classification uses it. Multilingual runs at temperature 1.0 upstream, so its confidence scores are **uncalibrated out of the box**. The default thresholds (`escalate_threshold = 0.6`, `choice_min_confidence = 0.85`) are starting points, not measured values. Measure on your own transcripts before trusting the `⚠` flagging (the thresholds drive neither the gate nor the brief), and lower or raise the thresholds to match what you observe. The measurement lives in `tests/test_laya_lane.py::test_multilingual_pin_lang_and_resources`, gated by `pytest.importorskip("laya")`, so the default suite skips it green and the extra enables it. With the extra installed: ```bash uv run pytest tests/test_laya_lane.py::test_multilingual_pin_lang_and_resources -v -s ``` That test builds a multilingual `Router`, runs one `lang="es"` predict, and records the wall latency, `router.loaded`, and the process RSS delta, and it checks the installed Laya version and pin. The numbers it prints are environment-specific (CPU, GPU, drivers, disk), so they are not repeated here; read them from your own run. The test asserts only sanity and the pin, never a benchmark target. ## Configuration reference All keys live under `[triage]` unless noted. The defaults are what you get with no config file. | key | default | meaning | |---|---|---| | `enabled` | `false` | Master gate. `false` means no lane, no panel, and behavior identical to before. | | `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). | | `escalate_threshold` | `0.6` | A `noul` answer at or above this `p` is flagged (`⚠` marker only). | | `choice_min_confidence` | `0.85` | A `choice` answer below this confidence is flagged (`⚠` marker only). | | `extract_enabled` | `true` | Master switch for the brief engine: with it false the panel classifies but no brief is requested. | | `extract_max_tokens` | `3072` | Token cap for the brief reply; a reply cut at this limit is rejected and the prior brief kept. | | `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 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. | | `ledger_max_facts` | `200` | Cap on code-owned durable facts; the oldest are evicted (logged) past this. | | `fact_max_chars` | `240` | Per-fact character cap; over-long facts are truncated. | | `device` | `""` | Empty string means auto, which maps to `None` so Laya picks the checkpoint's default device. Any other value is passed through. | | `preload` | `["multilingual"]` | Checkpoints preloaded at lane startup. The fixed default preloads the multilingual checkpoint only. | | `default_checkpoint` | `"multilingual"` | Router default and the per-request `model` for every call. Multilingual is pinned. | | `dump_io` | `false` | Opt-in I/O dump under `troubleshoot/triage/YYYY-MM-DD/HHMMSS-mmm.json`; a coalesced update writes two files (see below). | `[llm].triage_system_prompt` drives the brief engine: it is sent as the static system message, and the user turn carries the code-owned fact ledger (`CURRENT LEDGER`), the volatile narrative (`CURRENT BRIEF`), and the new segment. The model must return exactly two sections: `NEW FACTS:` (only genuinely-new durable facts, one per `- ` line, or `none`) and `BRIEF:` (the volatile narrative). It never rewrites the ledger. Code appends new facts with conservative dedupe, evicts the oldest past `ledger_max_facts`, truncates each past `fact_max_chars`, and renders the final brief. The reply is bounded by `extract_max_tokens`, `extract_timeout_s`, and `extract_temperature` (default `0.2`). `brief_max_chars` bounds only the `CURRENT BRIEF` sent on the next pass, trimmed on a line boundary; a length-truncated reply or a rewrite that drops a canonical section is rejected and the prior narrative stays authoritative. Because the brief engine keeps no conversation history, `[llm].brief_compaction_system_prompt` and the `[llm]` compaction settings do not apply to it; they remain in effect for the chat/command session. `[brief]` is a separate section that controls durability of the living brief. `write` (default `false`) gates disk persistence only: with it false the brief stays in the TUI, and with `write = true` each accepted brief is snapshotted to `//brief.md` inside the active conversation folder (falling back to `brief/brief.md`, cwd-relative and gitignored, when no session is active) and truncated on `clear`. A new conversation resets the brief, so each session folder gets a fresh one. ## Opt-in I/O dump (`dump_io`) With `[triage].dump_io = true` (default `false`) each triage writes a self-contained JSON file under `troubleshoot/triage/YYYY-MM-DD/HHMMSS-mmm.json` — local time with millisecond precision, the same naming scheme as the audio files (`//audio/HHMMSS-mmm-.wav`). A file holds the Laya request, the Laya response, the LLM request (endpoint URL plus the full chat-completion body), the LLM response (raw envelope), and the LLM round-trip time, keyed by the `ts` carried on the triage events: ```json { "ts": 1759073916.19, "iso": "2026-09-28T09:38:36.412000-06:00", "laya": {"request": {...}, "response": {...}, "error": null}, "llm": {"request": {"url": "...", "body": {...}}, "response": {...}, "error": null, "duration_ms": 812.4} } ``` `llm.duration_ms` is the wall-clock time spent in the LLM transport call (monotonic clock, millisecond precision), recorded on both success and failure. It measures only the request round-trip, not prompt building or reply parsing. A turn whose brief is coalesced (the previous `brief-update` was still in flight, so the turn was refused and its deltas were flushed later) produces **two** files, not one. The turn's own record carries only the `laya` half (`llm` is `null`); the later flush dispatch writes a separate record carrying only the `llm` half (`laya` is `null`), keyed by its own `ts`. Same-millisecond records get a `-N` suffix on the second and later files (`HHMMSS-mmm-1.json`), so the first record keeps the exact `HHMMSS-mmm.json` shape and no record overwrites another. When the brief does not run (the master switch is off, the gate fails, or no brief function is wired) the file still gets written with only the `laya` half (`llm` is `null`); on a Laya error the file records `laya.response = null` with `laya.error = "predict failed"`. The directory is fixed as `troubleshoot/triage/` (cwd-relative, like the session folders) — there is no second config key for it. Files contain conversation text, so they are gitignored private debug output. Only the triage Laya predict and the triage brief call are dumped (never the loader warmup predict or non-triage LLM calls), and dumping is best-effort: a dump failure is logged and swallowed, never disturbing triage behavior or events. ## Upgrading from the JSON extraction Earlier versions of this panel asked the LLM for a strict JSON extraction (`{summary, opportunities[], doubts[], action_items[{what, why}], risks[]}`) and rendered the parsed fields in the panel. That is replaced by the plain-text brief described above. - `[triage].extract_enabled` still exists and is still the master switch, but it now gates the brief update, not a JSON extraction. - `[triage].extract_max_tokens` (default raised to `3072`) is the brief reply cap, `extract_timeout_s` is the brief request timeout, and `extract_temperature` (default `0.2`) is the brief request temperature. - `[llm].triage_system_prompt` is now a plain-text brief instruction; the shipped default no longer asks for JSON. - The panel's `content` stage now carries a `brief` string instead of merged JSON. - `[brief]` (new section) optionally persists the brief to disk; `write` is `false` by default, so nothing changes on disk unless you opt in. ## Upgrading from a v5-draft config An earlier draft of this feature used an external service with its own transaction keys. Those keys are **not** `[triage]` keys and are silently ignored by `load_config` because it reads each field with a per-field `.get()`: | stale key | status | |---|---| | `socket` | no successor | | `model` | no successor; the checkpoint is pinned to `default_checkpoint` | | `predict_timeout_s` | no successor | | `checkpoint` | superseded by `default_checkpoint` | | `subfolder` | no successor | | `max_len` | no successor; the 8192-token long-document capability is out of scope | Remove them from `config.toml` to avoid confusion, but they will not change behavior if left in place. There is also no `act_probability` anywhere; the per-item `flagged`/`⚠` marker uses the gating confidence selected by `confidence_field`.