state.py 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248
  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. MAX_TRANSCRIPTS = 200
  7. MAX_CHAT = 200
  8. MAX_LOWER = 500
  9. MODE_STYLES = {
  10. "idle": "green",
  11. "command_armed": "yellow",
  12. "executing": "cyan",
  13. "chat": "magenta",
  14. }
  15. def _num(value) -> float | None:
  16. """Best-effort float coercion; None when missing/non-numeric."""
  17. try:
  18. return float(value)
  19. except (TypeError, ValueError):
  20. return None
  21. def _fmt_gate(item: dict) -> str:
  22. """Format the gating confidence defensively; '?' when absent."""
  23. for key in ("answer_confidence", "confidence"):
  24. value = _num(item.get(key))
  25. if value is not None:
  26. return f"{value:.2f}"
  27. return "?"
  28. @dataclass
  29. class ViewState:
  30. """Everything the TUI renders."""
  31. mode: str = "idle"
  32. stt_ok: bool = False
  33. device_ok: bool = True
  34. connected: bool = True
  35. speech_open: bool = False
  36. worker_stall_s: float = 0.0
  37. queue_depth: int = 0
  38. inference_depth: int = 0
  39. inference_in_flight: bool = False
  40. ready: bool = False
  41. phase: str = "idle"
  42. command_text: str = ""
  43. command_edited: bool = False
  44. in_flight: bool = False
  45. transcripts: list[dict] = field(default_factory=list)
  46. transcripts_total: int = 0
  47. chat: list[dict] = field(default_factory=list)
  48. lower: list[tuple[str, bool]] = field(default_factory=list)
  49. tool: dict | None = None
  50. tool_result: dict | None = None
  51. last_error: dict | None = None
  52. chat_epoch: int = 0
  53. triage: dict | None = None
  54. triage_state: str = "disabled"
  55. triage_reason: str = ""
  56. triage_epoch: int = 0
  57. def ingest(self, event: dict) -> None:
  58. """Fold one event into the view state."""
  59. etype = event.get("type")
  60. if etype == "mode":
  61. self._set_mode(event.get("mode", self.mode))
  62. elif etype == "status":
  63. self._set_mode(event.get("mode", self.mode))
  64. self.ready = bool(event.get("ready", self.ready))
  65. self.phase = event.get("phase", self.phase)
  66. self.command_text = event.get("command_text", self.command_text)
  67. self.stt_ok = bool(event.get("stt_ok", self.stt_ok))
  68. if "speech_open" in event:
  69. self.speech_open = bool(event["speech_open"])
  70. changed = False
  71. if "triage" in event:
  72. self.triage = event["triage"]
  73. changed = True
  74. if "triage_state" in event:
  75. self.triage_state = event["triage_state"]
  76. changed = True
  77. if "triage_reason" in event:
  78. self.triage_reason = event["triage_reason"]
  79. changed = True
  80. if changed:
  81. self.triage_epoch += 1
  82. elif etype == "triage":
  83. stage = event.get("stage")
  84. if stage == "cleared":
  85. self.triage = None
  86. else:
  87. # A triage payload can only come from a ready lane: authoritative.
  88. self.triage_state = "ok"
  89. self.triage_reason = ""
  90. self.triage = event.get("triage")
  91. self.triage_epoch += 1
  92. elif etype == "speech_start":
  93. self.speech_open = True
  94. elif etype == "speech_stop":
  95. self.speech_open = False
  96. elif etype == "heartbeat":
  97. # The heartbeat re-states the mode: a missed `mode` event self-corrects.
  98. self._set_mode(event.get("mode", self.mode))
  99. self.stt_ok = bool(event.get("stt_ok", self.stt_ok))
  100. self.in_flight = bool(event.get("in_flight", self.in_flight))
  101. self.worker_stall_s = float(event.get("worker_stall_s", self.worker_stall_s) or 0.0)
  102. if "queue_depth" in event:
  103. self.queue_depth = int(event["queue_depth"] or 0)
  104. if "inference_depth" in event:
  105. self.inference_depth = int(event["inference_depth"] or 0)
  106. if "inference_in_flight" in event:
  107. self.inference_in_flight = bool(event["inference_in_flight"])
  108. if "device_ok" in event:
  109. self.device_ok = bool(event["device_ok"])
  110. elif etype == "command_text":
  111. self.command_text = event.get("text", "")
  112. self.command_edited = bool(event.get("edited", False))
  113. self.phase = event.get("phase", self.phase)
  114. elif etype == "transcript":
  115. self.transcripts.append(event)
  116. del self.transcripts[:-MAX_TRANSCRIPTS]
  117. self.transcripts_total += 1
  118. elif etype == "chat_message":
  119. self.chat.append(event)
  120. del self.chat[:-MAX_CHAT]
  121. self.append_lower(
  122. f"{event.get('role', '?')}: {event.get('text', '')}", from_chat=True
  123. )
  124. elif etype == "tool_call":
  125. self.tool = event
  126. args = event.get("args") or {}
  127. suffix = f" {args}" if args else ""
  128. self.append_lower(f"-> tool {event.get('tool')}{suffix}")
  129. elif etype == "tool_result":
  130. self.tool_result = event
  131. verdict = "ok" if event.get("ok") else "failed"
  132. self.append_lower(f"<- {verdict}: {event.get('result', '')}")
  133. elif etype == "error":
  134. self.last_error = event
  135. self.append_lower(f"! {event.get('code')}: {event.get('message')}")
  136. elif etype == "cleared":
  137. self.clear_output()
  138. def append_lower(self, line: str, from_chat: bool = False) -> None:
  139. """Append one line to the lower pane log (chat turns, tool calls, results, errors)."""
  140. self.lower.append((line, from_chat))
  141. del self.lower[:-MAX_LOWER]
  142. def clear_chat(self) -> None:
  143. """Forget chat turns and their rendered lines, keeping tool/error output."""
  144. self.chat = []
  145. self.lower = [(line, from_chat) for line, from_chat in self.lower if not from_chat]
  146. self.chat_epoch += 1
  147. def clear_output(self) -> None:
  148. """Clear conversation/command output: chat, tool calls/results, errors."""
  149. self.chat = []
  150. self.lower = []
  151. self.tool = None
  152. self.tool_result = None
  153. self.last_error = None
  154. self.triage = None
  155. self.chat_epoch += 1
  156. self.triage_epoch += 1
  157. def _set_mode(self, mode: str) -> None:
  158. if self.mode == "chat" and mode != "chat":
  159. self.clear_chat()
  160. self.mode = mode
  161. def lower_lines(self) -> list[str]:
  162. return [line for line, _ in self.lower]
  163. def health_ok(self) -> bool:
  164. """False when the header should go red."""
  165. return self.stt_ok and self.device_ok
  166. def mode_style(self) -> str:
  167. return MODE_STYLES.get(self.mode, "white")
  168. def transcript_lines(self) -> list[str]:
  169. return [f"[{e.get('lang', '?')}] {e.get('text', '')}" for e in self.transcripts]
  170. def chat_lines(self) -> list[str]:
  171. return [f"{m.get('role', '?')}: {m.get('text', '')}" for m in self.chat]
  172. def triage_lines(self) -> list[str]:
  173. """Render the triage panel lines. Pure and defensive: never raises."""
  174. if self.triage_state != "ok":
  175. reason = self.triage_reason or self.triage_state or "unavailable"
  176. return [f"triage: unavailable — {reason}"]
  177. if not isinstance(self.triage, dict):
  178. return ["triage: unavailable — no data"]
  179. order = self.triage.get("order")
  180. items = self.triage.get("items")
  181. lines: list[str] = []
  182. if isinstance(order, list) and isinstance(items, dict):
  183. for qid in order:
  184. item = items.get(qid)
  185. if not isinstance(item, dict):
  186. lines.append(f"{qid} ⚠" if item else str(qid))
  187. continue
  188. kind = item.get("kind")
  189. flagged = bool(item.get("flagged"))
  190. mark = " ⚠" if flagged else ""
  191. if kind == "choice":
  192. line = f"{qid}: {item.get('choice', '?')} ({_fmt_gate(item)}){mark}"
  193. elif kind == "noul":
  194. p = _num(item.get("p"))
  195. bar = "█" * int(p * 10) if p is not None else ""
  196. bar = bar.ljust(10)
  197. ptxt = f"{p:.2f}" if p is not None else "?"
  198. line = f"{bar} {qid}: {ptxt}{mark}"
  199. elif kind == "score":
  200. level = _num(item.get("level"))
  201. bar = ("█" * int(level)).ljust(10) if level is not None else " " * 10
  202. label = item.get("label", "?")
  203. line = f"{bar} {qid}: {label}{mark}"
  204. else:
  205. line = f"{qid}{mark}" if flagged else str(qid)
  206. lines.append(line)
  207. content = self.triage.get("content")
  208. if isinstance(content, dict):
  209. if lines:
  210. lines.append("")
  211. summary = content.get("summary")
  212. if summary:
  213. lines.append(f"summary: {summary}")
  214. for key in ("opportunities", "risks", "action_items"):
  215. values = content.get(key)
  216. if not isinstance(values, list):
  217. continue
  218. for value in values[:3]:
  219. lines.append(f"{key[:-1]}: {value}")
  220. if not lines:
  221. return ["triage: no signals"]
  222. return lines