state.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401
  1. """Pure reduction of assistant/STT events into TUI view state.
  2. Kept free of Textual so the behaviour is unit-testable: attach, mode
  3. self-correction from the heartbeat, command editing, bounded buffers.
  4. """
  5. from dataclasses import dataclass, field
  6. from datetime import datetime
  7. from rich.text import Text
  8. MAX_TRANSCRIPTS = 200
  9. MAX_CHAT = 200
  10. MAX_LOWER = 500
  11. MODE_STYLES = {
  12. "idle": "green",
  13. "command_armed": "yellow",
  14. "executing": "cyan",
  15. "chat": "magenta",
  16. }
  17. # Time, language tag, and body text must use disjoint colors so they are never
  18. # confused: these fixed header styles never collide with the body palette.
  19. TIME_STYLE = "bold cyan"
  20. LANG_TAG_STYLE = "bold yellow"
  21. REASON_STYLE = "italic dim"
  22. SPEAKER_NAMED_STYLE = "bold green"
  23. SPEAKER_UNKNOWN_STYLE = "bold red"
  24. LANG_TEXT_STYLES = {
  25. "es": "green",
  26. "en": "bright_blue",
  27. "fr": "magenta",
  28. "de": "bright_green",
  29. "pt": "red",
  30. "it": "bright_magenta",
  31. }
  32. DEFAULT_LANG_TEXT_STYLE = "white"
  33. def _num(value) -> float | None:
  34. """Best-effort float coercion; None when missing/non-numeric."""
  35. try:
  36. return float(value)
  37. except (TypeError, ValueError):
  38. return None
  39. def _fmt_clock(ts) -> str:
  40. """Render a transcript timestamp as local HH:MM:SS (epoch or ISO-8601)."""
  41. value = _num(ts)
  42. if value is not None:
  43. try:
  44. return datetime.fromtimestamp(value).strftime("%H:%M:%S")
  45. except (OverflowError, OSError, ValueError):
  46. return "--:--:--"
  47. if isinstance(ts, str):
  48. try:
  49. return datetime.fromisoformat(ts).strftime("%H:%M:%S")
  50. except ValueError:
  51. pass
  52. return "--:--:--"
  53. def _fmt_gate(item: dict) -> str:
  54. """Format the gating confidence defensively; '?' when absent."""
  55. for key in ("answer_confidence", "confidence"):
  56. value = _num(item.get(key))
  57. if value is not None:
  58. return f"{value:.2f}"
  59. return "?"
  60. @dataclass
  61. class ViewState:
  62. """Everything the TUI renders."""
  63. mode: str = "idle"
  64. stt_ok: bool = False
  65. device_ok: bool = True
  66. connected: bool = True
  67. speech_open: bool = False
  68. worker_stall_s: float = 0.0
  69. queue_depth: int = 0
  70. inference_depth: int = 0
  71. inference_in_flight: bool = False
  72. ready: bool = False
  73. phase: str = "idle"
  74. command_text: str = ""
  75. command_edited: bool = False
  76. in_flight: bool = False
  77. transcripts: list[dict] = field(default_factory=list)
  78. transcripts_total: int = 0
  79. chat: list[dict] = field(default_factory=list)
  80. lower: list[tuple[str, bool]] = field(default_factory=list)
  81. tool: dict | None = None
  82. tool_result: dict | None = None
  83. last_error: dict | None = None
  84. chat_epoch: int = 0
  85. triage: dict | None = None
  86. triage_state: str = "disabled"
  87. triage_reason: str = ""
  88. triage_epoch: int = 0
  89. triage_ts: float | None = None
  90. questions: dict | None = None
  91. questions_epoch: int = 0
  92. speakers: list[dict] = field(default_factory=list)
  93. session_dir: str | None = None
  94. def ingest(self, event: dict) -> None:
  95. """Fold one event into the view state."""
  96. etype = event.get("type")
  97. if etype == "mode":
  98. self._set_mode(event.get("mode", self.mode))
  99. elif etype == "status":
  100. self._set_mode(event.get("mode", self.mode))
  101. self.ready = bool(event.get("ready", self.ready))
  102. self.phase = event.get("phase", self.phase)
  103. self.command_text = event.get("command_text", self.command_text)
  104. self.stt_ok = bool(event.get("stt_ok", self.stt_ok))
  105. if "speech_open" in event:
  106. self.speech_open = bool(event["speech_open"])
  107. changed = False
  108. if "triage" in event:
  109. self.triage = event["triage"]
  110. changed = True
  111. if "triage_state" in event:
  112. self.triage_state = event["triage_state"]
  113. changed = True
  114. if "triage_reason" in event:
  115. self.triage_reason = event["triage_reason"]
  116. changed = True
  117. if changed:
  118. self.triage_epoch += 1
  119. if "questions" in event:
  120. # Static config from the analyzer: independent of the triage epoch.
  121. self.questions = event["questions"]
  122. self.questions_epoch += 1
  123. self.session_dir = event.get("session_dir") or self.session_dir
  124. elif etype == "session":
  125. self.session_dir = event.get("session_dir") or self.session_dir
  126. elif etype == "triage":
  127. stage = event.get("stage")
  128. if stage == "cleared":
  129. self.triage = None
  130. self.triage_ts = None
  131. else:
  132. # A triage payload can only come from a ready lane: authoritative.
  133. self.triage_state = "ok"
  134. self.triage_reason = ""
  135. self.triage = event.get("triage")
  136. self.triage_ts = event.get("ts", self.triage_ts)
  137. self.triage_epoch += 1
  138. elif etype == "speech_start":
  139. self.speech_open = True
  140. elif etype == "speech_stop":
  141. self.speech_open = False
  142. elif etype == "heartbeat":
  143. # The heartbeat re-states the mode: a missed `mode` event self-corrects.
  144. self._set_mode(event.get("mode", self.mode))
  145. self.stt_ok = bool(event.get("stt_ok", self.stt_ok))
  146. self.in_flight = bool(event.get("in_flight", self.in_flight))
  147. self.worker_stall_s = float(event.get("worker_stall_s", self.worker_stall_s) or 0.0)
  148. if "queue_depth" in event:
  149. self.queue_depth = int(event["queue_depth"] or 0)
  150. if "inference_depth" in event:
  151. self.inference_depth = int(event["inference_depth"] or 0)
  152. if "inference_in_flight" in event:
  153. self.inference_in_flight = bool(event["inference_in_flight"])
  154. if "device_ok" in event:
  155. self.device_ok = bool(event["device_ok"])
  156. elif etype == "command_text":
  157. self.command_text = event.get("text", "")
  158. self.command_edited = bool(event.get("edited", False))
  159. self.phase = event.get("phase", self.phase)
  160. elif etype == "speakers":
  161. self.speakers = list(event.get("speakers") or [])
  162. elif etype == "transcript":
  163. self.session_dir = event.get("session_dir") or self.session_dir
  164. self.transcripts.append(event)
  165. del self.transcripts[:-MAX_TRANSCRIPTS]
  166. self.transcripts_total += 1
  167. elif etype == "chat_message":
  168. self.chat.append(event)
  169. del self.chat[:-MAX_CHAT]
  170. self.append_lower(
  171. f"{event.get('role', '?')}: {event.get('text', '')}", from_chat=True
  172. )
  173. elif etype == "tool_call":
  174. self.tool = event
  175. args = event.get("args") or {}
  176. suffix = f" {args}" if args else ""
  177. self.append_lower(f"-> tool {event.get('tool')}{suffix}")
  178. elif etype == "tool_result":
  179. self.tool_result = event
  180. verdict = "ok" if event.get("ok") else "failed"
  181. self.append_lower(f"<- {verdict}: {event.get('result', '')}")
  182. elif etype == "error":
  183. self.last_error = event
  184. self.append_lower(f"! {event.get('code')}: {event.get('message')}")
  185. elif etype == "cleared":
  186. self.clear_output()
  187. def append_lower(self, line: str, from_chat: bool = False) -> None:
  188. """Append one line to the lower pane log (chat turns, tool calls, results, errors)."""
  189. self.lower.append((line, from_chat))
  190. del self.lower[:-MAX_LOWER]
  191. def clear_chat(self) -> None:
  192. """Forget chat turns and their rendered lines, keeping tool/error output."""
  193. self.chat = []
  194. self.lower = [(line, from_chat) for line, from_chat in self.lower if not from_chat]
  195. self.chat_epoch += 1
  196. def clear_output(self) -> None:
  197. """Clear conversation/command output: chat, tool calls/results, errors."""
  198. self.chat = []
  199. self.lower = []
  200. self.tool = None
  201. self.tool_result = None
  202. self.last_error = None
  203. self.triage = None
  204. self.triage_ts = None
  205. self.chat_epoch += 1
  206. self.triage_epoch += 1
  207. def _set_mode(self, mode: str) -> None:
  208. if self.mode == "chat" and mode != "chat":
  209. self.clear_chat()
  210. self.mode = mode
  211. def lower_lines(self) -> list[str]:
  212. return [line for line, _ in self.lower]
  213. def health_ok(self) -> bool:
  214. """False when the header should go red."""
  215. return self.stt_ok and self.device_ok
  216. def mode_style(self) -> str:
  217. return MODE_STYLES.get(self.mode, "white")
  218. def transcript_lines(self) -> list[Text]:
  219. """Render each transcript as a styled two-line block: header then text."""
  220. blocks: list[Text] = []
  221. for e in self.transcripts:
  222. lang = str(e.get("lang") or "?").lower()
  223. text_style = LANG_TEXT_STYLES.get(lang, DEFAULT_LANG_TEXT_STYLE)
  224. reason = str(e.get("end_reason") or "").strip()
  225. duration = _num(e.get("duration_s"))
  226. speaker = str(e.get("speaker") or "Unknown")
  227. speaker_style = (
  228. SPEAKER_NAMED_STYLE
  229. if e.get("speaker_status") == "matched" and speaker not in ("", "Unknown")
  230. else SPEAKER_UNKNOWN_STYLE
  231. )
  232. block = Text()
  233. block.append(_fmt_clock(e.get("ts")), style=TIME_STYLE)
  234. block.append(" ")
  235. block.append(lang.upper(), style=LANG_TAG_STYLE)
  236. block.append(" ")
  237. block.append(speaker, style=speaker_style)
  238. block.append(" ")
  239. block.append(f"{duration:.1f}s" if duration is not None else "?", style="dim")
  240. if reason:
  241. block.append(" ")
  242. block.append(reason, style=REASON_STYLE)
  243. block.append("\n")
  244. block.append(" " + str(e.get("text", "")), style=text_style)
  245. blocks.append(block)
  246. return blocks
  247. def chat_lines(self) -> list[str]:
  248. return [f"{m.get('role', '?')}: {m.get('text', '')}" for m in self.chat]
  249. def triage_renderables(self) -> list[Text]:
  250. """triage_lines() as Text, with the last-triage clock header when available."""
  251. out = [Text(line) for line in self.triage_lines()]
  252. if (
  253. self.triage_ts is not None
  254. and self.triage_state == "ok"
  255. and isinstance(self.triage, dict)
  256. ):
  257. clock = Text()
  258. clock.append(_fmt_clock(self.triage_ts), style=TIME_STYLE)
  259. out.insert(0, clock)
  260. return out
  261. def triage_lines(self) -> list[str]:
  262. """Render the triage panel lines. Pure and defensive: never raises."""
  263. if self.triage_state != "ok":
  264. reason = self.triage_reason or self.triage_state or "unavailable"
  265. return [f"triage: unavailable — {reason}"]
  266. if not isinstance(self.triage, dict):
  267. return ["triage: unavailable — no data"]
  268. order = self.triage.get("order")
  269. items = self.triage.get("items")
  270. lines: list[str] = []
  271. if isinstance(order, list) and isinstance(items, dict):
  272. for qid in order:
  273. item = items.get(qid)
  274. if not isinstance(item, dict):
  275. lines.append(f"{qid} ⚠" if item else str(qid))
  276. continue
  277. kind = item.get("kind")
  278. flagged = bool(item.get("flagged"))
  279. mark = " ⚠" if flagged else ""
  280. if kind == "choice":
  281. line = f"{qid}: {item.get('choice', '?')} ({_fmt_gate(item)}){mark}"
  282. elif kind == "noul":
  283. p = _num(item.get("p"))
  284. bar = "█" * int(p * 10) if p is not None else ""
  285. bar = bar.ljust(10)
  286. ptxt = f"{p:.2f}" if p is not None else "?"
  287. line = f"{bar} {qid}: {ptxt}{mark}"
  288. elif kind == "score":
  289. level = _num(item.get("level"))
  290. bar = ("█" * int(level)).ljust(10) if level is not None else " " * 10
  291. label = item.get("label", "?")
  292. line = f"{bar} {qid}: {label}{mark}"
  293. else:
  294. line = f"{qid}{mark}" if flagged else str(qid)
  295. lines.append(line)
  296. brief = self.triage.get("brief")
  297. if isinstance(brief, str) and brief:
  298. lines.append("")
  299. lines.extend(brief.splitlines())
  300. if not lines:
  301. return ["triage: no signals"]
  302. return lines
  303. def questions_lines(self) -> list[str]:
  304. """Render the loaded questions as structured lines. Pure and defensive: never raises."""
  305. entries = self._question_entries()
  306. if not entries:
  307. reason = self.triage_reason or self.triage_state or "no questions loaded"
  308. return [f"questions: unavailable — {reason}"]
  309. lines: list[str] = []
  310. for qid, qtype, body in entries:
  311. lines.append(f"{qid} [{qtype}]")
  312. lines.extend(body)
  313. return lines
  314. def questions_renderables(self) -> list[Text]:
  315. """questions_lines() as Text, with the qid header styled (bold qid, dim type)."""
  316. entries = self._question_entries()
  317. if not entries:
  318. return [Text(line) for line in self.questions_lines()]
  319. out: list[Text] = []
  320. for qid, qtype, body in entries:
  321. header = Text()
  322. header.append(qid, style="bold")
  323. header.append(" ")
  324. header.append(f"[{qtype}]", style="dim")
  325. out.append(header)
  326. for line in body:
  327. out.append(Text(line))
  328. return out
  329. def _question_entries(self) -> list[tuple[str, str, list[str]]]:
  330. """(qid, type, body lines) per usable question, in file order. Never raises."""
  331. entries: list[tuple[str, str, list[str]]] = []
  332. if not (isinstance(self.questions, dict) and self.questions):
  333. return entries
  334. for qid, qdef in self.questions.items():
  335. if not isinstance(qdef, dict):
  336. continue
  337. qtype = str(qdef.get("type") or "?")
  338. body: list[str] = []
  339. instructions = qdef.get("instructions")
  340. if isinstance(instructions, str) and instructions:
  341. body.append(f" {instructions}")
  342. body.extend(self._criteria_lines(qtype, qdef.get("criteria")))
  343. entries.append((str(qid), qtype, body))
  344. return entries
  345. @staticmethod
  346. def _criteria_lines(qtype: str, criteria) -> list[str]:
  347. """One line per criterion for a known type; malformed criteria degrade to none."""
  348. lines: list[str] = []
  349. if qtype == "choice":
  350. if isinstance(criteria, dict):
  351. for label, description in criteria.items():
  352. lines.append(f" {label} — {description}")
  353. elif isinstance(criteria, list):
  354. for label in criteria:
  355. lines.append(f" {label}")
  356. elif qtype == "score":
  357. if isinstance(criteria, list):
  358. for index, level in enumerate(criteria, start=1):
  359. lines.append(f" {index}. {level}")
  360. elif qtype == "noul":
  361. if isinstance(criteria, dict):
  362. for value, description in criteria.items():
  363. lines.append(f" {value} — {description}")
  364. return lines