The triage panel is a display-only signal readout in the assistant TUI. On each settled
turn (no chat mode required) the assistant classifies the most recent transcribed
utterances 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.
[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.⚠ mark follows any item the analyzer flagged.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 (truncated only when it exceeds brief_max_chars).ok, for example when the model is still loading
or the questions.json file is missing. The panel never renders blank.HH:MM:SS format and bold cyan style.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.
settled turn (any mode)
-> newest transcribed-utterance window (window entries, condensed under state_char_budget)
-> 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
turn_ended(): speech stopped
plus quiet for turn_gap_s). A repeated window is skipped, so an identical set of
utterances never re-gates.window transcribed utterances from the STT
stream, not the chat conversation history. build_state condenses it newest-first
under state_char_budget and restores chronological order, with - prefixed lines.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.should_escalate is true when any one of three arms fires:
noul: p >= escalate_thresholdchoice: the gating confidence is < choice_min_confidencescore: 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.extract_enabled is the brief engine's master switch: with it false
the panel still classifies and shows signals, but no brief is requested. When it is true
and a brief function is wired, the fail-open gate decides whether the turn updates the
brief. The gate reads the substantive_update noul arm of the signals: the update runs
when p >= brief_gate_threshold (default 0.5), and any missing or non-numeric signal
passes. The service queues one brief-update job on the existing LLM lane. The job
carries the speaker-labeled pending deltas (the transcripts 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. [llm].triage_system_prompt drives a dedicated brief
conversation that keeps its own history; the reply is a plain-text brief, not JSON
(fences stripped, then truncated to brief_max_chars, never dropped). The accepted 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.ConversationEngine, so it compacts
its history independently, using [llm].brief_compaction_system_prompt and the [llm]
compaction settings. When it fires, the summary is injected as a second system message,
which resets that engine's prompt prefix: the server's prefix cache is invalidated
once for it, and the summarizer call can hold the shared LLM lane for up to
compaction_timeout_s.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.Only one brief-update job may exist at a time. The service tracks a token and an
outstanding flag under its lock:
submit_if_room, so a full queue never evicts a queued chat
turn just to run a brief."" 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.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.
The assistant has exactly three 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 |
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.
Triage reads a bare Laya questions dict: a JSON object whose keys are question ids
(qid) and whose values are question definitions.
{
"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 |
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.
A ready-made file ships in the repo:
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 |
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).
The Laya dependency lives behind an optional extra, pinned to one exact revision:
# 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.
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:
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.
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 transcribed utterances available per classification; the char budget is the real limiter. |
state_char_budget |
3000 |
Character cap (~750 tokens) for the condensed state sent to Laya. |
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 |
1536 |
Token cap for the brief reply; the brief is then truncated to brief_max_chars. |
extract_timeout_s |
30.0 |
Timeout for the brief request. |
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_threshold |
0.5 |
Gate threshold for substantive_update; the update runs at or above this p. |
brief_max_chars |
4000 |
Display cap for the brief; over this it is truncated, never dropped. |
brief_pending_char_budget |
3000 |
Pending transcript-character budget before a coalesced update is flushed; the oldest deltas are dropped past it. |
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 triage/YYYY-MM-DD/HHMMSS-mmm.json; a coalesced update writes two files (see below). |
[llm].triage_system_prompt drives the brief conversation: given the current brief (its
previous reply, if any) and the new segment, it must return the updated brief as plain text
only, with no markdown fences and no JSON. The reply is bounded by extract_max_tokens and
extract_timeout_s, then truncated to brief_max_chars.
[llm].brief_compaction_system_prompt is the summarizer used when the brief conversation's
own history is compacted.
[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 <session_dir>/<id>/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.
dump_io)With [triage].dump_io = true (default false) each triage writes a self-contained JSON
file under triage/YYYY-MM-DD/HHMMSS-mmm.json — local time with millisecond precision, the
same naming scheme as the audio files (<session_dir>/<id>/audio/HHMMSS-mmm-<reason>.wav). A file
holds the Laya request, the Laya response, the LLM request (endpoint URL plus the full
chat-completion body), and the LLM response (raw envelope), keyed by the ts carried on the
triage events:
{
"ts": 1759073916.19,
"iso": "2026-09-28T09:38:36.412000-06:00",
"laya": {"request": {...}, "response": {...}, "error": null},
"llm": {"request": {"url": "...", "body": {...}}, "response": {...}, "error": null}
}
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 triage/ (cwd-relative, like the session folders and audit/) —
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.
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 1536) is the brief reply cap, and
extract_timeout_s is the brief request timeout.[llm].triage_system_prompt is now a plain-text brief instruction; the shipped default no
longer asks for JSON.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.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.