triage_dump.py 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156
  1. """Opt-in triage I/O dump: one JSON file per triage with Laya + LLM I/O.
  2. Enabled via `[triage].dump_io = true` (off by default). When enabled, each
  3. triage handled in `_on_triage_result` produces exactly one file under
  4. `triage/YYYY-MM-DD/HHMMSS-mmm.json` (local time, millisecond precision — the
  5. same naming scheme as `audio/YYYY-MM-DD/HHMMSS-mmm-<reason>.wav`).
  6. The directory `triage/` is fixed (cwd-relative, like `transcripts/`/`audio/`/
  7. `audit/`) and gitignored. Files contain conversation text (transcript state,
  8. Laya questions/answers, LLM prompts), so treat them as private debug output.
  9. Best-effort by design: a dump failure is logged and swallowed, never raised,
  10. so it can never disturb the triage flow.
  11. Merge semantics: `begin`/`record` merge one level deep for the `laya` and
  12. `llm` halves, and an incoming `None` never clobbers an already-recorded
  13. non-`None` value. That is what lets the service record the LLM request in the
  14. request phase and the LLM response in the response phase without the second
  15. call wiping the first.
  16. """
  17. import json
  18. import logging
  19. import os
  20. import tempfile
  21. import threading
  22. from datetime import datetime
  23. from pathlib import Path
  24. from typing import Any
  25. logger = logging.getLogger(__name__)
  26. class TriageDumper:
  27. """Thread-safe, best-effort per-triage I/O recorder."""
  28. def __init__(self, enabled: bool, directory: str = "triage") -> None:
  29. self._enabled = bool(enabled)
  30. self._directory = str(directory)
  31. self._lock = threading.Lock()
  32. self._pending: dict[float, dict] = {}
  33. # Monotonic tie-breaker for same-millisecond collisions: the first
  34. # record for a millisecond keeps the exact `HHMMSS-mmm.json` shape,
  35. # later records in the same millisecond get a `-N` suffix so they
  36. # never overwrite the earlier file.
  37. self._name_lock = threading.Lock()
  38. self._last_stamp: str | None = None
  39. self._stamp_seq = 0
  40. @property
  41. def enabled(self) -> bool:
  42. return self._enabled
  43. def begin(self, ts: float, **parts: Any) -> None:
  44. """Create (or merge into) the pending record keyed by `ts`."""
  45. if not self._enabled:
  46. return
  47. with self._lock:
  48. record = self._pending.get(ts)
  49. if record is None:
  50. record = {"ts": ts}
  51. self._pending[ts] = record
  52. self._merge(record, parts)
  53. def record(self, ts: float, **parts: Any) -> None:
  54. """Merge `parts` into the pending record keyed by `ts` (alias of begin)."""
  55. self.begin(ts, **parts)
  56. def finalize(self, ts: float) -> None:
  57. """Pop the record for `ts` and write it to disk (no-op when unknown)."""
  58. if not self._enabled:
  59. return
  60. with self._lock:
  61. record = self._pending.pop(ts, None)
  62. if record is None:
  63. return
  64. self._write(record)
  65. def discard(self, ts: float) -> None:
  66. """Drop the pending record for `ts` WITHOUT writing (no-op when unknown)."""
  67. if not self._enabled:
  68. return
  69. with self._lock:
  70. self._pending.pop(ts, None)
  71. def clear(self) -> None:
  72. """Drop all pending records."""
  73. if not self._enabled:
  74. return
  75. with self._lock:
  76. self._pending.clear()
  77. @staticmethod
  78. def _merge(record: dict, parts: dict) -> None:
  79. for key, value in parts.items():
  80. if (
  81. key in ("laya", "llm")
  82. and isinstance(value, dict)
  83. and isinstance(record.get(key), dict)
  84. ):
  85. nested = record[key]
  86. for sub_key, sub_value in value.items():
  87. if sub_value is None and nested.get(sub_key) is not None:
  88. continue
  89. nested[sub_key] = sub_value
  90. else:
  91. record[key] = value
  92. def _reserve_path(self, day_dir: Path, stamp: str) -> Path:
  93. """Pick a collision-free path for `stamp` (monotonic tie-breaker)."""
  94. key = f"{day_dir.name} {stamp}"
  95. with self._name_lock:
  96. if key == self._last_stamp:
  97. self._stamp_seq += 1
  98. else:
  99. self._last_stamp = key
  100. self._stamp_seq = 0
  101. seq = self._stamp_seq
  102. path = day_dir / (f"{stamp}.json" if seq == 0 else f"{stamp}-{seq}.json")
  103. while path.exists():
  104. seq += 1
  105. path = day_dir / f"{stamp}-{seq}.json"
  106. self._stamp_seq = seq
  107. return path
  108. def _write(self, record: dict) -> None:
  109. try:
  110. ts_raw = record.get("ts")
  111. if not isinstance(ts_raw, (int, float)):
  112. return
  113. ts = float(ts_raw)
  114. dt = datetime.fromtimestamp(ts)
  115. day_dir = Path(self._directory) / dt.strftime("%Y-%m-%d")
  116. day_dir.mkdir(parents=True, exist_ok=True)
  117. stamp = dt.strftime("%H%M%S") + "-" + dt.strftime("%f")[:3]
  118. final_path = self._reserve_path(day_dir, stamp)
  119. payload = {
  120. "ts": ts,
  121. "iso": dt.astimezone().isoformat(),
  122. "laya": record.get("laya"),
  123. "llm": record.get("llm"),
  124. }
  125. text = json.dumps(payload, ensure_ascii=False, indent=2)
  126. fd, tmp = tempfile.mkstemp(dir=str(day_dir), suffix=".tmp")
  127. try:
  128. with os.fdopen(fd, "w", encoding="utf-8") as f:
  129. f.write(text)
  130. os.replace(tmp, final_path)
  131. except OSError:
  132. try:
  133. os.unlink(tmp)
  134. except OSError:
  135. pass
  136. raise
  137. except Exception as e: # noqa: BLE001 - dump is best-effort, never raises
  138. logger.warning("triage I/O dump failed: %s", e)