state.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405
  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. def ingest(self, event: dict) -> None:
  94. """Fold one event into the view state."""
  95. etype = event.get("type")
  96. if etype == "mode":
  97. self._set_mode(event.get("mode", self.mode))
  98. elif etype == "status":
  99. self._set_mode(event.get("mode", self.mode))
  100. self.ready = bool(event.get("ready", self.ready))
  101. self.phase = event.get("phase", self.phase)
  102. self.command_text = event.get("command_text", self.command_text)
  103. self.stt_ok = bool(event.get("stt_ok", self.stt_ok))
  104. if "speech_open" in event:
  105. self.speech_open = bool(event["speech_open"])
  106. changed = False
  107. if "triage" in event:
  108. self.triage = event["triage"]
  109. changed = True
  110. if "triage_state" in event:
  111. self.triage_state = event["triage_state"]
  112. changed = True
  113. if "triage_reason" in event:
  114. self.triage_reason = event["triage_reason"]
  115. changed = True
  116. if changed:
  117. self.triage_epoch += 1
  118. if "questions" in event:
  119. # Static config from the analyzer: independent of the triage epoch.
  120. self.questions = event["questions"]
  121. self.questions_epoch += 1
  122. elif etype == "triage":
  123. stage = event.get("stage")
  124. if stage == "cleared":
  125. self.triage = None
  126. self.triage_ts = None
  127. else:
  128. # A triage payload can only come from a ready lane: authoritative.
  129. self.triage_state = "ok"
  130. self.triage_reason = ""
  131. self.triage = event.get("triage")
  132. self.triage_ts = event.get("ts", self.triage_ts)
  133. self.triage_epoch += 1
  134. elif etype == "speech_start":
  135. self.speech_open = True
  136. elif etype == "speech_stop":
  137. self.speech_open = False
  138. elif etype == "heartbeat":
  139. # The heartbeat re-states the mode: a missed `mode` event self-corrects.
  140. self._set_mode(event.get("mode", self.mode))
  141. self.stt_ok = bool(event.get("stt_ok", self.stt_ok))
  142. self.in_flight = bool(event.get("in_flight", self.in_flight))
  143. self.worker_stall_s = float(event.get("worker_stall_s", self.worker_stall_s) or 0.0)
  144. if "queue_depth" in event:
  145. self.queue_depth = int(event["queue_depth"] or 0)
  146. if "inference_depth" in event:
  147. self.inference_depth = int(event["inference_depth"] or 0)
  148. if "inference_in_flight" in event:
  149. self.inference_in_flight = bool(event["inference_in_flight"])
  150. if "device_ok" in event:
  151. self.device_ok = bool(event["device_ok"])
  152. elif etype == "command_text":
  153. self.command_text = event.get("text", "")
  154. self.command_edited = bool(event.get("edited", False))
  155. self.phase = event.get("phase", self.phase)
  156. elif etype == "speakers":
  157. self.speakers = list(event.get("speakers") or [])
  158. elif etype == "transcript":
  159. self.transcripts.append(event)
  160. del self.transcripts[:-MAX_TRANSCRIPTS]
  161. self.transcripts_total += 1
  162. elif etype == "chat_message":
  163. self.chat.append(event)
  164. del self.chat[:-MAX_CHAT]
  165. self.append_lower(
  166. f"{event.get('role', '?')}: {event.get('text', '')}", from_chat=True
  167. )
  168. elif etype == "tool_call":
  169. self.tool = event
  170. args = event.get("args") or {}
  171. suffix = f" {args}" if args else ""
  172. self.append_lower(f"-> tool {event.get('tool')}{suffix}")
  173. elif etype == "tool_result":
  174. self.tool_result = event
  175. verdict = "ok" if event.get("ok") else "failed"
  176. self.append_lower(f"<- {verdict}: {event.get('result', '')}")
  177. elif etype == "error":
  178. self.last_error = event
  179. self.append_lower(f"! {event.get('code')}: {event.get('message')}")
  180. elif etype == "cleared":
  181. self.clear_output()
  182. def append_lower(self, line: str, from_chat: bool = False) -> None:
  183. """Append one line to the lower pane log (chat turns, tool calls, results, errors)."""
  184. self.lower.append((line, from_chat))
  185. del self.lower[:-MAX_LOWER]
  186. def clear_chat(self) -> None:
  187. """Forget chat turns and their rendered lines, keeping tool/error output."""
  188. self.chat = []
  189. self.lower = [(line, from_chat) for line, from_chat in self.lower if not from_chat]
  190. self.chat_epoch += 1
  191. def clear_output(self) -> None:
  192. """Clear conversation/command output: chat, tool calls/results, errors."""
  193. self.chat = []
  194. self.lower = []
  195. self.tool = None
  196. self.tool_result = None
  197. self.last_error = None
  198. self.triage = None
  199. self.triage_ts = None
  200. self.chat_epoch += 1
  201. self.triage_epoch += 1
  202. def _set_mode(self, mode: str) -> None:
  203. if self.mode == "chat" and mode != "chat":
  204. self.clear_chat()
  205. self.mode = mode
  206. def lower_lines(self) -> list[str]:
  207. return [line for line, _ in self.lower]
  208. def health_ok(self) -> bool:
  209. """False when the header should go red."""
  210. return self.stt_ok and self.device_ok
  211. def mode_style(self) -> str:
  212. return MODE_STYLES.get(self.mode, "white")
  213. def transcript_lines(self) -> list[Text]:
  214. """Render each transcript as a styled two-line block: header then text."""
  215. blocks: list[Text] = []
  216. for e in self.transcripts:
  217. lang = str(e.get("lang") or "?").lower()
  218. text_style = LANG_TEXT_STYLES.get(lang, DEFAULT_LANG_TEXT_STYLE)
  219. reason = str(e.get("end_reason") or "").strip()
  220. duration = _num(e.get("duration_s"))
  221. speaker = str(e.get("speaker") or "Unknown")
  222. speaker_style = (
  223. SPEAKER_NAMED_STYLE
  224. if e.get("speaker_status") == "matched" and speaker not in ("", "Unknown")
  225. else SPEAKER_UNKNOWN_STYLE
  226. )
  227. block = Text()
  228. block.append(_fmt_clock(e.get("ts")), style=TIME_STYLE)
  229. block.append(" ")
  230. block.append(lang.upper(), style=LANG_TAG_STYLE)
  231. block.append(" ")
  232. block.append(speaker, style=speaker_style)
  233. block.append(" ")
  234. block.append(f"{duration:.1f}s" if duration is not None else "?", style="dim")
  235. if reason:
  236. block.append(" ")
  237. block.append(reason, style=REASON_STYLE)
  238. block.append("\n")
  239. block.append(" " + str(e.get("text", "")), style=text_style)
  240. blocks.append(block)
  241. return blocks
  242. def chat_lines(self) -> list[str]:
  243. return [f"{m.get('role', '?')}: {m.get('text', '')}" for m in self.chat]
  244. def triage_renderables(self) -> list[Text]:
  245. """triage_lines() as Text, with the last-triage clock header when available."""
  246. out = [Text(line) for line in self.triage_lines()]
  247. if (
  248. self.triage_ts is not None
  249. and self.triage_state == "ok"
  250. and isinstance(self.triage, dict)
  251. ):
  252. clock = Text()
  253. clock.append(_fmt_clock(self.triage_ts), style=TIME_STYLE)
  254. out.insert(0, clock)
  255. return out
  256. def triage_lines(self) -> list[str]:
  257. """Render the triage panel lines. Pure and defensive: never raises."""
  258. if self.triage_state != "ok":
  259. reason = self.triage_reason or self.triage_state or "unavailable"
  260. return [f"triage: unavailable — {reason}"]
  261. if not isinstance(self.triage, dict):
  262. return ["triage: unavailable — no data"]
  263. order = self.triage.get("order")
  264. items = self.triage.get("items")
  265. lines: list[str] = []
  266. if isinstance(order, list) and isinstance(items, dict):
  267. for qid in order:
  268. item = items.get(qid)
  269. if not isinstance(item, dict):
  270. lines.append(f"{qid} ⚠" if item else str(qid))
  271. continue
  272. kind = item.get("kind")
  273. flagged = bool(item.get("flagged"))
  274. mark = " ⚠" if flagged else ""
  275. if kind == "choice":
  276. line = f"{qid}: {item.get('choice', '?')} ({_fmt_gate(item)}){mark}"
  277. elif kind == "noul":
  278. p = _num(item.get("p"))
  279. bar = "█" * int(p * 10) if p is not None else ""
  280. bar = bar.ljust(10)
  281. ptxt = f"{p:.2f}" if p is not None else "?"
  282. line = f"{bar} {qid}: {ptxt}{mark}"
  283. elif kind == "score":
  284. level = _num(item.get("level"))
  285. bar = ("█" * int(level)).ljust(10) if level is not None else " " * 10
  286. label = item.get("label", "?")
  287. line = f"{bar} {qid}: {label}{mark}"
  288. else:
  289. line = f"{qid}{mark}" if flagged else str(qid)
  290. lines.append(line)
  291. content = self.triage.get("content")
  292. if isinstance(content, dict):
  293. if lines:
  294. lines.append("")
  295. summary = content.get("summary")
  296. if summary:
  297. lines.append(f"summary: {summary}")
  298. for key in ("opportunities", "risks", "action_items"):
  299. values = content.get(key)
  300. if not isinstance(values, list):
  301. continue
  302. for value in values[:3]:
  303. lines.append(f"{key[:-1]}: {value}")
  304. if not lines:
  305. return ["triage: no signals"]
  306. return lines
  307. def questions_lines(self) -> list[str]:
  308. """Render the loaded questions as structured lines. Pure and defensive: never raises."""
  309. entries = self._question_entries()
  310. if not entries:
  311. reason = self.triage_reason or self.triage_state or "no questions loaded"
  312. return [f"questions: unavailable — {reason}"]
  313. lines: list[str] = []
  314. for qid, qtype, body in entries:
  315. lines.append(f"{qid} [{qtype}]")
  316. lines.extend(body)
  317. return lines
  318. def questions_renderables(self) -> list[Text]:
  319. """questions_lines() as Text, with the qid header styled (bold qid, dim type)."""
  320. entries = self._question_entries()
  321. if not entries:
  322. return [Text(line) for line in self.questions_lines()]
  323. out: list[Text] = []
  324. for qid, qtype, body in entries:
  325. header = Text()
  326. header.append(qid, style="bold")
  327. header.append(" ")
  328. header.append(f"[{qtype}]", style="dim")
  329. out.append(header)
  330. for line in body:
  331. out.append(Text(line))
  332. return out
  333. def _question_entries(self) -> list[tuple[str, str, list[str]]]:
  334. """(qid, type, body lines) per usable question, in file order. Never raises."""
  335. entries: list[tuple[str, str, list[str]]] = []
  336. if not (isinstance(self.questions, dict) and self.questions):
  337. return entries
  338. for qid, qdef in self.questions.items():
  339. if not isinstance(qdef, dict):
  340. continue
  341. qtype = str(qdef.get("type") or "?")
  342. body: list[str] = []
  343. instructions = qdef.get("instructions")
  344. if isinstance(instructions, str) and instructions:
  345. body.append(f" {instructions}")
  346. body.extend(self._criteria_lines(qtype, qdef.get("criteria")))
  347. entries.append((str(qid), qtype, body))
  348. return entries
  349. @staticmethod
  350. def _criteria_lines(qtype: str, criteria) -> list[str]:
  351. """One line per criterion for a known type; malformed criteria degrade to none."""
  352. lines: list[str] = []
  353. if qtype == "choice":
  354. if isinstance(criteria, dict):
  355. for label, description in criteria.items():
  356. lines.append(f" {label} — {description}")
  357. elif isinstance(criteria, list):
  358. for label in criteria:
  359. lines.append(f" {label}")
  360. elif qtype == "score":
  361. if isinstance(criteria, list):
  362. for index, level in enumerate(criteria, start=1):
  363. lines.append(f" {index}. {level}")
  364. elif qtype == "noul":
  365. if isinstance(criteria, dict):
  366. for value, description in criteria.items():
  367. lines.append(f" {value} — {description}")
  368. return lines