Răsfoiți Sursa

Added triage dump feature

Efren Yevale Varela 1 lună în urmă
părinte
comite
be840109b7

+ 1 - 0
.gitignore

@@ -5,6 +5,7 @@ __pycache__/
 transcripts/
 transcripts/
 audio/
 audio/
 audit/
 audit/
+triage/
 models/
 models/
 speakers/
 speakers/
 *.onnx
 *.onnx

+ 3 - 3
AGENTS.md

@@ -64,7 +64,7 @@ publish-only.
   `XDG_RUNTIME_DIR` (sockets), `HF_HOME` (Whisper cache, honored by faster-whisper/huggingface).
   `XDG_RUNTIME_DIR` (sockets), `HF_HOME` (Whisper cache, honored by faster-whisper/huggingface).
 - `[triage]` is the display-only signal panel, off by default (`enabled = false`). Its
 - `[triage]` is the display-only signal panel, off by default (`enabled = false`). Its
   `questions_file` (`questions.json` in the cwd) is private user state and is gitignored, like
   `questions_file` (`questions.json` in the cwd) is private user state and is gitignored, like
-  `config.toml`.
+  `config.toml`. `dump_io = true` writes one JSON per triage under `triage/` (also gitignored).
 - `[speaker]` is opt-in per-utterance speaker identification, off by default, backed by the
 - `[speaker]` is opt-in per-utterance speaker identification, off by default, backed by the
   `SpeakerConfig` dataclass (12th known section; 14 keys: `enabled`, `model_path`, `provider`,
   `SpeakerConfig` dataclass (12th known section; 14 keys: `enabled`, `model_path`, `provider`,
   `threshold_id`, `threshold_new`, `min_enroll_s`, `rms_floor`, `margin`, `mint_cooldown_s`,
   `threshold_id`, `threshold_new`, `min_enroll_s`, `rms_floor`, `margin`, `mint_cooldown_s`,
@@ -83,7 +83,7 @@ publish-only.
 
 
 ## Testing
 ## Testing
 
 
-- `tests/` has 701 tests, all hardware- and network-free. Preserve that: inject fakes instead of
+- `tests/` has 733 tests, all hardware- and network-free. Preserve that: inject fakes instead of
   touching real `pactl`, audio devices, an LLM, or `sherpa_onnx` (the speaker lane uses a fake
   touching real `pactl`, audio devices, an LLM, or `sherpa_onnx` (the speaker lane uses a fake
   embedder; no ONNX in tests).
   embedder; no ONNX in tests).
 - Key seams: `Daemon(...)` accepts `capture_factory`, `vad_factory`, `transcriber_factory`,
 - Key seams: `Daemon(...)` accepts `capture_factory`, `vad_factory`, `transcriber_factory`,
@@ -102,7 +102,7 @@ publish-only.
 ## Conventions & gotchas
 ## Conventions & gotchas
 
 
 - Never commit gitignored runtime output: `config.toml`, `transcripts/`, `audio/`, `audit/`,
 - Never commit gitignored runtime output: `config.toml`, `transcripts/`, `audio/`, `audit/`,
-  `speakers/` (biometric), `models/`, `.omo/`.
+  `triage/`, `speakers/` (biometric), `models/`, `.omo/`.
 - New assistant tools: register in `assistant/tools.py`. Model-supplied arguments are JSON-parsed and
 - New assistant tools: register in `assistant/tools.py`. Model-supplied arguments are JSON-parsed and
   schema-validated before running; set `side_effect` and `requires_confirmation` for anything
   schema-validated before running; set `side_effect` and `requires_confirmation` for anything
   non-read-only.
   non-read-only.

+ 1 - 1
README.md

@@ -143,7 +143,7 @@ See [docs/CLI.md](docs/CLI.md) for every subcommand and [docs/CONFIGURATION.md](
 - [docs/AUDIO-SETUP.md](docs/AUDIO-SETUP.md) — virtual sink creation and audio routing options.
 - [docs/AUDIO-SETUP.md](docs/AUDIO-SETUP.md) — virtual sink creation and audio routing options.
 - [docs/CLI.md](docs/CLI.md) — `stt-daemon` subcommands and common overrides.
 - [docs/CLI.md](docs/CLI.md) — `stt-daemon` subcommands and common overrides.
 - [docs/ASSISTANT.md](docs/ASSISTANT.md) — the assistant layer: modes, command flow, LLM, tools, audit.
 - [docs/ASSISTANT.md](docs/ASSISTANT.md) — the assistant layer: modes, command flow, LLM, tools, audit.
-- [docs/TRIAGE.md](docs/TRIAGE.md) — the triage panel: Laya lane, escalation cascade, and questions file.
+- [docs/TRIAGE.md](docs/TRIAGE.md) — the triage panel: Laya lane, LLM extraction, and questions file.
 - [docs/SPEAKERS.md](docs/SPEAKERS.md) — speaker identification: enrollment, thresholds, store, and commands.
 - [docs/SPEAKERS.md](docs/SPEAKERS.md) — speaker identification: enrollment, thresholds, store, and commands.
 - [docs/OUTPUT.md](docs/OUTPUT.md) — stdout, JSONL, and WAV output formats and fields.
 - [docs/OUTPUT.md](docs/OUTPUT.md) — stdout, JSONL, and WAV output formats and fields.
 - [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md) — common errors and how to fix them.
 - [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md) — common errors and how to fix them.

+ 1 - 0
config.example.toml

@@ -106,6 +106,7 @@ extract_timeout_s    = 30.0
 device               = ""
 device               = ""
 preload              = ["multilingual"]
 preload              = ["multilingual"]
 default_checkpoint   = "multilingual"
 default_checkpoint   = "multilingual"
+dump_io              = false
 
 
 [speaker]
 [speaker]
 enabled           = false
 enabled           = false

+ 10 - 6
docs/ASSISTANT.md

@@ -159,12 +159,16 @@ three may be parallelized.
 
 
 On each settled turn in any mode (idle, chat, or command; `turn_ended()`), independent of chat,
 On each settled turn in any mode (idle, chat, or command; `turn_ended()`), independent of chat,
 the lane classifies the last `window` transcribed utterances from the STT stream (not the chat
 the lane classifies the last `window` transcribed utterances from the STT stream (not the chat
-history). Results flow through a **`signals -> content`** cascade: the signals stage
-always arrives first (per-category confidences), and when they escalate the assistant submits one
-`triage-extract` job to the existing LLM lane for a structured JSON extraction, which arrives as the
-content stage and is merged over the signals. The panel renders both; it is display-only and triggers
-no modes, commands, or tools. Leaving chat mode does not clear the panel or its buffer; an explicit
-`clear` does.
+history). Results flow through a `signals -> content` two-stage exchange: the signals stage always
+arrives first (per-category confidences), and the assistant then submits one `triage-extract` job to
+the existing LLM lane for a structured JSON extraction, which arrives as the content stage and is
+merged over the signals. Extraction runs after every Laya pass when `extract_enabled` is true and an
+LLM client is wired; on a Laya error it still runs with transcript state only. The deployment
+thresholds (`escalate_threshold`, `choice_min_confidence`) only drive the per-item `⚠` marker.
+The panel renders the last-triage clock (transcript time format/color) and both stages; it is
+display-only and triggers no modes, commands, or tools. Leaving chat mode does not clear the panel or
+its buffer; an explicit `clear` does. With `dump_io = true` each triage also writes one JSON file
+under `triage/` (see [TRIAGE.md](TRIAGE.md)).
 
 
 ## Speakers
 ## Speakers
 
 

+ 7 - 3
docs/CONFIGURATION.md

@@ -130,6 +130,7 @@ extract_timeout_s    = 30.0                 # Extraction request timeout
 device               = ""                   # "" = auto -> None (Laya picks the multilingual checkpoint's default device)
 device               = ""                   # "" = auto -> None (Laya picks the multilingual checkpoint's default device)
 preload              = ["multilingual"]     # Fixed default: only the multilingual checkpoint is preloaded
 preload              = ["multilingual"]     # Fixed default: only the multilingual checkpoint is preloaded
 default_checkpoint   = "multilingual"       # Router default; multilingual is pinned for every call
 default_checkpoint   = "multilingual"       # Router default; multilingual is pinned for every call
+dump_io              = false                # Opt-in triage I/O dump: one JSON per triage under triage/YYYY-MM-DD/HHMMSS-mmm.json (Laya request/response + LLM request/response; best-effort, gitignored)
 
 
 [speaker]                                   # Speaker identification (sherpa-onnx embedding lane; off by default)
 [speaker]                                   # Speaker identification (sherpa-onnx embedding lane; off by default)
 enabled           = false                    # Master switch: false = no embedding lane, every record status "none"
 enabled           = false                    # Master switch: false = no embedding lane, every record status "none"
@@ -197,9 +198,12 @@ and `default_checkpoint = "multilingual"`; the multilingual checkpoint is pinned
 (there is no `.en` English-only variant). `predict_max_tokens` is the Laya token budget (the
 (there is no `.en` English-only variant). `predict_max_tokens` is the Laya token budget (the
 multilingual checkpoint supports 1024); the condensed state shares it with the ~192-token
 multilingual checkpoint supports 1024); the condensed state shares it with the ~192-token
 question head, leaving ~830 tokens of room for state at the default. `confidence_field` selects which of Laya's two
 question head, leaving ~830 tokens of room for state at the default. `confidence_field` selects which of Laya's two
-confidence values gates escalation, with `answer_confidence` (the calibrated `max(p)`) as the
-default. With `enabled = false` (the default) the lane is never built and the system behaves
-exactly as before.
+confidence values drives the per-item `flagged`/`⚠` marker, with `answer_confidence` (the
+calibrated `max(p)`) as the default. With `enabled = false` (the default) the lane is never built and the system behaves
+exactly as before. `dump_io = true` additionally writes one JSON file per triage under
+`triage/YYYY-MM-DD/HHMMSS-mmm.json` (local time, millisecond precision, same naming scheme as
+the audio files) containing the Laya request/response and the LLM request/response; it is
+off by default, best-effort (a dump failure never disturbs triage), and gitignored.
 
 
 `[llm].triage_system_prompt` is the strict JSON-only instruction used for the extraction cascade;
 `[llm].triage_system_prompt` is the strict JSON-only instruction used for the extraction cascade;
 it must return `{summary, opportunities[], doubts[], action_items[{what, why}], risks[]}` and
 it must return `{summary, opportunities[], doubts[], action_items[{what, why}], risks[]}` and

+ 5 - 1
docs/DEVELOPMENT.md

@@ -55,6 +55,10 @@ src/stt_daemon/
 │   ├── executor.py      - NullExecutor / LlmExecutor (tool loop)
 │   ├── executor.py      - NullExecutor / LlmExecutor (tool loop)
 │   ├── tools.py         - Tool registry + schema validation + get_datetime
 │   ├── tools.py         - Tool registry + schema validation + get_datetime
 │   ├── llm.py           - llama.cpp client + chat session (block trimming)
 │   ├── llm.py           - llama.cpp client + chat session (block trimming)
+│   ├── inference.py     - Single-lane LLM queue (chat/command/triage-extract)
+│   ├── triage.py        - Triage analyzer (normalize, flag, extraction parse)
+│   ├── laya_lane.py     - In-process Laya lane (loader + single-flight worker)
+│   ├── triage_dump.py   - Opt-in per-triage I/O dump (`dump_io`)
 │   ├── audit.py         - Daily action audit log
 │   ├── audit.py         - Daily action audit log
 │   ├── runner.py        - `stt-assistant` process
 │   ├── runner.py        - `stt-assistant` process
 │   └── __main__.py      - Entry point for `python -m stt_daemon.assistant`
 │   └── __main__.py      - Entry point for `python -m stt_daemon.assistant`
@@ -63,7 +67,7 @@ src/stt_daemon/
     ├── client.py        - Control-socket client
     ├── client.py        - Control-socket client
     └── app.py           - Textual app + `stt-tui` entry point
     └── app.py           - Textual app + `stt-tui` entry point
 
 
-tests/                    - 701 tests, no audio hardware or live LLM required
+tests/                    - 733 tests, no audio hardware or live LLM required
 
 
 scripts/
 scripts/
 ├── audio-sink.sh        - Thin wrapper over `stt-daemon setup|clear|list`
 ├── audio-sink.sh        - Thin wrapper over `stt-daemon setup|clear|list`

+ 5 - 0
docs/OUTPUT.md

@@ -60,3 +60,8 @@ emitted and a warning is logged.
 > Point `audio_dir` at a volume with headroom and prune it yourself (e.g. a cron
 > Point `audio_dir` at a volume with headroom and prune it yourself (e.g. a cron
 > job removing files older than N days), and leave `save_audio = false` unless you
 > job removing files older than N days), and leave `save_audio = false` unless you
 > actually need the clips for auditing.
 > actually need the clips for auditing.
+
+**Triage I/O dump (assistant-side)** — with `[triage].dump_io = true` the assistant writes one
+self-contained JSON per triage under `triage/YYYY-MM-DD/HHMMSS-mmm.json` (the Laya request/response
+plus the LLM request/response). It is separate from the daemon transcript output above, off by
+default, and best-effort. See [TRIAGE.md](TRIAGE.md#opt-in-io-dump-dump_io).

+ 34 - 3
docs/TRIAGE.md

@@ -191,7 +191,8 @@ Because the multilingual checkpoint is pinned, every classification uses it. Mul
 runs at temperature 1.0 upstream, so its confidence scores are **uncalibrated out of the
 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`)
 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
 are starting points, not measured values. Measure on your own transcripts before trusting
-the escalation behavior, and lower or raise the thresholds to match what you observe.
+the `⚠` flagging (the thresholds no longer gate extraction), 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`,
 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
 gated by `pytest.importorskip("laya")`, so the default suite skips it green and the extra
@@ -228,11 +229,41 @@ file.
 | `device` | `""` | Empty string means auto, which maps to `None` so Laya picks the checkpoint's default device. Any other value is passed through. |
 | `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. |
 | `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. |
 | `default_checkpoint` | `"multilingual"` | Router default and the per-request `model` for every call. Multilingual is pinned. |
+| `dump_io` | `false` | Opt-in I/O dump: one JSON file per triage under `triage/YYYY-MM-DD/HHMMSS-mmm.json` (see below). |
 
 
 `[llm].triage_system_prompt` is the strict JSON-only instruction used by the extraction
 `[llm].triage_system_prompt` is the strict JSON-only instruction used by the extraction
 cascade. It must return `{summary, opportunities[], doubts[], action_items[{what, why}],
 cascade. It must return `{summary, opportunities[], doubts[], action_items[{what, why}],
 risks[]}` and nothing else.
 risks[]}` and nothing else.
 
 
+## Opt-in I/O dump (`dump_io`)
+
+With `[triage].dump_io = true` (default `false`) each triage writes exactly one
+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
+(`audio/YYYY-MM-DD/HHMMSS-mmm-<reason>.wav`). The file holds all four I/O pieces of
+that triage: 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
+same `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}
+}
+```
+
+When extraction does not run 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"` plus the state-only LLM half. The directory is fixed
+as `triage/` (cwd-relative, like `transcripts/`/`audio/`/`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 extraction 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 a v5-draft config
 ## Upgrading from a v5-draft config
 
 
 An earlier draft of this feature used an external service with its own transaction keys.
 An earlier draft of this feature used an external service with its own transaction keys.
@@ -249,5 +280,5 @@ reads each field with a per-field `.get()`:
 | `max_len` | no successor; the 8192-token long-document capability is out of scope |
 | `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
 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; escalation uses the gating confidence
-selected by `confidence_field`.
+in place. There is also no `act_probability` anywhere; the per-item `flagged`/`⚠` marker uses the
+gating confidence selected by `confidence_field`.

+ 20 - 0
src/stt_daemon/assistant/llm.py

@@ -54,6 +54,13 @@ def _default_opener(payload: dict, timeout: float) -> dict:
         return json.loads(response.read().decode("utf-8"))
         return json.loads(response.read().decode("utf-8"))
 
 
 
 
+def _safe_io(cb, event) -> None:
+    try:
+      cb(event)
+    except Exception as e:  # noqa: BLE001 - a dump hook must not break chat
+      logger.warning("on_io callback failed: %s", e)
+
+
 class LlmClient:
 class LlmClient:
     """Minimal OpenAI-compatible chat client (stdlib only)."""
     """Minimal OpenAI-compatible chat client (stdlib only)."""
 
 
@@ -74,6 +81,7 @@ class LlmClient:
         tools: list[dict] | None = None,
         tools: list[dict] | None = None,
         max_tokens: int | None = None,
         max_tokens: int | None = None,
         timeout_s: float | None = None,
         timeout_s: float | None = None,
+        on_io: Callable[[dict], None] | None = None,
     ) -> LlmResponse:
     ) -> LlmResponse:
         """Send one completion request and parse the reply.
         """Send one completion request and parse the reply.
 
 
@@ -82,6 +90,8 @@ class LlmClient:
             tools: Optional tool schemas.
             tools: Optional tool schemas.
             max_tokens: Per-request output cap; uses cfg.max_tokens when None.
             max_tokens: Per-request output cap; uses cfg.max_tokens when None.
             timeout_s: Per-request timeout; uses cfg.timeout_s when None.
             timeout_s: Per-request timeout; uses cfg.timeout_s when None.
+            on_io: Optional hook receiving `{"phase": "request", ...}` before
+                the call and `{"phase": "response", ...}` after it.
         """
         """
         effective_max_tokens = self.cfg.max_tokens if max_tokens is None else max_tokens
         effective_max_tokens = self.cfg.max_tokens if max_tokens is None else max_tokens
         effective_timeout   = self.cfg.timeout_s  if timeout_s is None else timeout_s
         effective_timeout   = self.cfg.timeout_s  if timeout_s is None else timeout_s
@@ -97,13 +107,23 @@ class LlmClient:
             body["tools"] = tools
             body["tools"] = tools
             body["tool_choice"] = "auto"
             body["tool_choice"] = "auto"
 
 
+        if on_io is not None:
+            _safe_io(
+                on_io, {"phase": "request", "url": self.cfg.endpoint, "body": body}
+            )
+
         try:
         try:
             payload = self._opener(
             payload = self._opener(
                 {"__url__": self.cfg.endpoint, "body": body}, effective_timeout
                 {"__url__": self.cfg.endpoint, "body": body}, effective_timeout
             )
             )
         except (urllib.error.URLError, OSError, json.JSONDecodeError) as e:
         except (urllib.error.URLError, OSError, json.JSONDecodeError) as e:
+            if on_io is not None:
+                _safe_io(on_io, {"phase": "response", "payload": None, "error": str(e)})
             raise LlmError(str(e)) from e
             raise LlmError(str(e)) from e
 
 
+        if on_io is not None:
+            _safe_io(on_io, {"phase": "response", "payload": payload, "error": None})
+
         choices = payload.get("choices") or []
         choices = payload.get("choices") or []
         if not choices:
         if not choices:
             raise LlmError("response contained no choices")
             raise LlmError("response contained no choices")

+ 5 - 1
src/stt_daemon/assistant/runner.py

@@ -30,7 +30,10 @@ def build_triage(cfg, client, on_result=None):
     )
     )
     extract = None
     extract = None
     if client is not None:
     if client is not None:
-        def _extract(prompt):
+        def _extract(prompt, on_io=None):
+            kwargs = {}
+            if on_io is not None:
+                kwargs["on_io"] = on_io
             resp = client.chat(
             resp = client.chat(
                 [
                 [
                     {"role": "system", "content": cfg.llm.triage_system_prompt},
                     {"role": "system", "content": cfg.llm.triage_system_prompt},
@@ -38,6 +41,7 @@ def build_triage(cfg, client, on_result=None):
                 ],
                 ],
                 max_tokens=cfg.triage.extract_max_tokens,
                 max_tokens=cfg.triage.extract_max_tokens,
                 timeout_s=cfg.triage.extract_timeout_s,
                 timeout_s=cfg.triage.extract_timeout_s,
+                **kwargs,
             )
             )
             return resp.content
             return resp.content
 
 

+ 94 - 20
src/stt_daemon/assistant/service.py

@@ -19,6 +19,7 @@ from ..config import Config
 from .executor import ExecutionResult
 from .executor import ExecutionResult
 from .inference import InferenceJob, InferenceQueue
 from .inference import InferenceJob, InferenceQueue
 from .modes import Mode, ModeMachine
 from .modes import Mode, ModeMachine
+from .triage_dump import TriageDumper
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
@@ -63,7 +64,7 @@ class AssistantService:
         chat_reset: Callable[[], None] | None = None,
         chat_reset: Callable[[], None] | None = None,
         triage: Any = None,
         triage: Any = None,
         laya_lane: Any = None,
         laya_lane: Any = None,
-        triage_extract_fn: Callable[[str], str] | None = None,
+        triage_extract_fn: Callable[..., str] | None = None,
     ):
     ):
         """Create the service.
         """Create the service.
 
 
@@ -117,6 +118,8 @@ class AssistantService:
         self._last_triage_window: tuple = ()
         self._last_triage_window: tuple = ()
         self._triage_generation = 0
         self._triage_generation = 0
         self._last_transcript_lang: str | None = None
         self._last_transcript_lang: str | None = None
+        self._triage_dump = TriageDumper(bool(getattr(cfg.triage, "dump_io", False)))
+        self._last_laya_request: dict | None = None
 
 
         # Health
         # Health
         self._last_heartbeat_ts: float | None = None
         self._last_heartbeat_ts: float | None = None
@@ -678,7 +681,20 @@ class AssistantService:
         state = self._triage.build_state(window)
         state = self._triage.build_state(window)
         if not state:
         if not state:
             return
             return
-        self._laya_lane.submit(state, questions, self._last_transcript_lang, generation)
+        accepted = self._laya_lane.submit(
+            state, questions, self._last_transcript_lang, generation
+        )
+        if accepted and self._triage_dump.enabled:
+            # Newest-wins caveat: the lane is single-flight and does not echo
+            # a request id, so under two submits within one predict the paired
+            # request is the most recent submit (acceptable for debug I/O).
+            self._last_laya_request = {
+                "state": state,
+                "questions": questions,
+                "model": getattr(self.cfg.triage, "default_checkpoint", "multilingual"),
+                "lang": self._last_transcript_lang,
+                "max_len": int(getattr(self.cfg.triage, "predict_max_tokens", 1024)),
+            }
 
 
     def _on_triage_result(self, generation, payload) -> None:
     def _on_triage_result(self, generation, payload) -> None:
         if self._triage is None:
         if self._triage is None:
@@ -688,49 +704,106 @@ class AssistantService:
                 return
                 return
             window = self._last_triage_window
             window = self._last_triage_window
         ts = self._now()
         ts = self._now()
+        if self._triage_dump.enabled:
+            self._triage_dump.begin(
+                ts,
+                laya={
+                    "request": self._last_laya_request,
+                    "response": payload,
+                    "error": None if payload is not None else "predict failed",
+                },
+            )
         signals = None
         signals = None
         if payload is not None:
         if payload is not None:
             signals = self._triage.normalize(payload)
             signals = self._triage.normalize(payload)
             if not signals:
             if not signals:
+                self._triage_dump.finalize(ts)
                 return
                 return
             with self._lock:
             with self._lock:
                 if generation != self._triage_generation:
                 if generation != self._triage_generation:
+                    self._triage_dump.discard(ts)
                     return
                     return
                 self.last_triage = signals
                 self.last_triage = signals
             self._emit(TRIAGE_EVENT, stage="signals", triage=signals, ts=ts)
             self._emit(TRIAGE_EVENT, stage="signals", triage=signals, ts=ts)
-        if self._triage_extract_fn is not None and getattr(
+        will_extract = self._triage_extract_fn is not None and getattr(
             self.cfg.triage, "extract_enabled", True
             self.cfg.triage, "extract_enabled", True
-        ):
+        )
+        if will_extract:
             job = InferenceJob(
             job = InferenceJob(
                 name="triage-extract",
                 name="triage-extract",
                 run=lambda: self._run_triage_extract(generation, window, signals, ts),
                 run=lambda: self._run_triage_extract(generation, window, signals, ts),
             )
             )
-            self._inference.submit_if_room(job)
+            if not self._inference.submit_if_room(job):
+                self._triage_dump.finalize(ts)
+        else:
+            self._triage_dump.finalize(ts)
 
 
     def _run_triage_extract(self, generation, window, signals, ts=None) -> None:
     def _run_triage_extract(self, generation, window, signals, ts=None) -> None:
         if self._triage is None or self._triage_extract_fn is None:
         if self._triage is None or self._triage_extract_fn is None:
             return
             return
+        dump = self._triage_dump.enabled
+        if ts is None:
+            ts = self._now()
         with self._lock:
         with self._lock:
             if generation != self._triage_generation:
             if generation != self._triage_generation:
+                if dump:
+                    self._triage_dump.discard(ts)
                 return
                 return
-        prompt = self._triage.build_extraction_prompt(window, signals)
+        io_hook: Callable[[dict], None] | None = None
+        if dump:
+            def _record_io(part: dict) -> None:
+                if part.get("phase") == "request":
+                    self._triage_dump.record(
+                        ts,
+                        llm={
+                            "request": {
+                                "url": part.get("url"),
+                                "body": part.get("body"),
+                            },
+                            "response": None,
+                            "error": None,
+                        },
+                    )
+                else:
+                    self._triage_dump.record(
+                        ts,
+                        llm={
+                            "request": None,
+                            "response": part.get("payload"),
+                            "error": part.get("error"),
+                        },
+                    )
+            io_hook = _record_io
         try:
         try:
-            text = self._triage_extract_fn(prompt)
-        except Exception as e:  # noqa: BLE001 - an extract failure must not kill the worker
-            logger.error("Triage extract error: %s", e)
-            return
-        with self._lock:
-            if generation != self._triage_generation:
+            prompt = self._triage.build_extraction_prompt(window, signals)
+            try:
+                if dump:
+                    text = self._triage_extract_fn(prompt, on_io=io_hook)
+                else:
+                    text = self._triage_extract_fn(prompt)
+            except Exception as e:  # noqa: BLE001 - an extract failure must not kill the worker
+                logger.error("Triage extract error: %s", e)
                 return
                 return
-        parsed = self._triage.parse_extraction(text)
-        if parsed is None:
-            return
-        with self._lock:
-            if generation != self._triage_generation:
+            with self._lock:
+                if generation != self._triage_generation:
+                    return
+            parsed = self._triage.parse_extraction(text)
+            if parsed is None:
                 return
                 return
-            merged = {**(signals or {}), "content": parsed}
-            self.last_triage = merged
-        self._emit(TRIAGE_EVENT, stage="content", triage=merged, ts=ts)
+            with self._lock:
+                if generation != self._triage_generation:
+                    return
+                merged = {**(signals or {}), "content": parsed}
+                self.last_triage = merged
+            self._emit(TRIAGE_EVENT, stage="content", triage=merged, ts=ts)
+        finally:
+            if dump:
+                with self._lock:
+                    stale = generation != self._triage_generation
+                if stale:
+                    self._triage_dump.discard(ts)
+                else:
+                    self._triage_dump.finalize(ts)
 
 
     def _reset_triage(self) -> None:
     def _reset_triage(self) -> None:
         if self._triage is None and self._laya_lane is None:
         if self._triage is None and self._laya_lane is None:
@@ -740,6 +813,7 @@ class AssistantService:
             self.last_triage = None
             self.last_triage = None
             self._last_triage_window = ()
             self._last_triage_window = ()
             self.triage_history = []
             self.triage_history = []
+        self._triage_dump.clear()
         if self._laya_lane is not None:
         if self._laya_lane is not None:
             self._laya_lane.drop_pending()
             self._laya_lane.drop_pending()
         self._inference.drop(lambda j: j.name == "triage-extract")
         self._inference.drop(lambda j: j.name == "triage-extract")

+ 132 - 0
src/stt_daemon/assistant/triage_dump.py

@@ -0,0 +1,132 @@
+"""Opt-in triage I/O dump: one JSON file per triage with Laya + LLM I/O.
+
+Enabled via `[triage].dump_io = true` (off by default). When enabled, each
+triage handled in `_on_triage_result` produces exactly one file under
+`triage/YYYY-MM-DD/HHMMSS-mmm.json` (local time, millisecond precision — the
+same naming scheme as `audio/YYYY-MM-DD/HHMMSS-mmm-<reason>.wav`).
+
+The directory `triage/` is fixed (cwd-relative, like `transcripts/`/`audio/`/
+`audit/`) and gitignored. Files contain conversation text (transcript state,
+Laya questions/answers, LLM prompts), so treat them as private debug output.
+
+Best-effort by design: a dump failure is logged and swallowed, never raised,
+so it can never disturb the triage flow.
+
+Merge semantics: `begin`/`record` merge one level deep for the `laya` and
+`llm` halves, and an incoming `None` never clobbers an already-recorded
+non-`None` value. That is what lets the service record the LLM request in the
+request phase and the LLM response in the response phase without the second
+call wiping the first.
+"""
+
+import json
+import logging
+import os
+import tempfile
+import threading
+from datetime import datetime
+from pathlib import Path
+from typing import Any
+
+logger = logging.getLogger(__name__)
+
+
+class TriageDumper:
+    """Thread-safe, best-effort per-triage I/O recorder."""
+
+    def __init__(self, enabled: bool, directory: str = "triage") -> None:
+        self._enabled = bool(enabled)
+        self._directory = str(directory)
+        self._lock = threading.Lock()
+        self._pending: dict[float, dict] = {}
+
+    @property
+    def enabled(self) -> bool:
+        return self._enabled
+
+    def begin(self, ts: float, **parts: Any) -> None:
+        """Create (or merge into) the pending record keyed by `ts`."""
+        if not self._enabled:
+            return
+        with self._lock:
+            record = self._pending.get(ts)
+            if record is None:
+                record = {"ts": ts}
+                self._pending[ts] = record
+            self._merge(record, parts)
+
+    def record(self, ts: float, **parts: Any) -> None:
+        """Merge `parts` into the pending record keyed by `ts` (alias of begin)."""
+        self.begin(ts, **parts)
+
+    def finalize(self, ts: float) -> None:
+        """Pop the record for `ts` and write it to disk (no-op when unknown)."""
+        if not self._enabled:
+            return
+        with self._lock:
+            record = self._pending.pop(ts, None)
+        if record is None:
+            return
+        self._write(record)
+
+    def discard(self, ts: float) -> None:
+        """Drop the pending record for `ts` WITHOUT writing (no-op when unknown)."""
+        if not self._enabled:
+            return
+        with self._lock:
+            self._pending.pop(ts, None)
+
+    def clear(self) -> None:
+        """Drop all pending records."""
+        if not self._enabled:
+            return
+        with self._lock:
+            self._pending.clear()
+
+    @staticmethod
+    def _merge(record: dict, parts: dict) -> None:
+        for key, value in parts.items():
+            if (
+                key in ("laya", "llm")
+                and isinstance(value, dict)
+                and isinstance(record.get(key), dict)
+            ):
+                nested = record[key]
+                for sub_key, sub_value in value.items():
+                    if sub_value is None and nested.get(sub_key) is not None:
+                        continue
+                    nested[sub_key] = sub_value
+            else:
+                record[key] = value
+
+    def _write(self, record: dict) -> None:
+        try:
+            ts_raw = record.get("ts")
+            if not isinstance(ts_raw, (int, float)):
+                return
+            ts = float(ts_raw)
+            dt = datetime.fromtimestamp(ts)
+            day_dir = Path(self._directory) / dt.strftime("%Y-%m-%d")
+            day_dir.mkdir(parents=True, exist_ok=True)
+            filename = dt.strftime("%H%M%S") + "-" + dt.strftime("%f")[:3] + ".json"
+            final_path = day_dir / filename
+            payload = {
+                "ts": ts,
+                "iso": dt.astimezone().isoformat(),
+                "laya": record.get("laya"),
+                "llm": record.get("llm"),
+            }
+            text = json.dumps(payload, ensure_ascii=False, indent=2)
+            fd, tmp = tempfile.mkstemp(dir=str(day_dir), suffix=".tmp")
+            try:
+                with os.fdopen(fd, "w", encoding="utf-8") as f:
+                    f.write(text)
+                os.replace(tmp, final_path)
+            except OSError:
+                try:
+                    os.unlink(tmp)
+                except OSError:
+                    pass
+                raise
+        except Exception as e:  # noqa: BLE001 - dump is best-effort, never raises
+            logger.warning("triage I/O dump failed: %s", e)

+ 2 - 0
src/stt_daemon/config.py

@@ -174,6 +174,7 @@ class TriageConfig:
     device: str = ""
     device: str = ""
     preload: list[str] = field(default_factory=lambda: ["multilingual"])
     preload: list[str] = field(default_factory=lambda: ["multilingual"])
     default_checkpoint: str = "multilingual"
     default_checkpoint: str = "multilingual"
+    dump_io: bool = False
 
 
 
 
 @dataclass
 @dataclass
@@ -501,6 +502,7 @@ def load_config(path: str | None = None) -> Config:
                 default_checkpoint=triage_data.get(
                 default_checkpoint=triage_data.get(
                     "default_checkpoint", cfg.triage.default_checkpoint
                     "default_checkpoint", cfg.triage.default_checkpoint
                 ),
                 ),
+                dump_io=triage_data.get("dump_io", cfg.triage.dump_io),
             )
             )
 
 
         # Speaker section
         # Speaker section

+ 179 - 0
tests/test_assistant_service.py

@@ -1,6 +1,8 @@
 """Tests for the assistant service (Phase 2) using synthetic event streams."""
 """Tests for the assistant service (Phase 2) using synthetic event streams."""
 
 
 import itertools
 import itertools
+import json
+import re
 import threading
 import threading
 import time
 import time
 from typing import Any
 from typing import Any
@@ -8,6 +10,7 @@ from typing import Any
 from stt_daemon import events
 from stt_daemon import events
 from stt_daemon.assistant.inference import InferenceQueue
 from stt_daemon.assistant.inference import InferenceQueue
 from stt_daemon.assistant.service import EXECUTE_BUSY, AssistantService
 from stt_daemon.assistant.service import EXECUTE_BUSY, AssistantService
+from stt_daemon.assistant.triage_dump import TriageDumper
 from stt_daemon.config import Config
 from stt_daemon.config import Config
 
 
 _sequence = itertools.count(1)
 _sequence = itertools.count(1)
@@ -1343,3 +1346,179 @@ def test_tick_heals_triage_panel_after_lane_ready():
         assert not any("loading" in line for line in lines)
         assert not any("loading" in line for line in lines)
     finally:
     finally:
         lane.stop()
         lane.stop()
+
+
+def _dump_extract(prompt, on_io=None):
+    """Extract fake that mimics LlmClient.chat's on_io phases."""
+    if on_io is not None:
+        on_io({"phase": "request", "url": "http://llm/v1", "body": {"prompt": prompt}})
+        on_io({"phase": "response", "payload": {"content": "parsed"}, "error": None})
+    return "RAW"
+
+
+def _run_triage_flow(service, emitted, clock, lane, payload):
+    service.toggle_chat()
+    service.handle_event(_transcript(clock, "hello"))
+    clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
+    service.tick()
+    _, _, _, gen = lane.submits[0]
+    service._on_triage_result(gen, payload)
+    return gen
+
+
+def _dump_files(tmp_path):
+    return list(tmp_path.rglob("*.json"))
+
+
+def test_dump_writes_full_laya_and_llm_io(tmp_path):
+    service, emitted, clock, triage, lane = _triage_service(extract_fn=_dump_extract)
+    service._triage_dump = TriageDumper(True, directory=str(tmp_path))
+    service.cfg.triage.dump_io = True
+
+    _run_triage_flow(service, emitted, clock, lane, _escalated_payload())
+    jobs = [j for j in service._inference._queue if j.name == "triage-extract"]
+    assert len(jobs) == 1
+    jobs[0].run()
+
+    files = _dump_files(tmp_path)
+    assert len(files) == 1
+    assert re.fullmatch(r"\d{6}-\d{3}\.json", files[0].name)
+    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"]["questions"] == triage.questions
+    assert data["laya"]["request"]["model"] == "multilingual"
+    assert data["laya"]["request"]["max_len"] == 1024
+    assert data["laya"]["response"] == _escalated_payload()
+    assert data["laya"]["error"] is None
+    assert data["llm"]["request"]["url"] == "http://llm/v1"
+    assert data["llm"]["request"]["body"] == {"prompt": "EXTRACT"}
+    assert data["llm"]["response"] == {"content": "parsed"}
+    assert data["llm"]["error"] is None
+
+
+def test_dump_laya_error_keeps_state_only_llm_half(tmp_path):
+    service, emitted, clock, _, lane = _triage_service(extract_fn=_dump_extract)
+    service._triage_dump = TriageDumper(True, directory=str(tmp_path))
+    service.cfg.triage.dump_io = True
+
+    _run_triage_flow(service, emitted, clock, lane, None)
+    jobs = [j for j in service._inference._queue if j.name == "triage-extract"]
+    assert len(jobs) == 1
+    jobs[0].run()
+
+    files = _dump_files(tmp_path)
+    assert len(files) == 1
+    data = json.loads(files[0].read_text(encoding="utf-8"))
+    assert data["laya"]["response"] is None
+    assert data["laya"]["error"] == "predict failed"
+    assert data["llm"]["request"]["url"] == "http://llm/v1"
+    assert data["llm"]["response"] == {"content": "parsed"}
+
+
+def test_dump_without_extract_has_laya_only(tmp_path):
+    service, emitted, clock, _, lane = _triage_service(extract_fn=_dump_extract)
+    service._triage_dump = TriageDumper(True, directory=str(tmp_path))
+    service.cfg.triage.dump_io = True
+    service.cfg.triage.extract_enabled = False
+
+    _run_triage_flow(service, emitted, clock, lane, _escalated_payload())
+
+    files = _dump_files(tmp_path)
+    assert len(files) == 1
+    data = json.loads(files[0].read_text(encoding="utf-8"))
+    assert data["laya"]["response"] == _escalated_payload()
+    assert data["llm"] is None
+
+
+def test_dump_queue_full_has_laya_only(tmp_path):
+    service, emitted, clock, _, lane = _triage_service(extract_fn=_dump_extract)
+    service._triage_dump = TriageDumper(True, directory=str(tmp_path))
+    service.cfg.triage.dump_io = True
+    service._inference = InferenceQueue(size=2)
+
+    _run_triage_flow(service, emitted, clock, lane, _escalated_payload())
+
+    assert service._inference.depth() == 1
+    files = _dump_files(tmp_path)
+    assert len(files) == 1
+    data = json.loads(files[0].read_text(encoding="utf-8"))
+    assert data["laya"]["response"] == _escalated_payload()
+    assert data["llm"] is None
+
+
+def test_dump_disabled_writes_nothing(tmp_path):
+    service, emitted, clock, _, lane = _triage_service(
+        extract_fn=lambda prompt: "RAW"
+    )
+    assert service._triage_dump.enabled is False
+    service._triage_dump = TriageDumper(False, directory=str(tmp_path))
+
+    _run_triage_flow(service, emitted, clock, lane, _escalated_payload())
+    jobs = [j for j in service._inference._queue if j.name == "triage-extract"]
+    assert len(jobs) == 1
+    jobs[0].run()
+
+    assert _dump_files(tmp_path) == []
+
+
+def test_dump_stale_result_discards_without_file(tmp_path):
+    service, emitted, clock, triage, lane = _triage_service(extract_fn=_dump_extract)
+    service._triage_dump = TriageDumper(True, directory=str(tmp_path))
+    service.cfg.triage.dump_io = True
+    service.toggle_chat()
+    service.handle_event(_transcript(clock, "hello"))
+    clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
+    service.tick()
+    _, _, _, gen = lane.submits[0]
+
+    real_normalize = triage.normalize
+
+    def stale_normalize(payload):
+        # Simulate a clear landing between the first check/begin and the re-check.
+        service._triage_generation += 1
+        return real_normalize(payload)
+
+    triage.normalize = stale_normalize
+    service._on_triage_result(gen, _escalated_payload())
+
+    assert _dump_files(tmp_path) == []
+    assert service._triage_dump._pending == {}
+
+
+def test_dump_stale_extract_discards_without_file(tmp_path):
+    service, _, clock, _, _ = _triage_service(extract_fn=_dump_extract)
+    service._triage_dump = TriageDumper(True, directory=str(tmp_path))
+    service.cfg.triage.dump_io = True
+    ts = clock()
+    service._triage_dump.begin(ts, laya={"request": {}, "response": {}, "error": None})
+    service._triage_generation += 1  # stale before the extract even starts
+
+    service._run_triage_extract(0, ("hello",), _escalated_payload(), ts)
+
+    assert _dump_files(tmp_path) == []
+    assert service._triage_dump._pending == {}
+
+
+def test_dump_extract_cleared_mid_flight_writes_nothing(tmp_path):
+    service, _, clock, _, _ = _triage_service(extract_fn=_dump_extract)
+    service._triage_dump = TriageDumper(True, directory=str(tmp_path))
+    service.cfg.triage.dump_io = True
+
+    def stale_extract(prompt, on_io=None):
+        # Simulate a clear landing mid-LLM-call (after the request phase fired).
+        service._triage_generation += 1
+        if on_io is not None:
+            on_io({"phase": "request", "url": "http://llm/v1", "body": {}})
+            on_io({"phase": "response", "payload": {"c": 1}, "error": None})
+        return "RAW"
+
+    service._triage_extract_fn = stale_extract
+    ts = clock()
+    service._triage_dump.begin(ts, laya={"request": {}, "response": {}, "error": None})
+
+    service._run_triage_extract(0, ("hello",), _escalated_payload(), ts)
+
+    assert _dump_files(tmp_path) == []
+    assert service._triage_dump._pending == {}

+ 29 - 0
tests/test_config.py

@@ -579,6 +579,35 @@ default_checkpoint = "english"
     assert cfg.triage.default_checkpoint == "english"
     assert cfg.triage.default_checkpoint == "english"
 
 
 
 
+def test_triage_dump_io_defaults_to_false():
+    """[triage].dump_io is opt-in: False unless explicitly enabled."""
+    with tempfile.TemporaryDirectory() as tmpdir:
+        import os
+        old_cwd = os.getcwd()
+        try:
+            os.chdir(tmpdir)
+            cfg = load_config()
+        finally:
+            os.chdir(old_cwd)
+
+    assert cfg.triage.dump_io is False
+
+
+def test_triage_dump_io_is_parsed():
+    """[triage].dump_io = true is applied from TOML."""
+    config_toml = """
+[triage]
+dump_io = true
+"""
+    with tempfile.TemporaryDirectory() as tmpdir:
+        config_path = Path(tmpdir) / "config.toml"
+        config_path.write_text(config_toml)
+
+        cfg = load_config(str(config_path))
+
+    assert cfg.triage.dump_io is True
+
+
 def test_unknown_triag_section_warns_and_keeps_defaults(caplog):
 def test_unknown_triag_section_warns_and_keeps_defaults(caplog):
     """A typo'd [triag] section must warn and never raise; triage keeps defaults."""
     """A typo'd [triag] section must warn and never raise; triage keeps defaults."""
     config_toml = """
     config_toml = """

+ 45 - 0
tests/test_llm.py

@@ -584,3 +584,48 @@ def test_chat_session_truncated_partial_reply_is_appended_and_notified():
     assert session.send("hi") == "partial answer"
     assert session.send("hi") == "partial answer"
     assert session.history[-1] == {"role": "assistant", "content": "partial answer"}
     assert session.history[-1] == {"role": "assistant", "content": "partial answer"}
     assert any(kind == "truncated" for kind, _ in notifications)
     assert any(kind == "truncated" for kind, _ in notifications)
+
+
+def test_on_io_receives_request_then_response():
+    """chat(..., on_io=cb) reports the request (url+body) then the response payload."""
+    client, _ = _make_client(lambda _p: _reply(content="ok"))
+    seen: list[dict] = []
+
+    response = client.chat([{"role": "user", "content": "hi"}], on_io=seen.append)
+
+    assert response.content == "ok"
+    assert [event["phase"] for event in seen] == ["request", "response"]
+    assert seen[0]["url"] == LlmConfig().endpoint
+    assert seen[0]["body"]["messages"] == [{"role": "user", "content": "hi"}]
+    assert seen[0]["body"]["max_tokens"] == LlmConfig().max_tokens
+    assert seen[1]["payload"]["choices"][0]["message"]["content"] == "ok"
+    assert seen[1]["error"] is None
+
+
+def test_on_io_response_phase_reports_opener_error():
+    """An opener raising still yields a response phase with the error set."""
+    import urllib.error
+
+    def opener(_payload, _timeout):
+        raise urllib.error.URLError("down")
+
+    client = LlmClient(LlmConfig(), opener=opener)
+    seen: list[dict] = []
+
+    with pytest.raises(LlmError):
+        client.chat([{"role": "user", "content": "hi"}], on_io=seen.append)
+
+    assert [event["phase"] for event in seen] == ["request", "response"]
+    assert seen[1]["payload"] is None
+    assert "down" in seen[1]["error"]
+
+
+def test_raising_on_io_does_not_break_chat():
+    """A throwing on_io callback is swallowed and the reply still parses."""
+
+    def bad(_event):
+        raise RuntimeError("boom")
+
+    client, _ = _make_client(lambda _p: _reply(content="ok"))
+
+    assert client.chat([{"role": "user", "content": "hi"}], on_io=bad).content == "ok"

+ 137 - 0
tests/test_triage_dump.py

@@ -0,0 +1,137 @@
+"""Tests for the opt-in triage I/O dump (TriageDumper)."""
+
+import json
+import re
+from datetime import datetime
+
+from stt_daemon.assistant.triage_dump import TriageDumper
+
+TS = 1759073916.19
+
+
+def _only_file(directory):
+    files = list(directory.rglob("*.json"))
+    assert len(files) == 1
+    return files[0]
+
+
+def test_enabled_writes_one_shaped_file(tmp_path):
+    dumper = TriageDumper(True, directory=str(tmp_path))
+    dumper.begin(
+        TS,
+        laya={"request": {"state": "s"}, "response": {"ok": True}, "error": None},
+        llm={"request": {"url": "u", "body": {}}, "response": {"c": 1}, "error": None},
+    )
+    dumper.finalize(TS)
+
+    path = _only_file(tmp_path)
+    assert re.fullmatch(r"\d{6}-\d{3}\.json", path.name)
+    expected_day = datetime.fromtimestamp(TS).strftime("%Y-%m-%d")
+    assert path.parent.name == expected_day
+
+    data = json.loads(path.read_text(encoding="utf-8"))
+    assert data["ts"] == TS
+    assert isinstance(data["iso"], str) and data["iso"]
+    assert data["laya"] == {
+        "request": {"state": "s"},
+        "response": {"ok": True},
+        "error": None,
+    }
+    assert data["llm"] == {
+        "request": {"url": "u", "body": {}},
+        "response": {"c": 1},
+        "error": None,
+    }
+
+
+def test_disabled_writes_nothing(tmp_path):
+    dumper = TriageDumper(False, directory=str(tmp_path))
+    assert dumper.enabled is False
+    dumper.begin(TS, laya={"request": {}, "response": {}, "error": None})
+    dumper.record(TS, llm={"request": {}, "response": {}, "error": None})
+    dumper.finalize(TS)
+    dumper.clear()
+
+    assert list(tmp_path.rglob("*")) == []
+
+
+def test_record_merges_without_dropping_earlier_parts(tmp_path):
+    dumper = TriageDumper(True, directory=str(tmp_path))
+    dumper.begin(
+        TS, laya={"request": {"state": "s"}, "response": None, "error": None}
+    )
+    dumper.record(TS, llm={"request": {"url": "u"}, "response": None, "error": None})
+    dumper.record(TS, laya={"response": {"ok": True}, "error": None})
+    dumper.finalize(TS)
+
+    data = json.loads(_only_file(tmp_path).read_text(encoding="utf-8"))
+    assert data["laya"]["request"] == {"state": "s"}
+    assert data["laya"]["response"] == {"ok": True}
+    assert data["llm"]["request"] == {"url": "u"}
+
+
+def test_llm_response_phase_keeps_request(tmp_path):
+    """The response-phase record must not wipe the captured request."""
+    dumper = TriageDumper(True, directory=str(tmp_path))
+    dumper.begin(TS, laya={"request": {}, "response": {}, "error": None})
+    dumper.record(
+        TS,
+        llm={"request": {"url": "u", "body": {"m": 1}}, "response": None, "error": None},
+    )
+    dumper.record(TS, llm={"request": None, "response": {"c": 1}, "error": None})
+    dumper.finalize(TS)
+
+    data = json.loads(_only_file(tmp_path).read_text(encoding="utf-8"))
+    assert data["llm"]["request"] == {"url": "u", "body": {"m": 1}}
+    assert data["llm"]["response"] == {"c": 1}
+    assert data["llm"]["error"] is None
+
+
+def test_finalize_unknown_ts_is_noop(tmp_path):
+    dumper = TriageDumper(True, directory=str(tmp_path))
+    dumper.finalize(1234567890.0)
+
+    assert list(tmp_path.rglob("*")) == []
+
+
+def test_clear_drops_pending(tmp_path):
+    dumper = TriageDumper(True, directory=str(tmp_path))
+    dumper.begin(TS, laya={"request": {}, "response": {}, "error": None})
+    dumper.clear()
+    dumper.finalize(TS)
+
+    assert list(tmp_path.rglob("*")) == []
+
+
+def test_write_failure_is_swallowed(tmp_path):
+    blocker = tmp_path / "blocker"
+    blocker.write_text("not a directory")
+    dumper = TriageDumper(True, directory=str(blocker / "sub"))
+
+    # Must not raise even though no directory can be created here.
+    dumper.begin(TS, laya={"request": {}, "response": {}, "error": None})
+    dumper.finalize(TS)
+
+    assert blocker.is_file()
+
+
+def test_discard_drops_pending_without_writing(tmp_path):
+    dumper = TriageDumper(True, directory=str(tmp_path))
+    dumper.begin(TS, laya={"request": {}, "response": {}, "error": None})
+    dumper.discard(TS)
+    dumper.finalize(TS)
+
+    assert list(tmp_path.rglob("*")) == []
+
+
+def test_discard_unknown_and_disabled_is_noop(tmp_path):
+    dumper = TriageDumper(True, directory=str(tmp_path))
+    dumper.discard(1234567890.0)
+
+    assert list(tmp_path.rglob("*")) == []
+
+    disabled = TriageDumper(False, directory=str(tmp_path))
+    disabled.discard(TS)
+    disabled.finalize(TS)
+
+    assert list(tmp_path.rglob("*")) == []