| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248 |
- """Pure reduction of assistant/STT events into TUI view state.
- Kept free of Textual so the behaviour is unit-testable: attach, mode
- self-correction from the heartbeat, command editing, bounded buffers.
- """
- from dataclasses import dataclass, field
- MAX_TRANSCRIPTS = 200
- MAX_CHAT = 200
- MAX_LOWER = 500
- MODE_STYLES = {
- "idle": "green",
- "command_armed": "yellow",
- "executing": "cyan",
- "chat": "magenta",
- }
- def _num(value) -> float | None:
- """Best-effort float coercion; None when missing/non-numeric."""
- try:
- return float(value)
- except (TypeError, ValueError):
- return None
- def _fmt_gate(item: dict) -> str:
- """Format the gating confidence defensively; '?' when absent."""
- for key in ("answer_confidence", "confidence"):
- value = _num(item.get(key))
- if value is not None:
- return f"{value:.2f}"
- return "?"
- @dataclass
- class ViewState:
- """Everything the TUI renders."""
- mode: str = "idle"
- stt_ok: bool = False
- device_ok: bool = True
- connected: bool = True
- speech_open: bool = False
- worker_stall_s: float = 0.0
- queue_depth: int = 0
- inference_depth: int = 0
- inference_in_flight: bool = False
- ready: bool = False
- phase: str = "idle"
- command_text: str = ""
- command_edited: bool = False
- in_flight: bool = False
- transcripts: list[dict] = field(default_factory=list)
- transcripts_total: int = 0
- chat: list[dict] = field(default_factory=list)
- lower: list[tuple[str, bool]] = field(default_factory=list)
- tool: dict | None = None
- tool_result: dict | None = None
- last_error: dict | None = None
- chat_epoch: int = 0
- triage: dict | None = None
- triage_state: str = "disabled"
- triage_reason: str = ""
- triage_epoch: int = 0
- def ingest(self, event: dict) -> None:
- """Fold one event into the view state."""
- etype = event.get("type")
- if etype == "mode":
- self._set_mode(event.get("mode", self.mode))
- elif etype == "status":
- self._set_mode(event.get("mode", self.mode))
- self.ready = bool(event.get("ready", self.ready))
- self.phase = event.get("phase", self.phase)
- self.command_text = event.get("command_text", self.command_text)
- self.stt_ok = bool(event.get("stt_ok", self.stt_ok))
- if "speech_open" in event:
- self.speech_open = bool(event["speech_open"])
- changed = False
- if "triage" in event:
- self.triage = event["triage"]
- changed = True
- if "triage_state" in event:
- self.triage_state = event["triage_state"]
- changed = True
- if "triage_reason" in event:
- self.triage_reason = event["triage_reason"]
- changed = True
- if changed:
- self.triage_epoch += 1
- elif etype == "triage":
- stage = event.get("stage")
- if stage == "cleared":
- self.triage = None
- else:
- # A triage payload can only come from a ready lane: authoritative.
- self.triage_state = "ok"
- self.triage_reason = ""
- self.triage = event.get("triage")
- self.triage_epoch += 1
- elif etype == "speech_start":
- self.speech_open = True
- elif etype == "speech_stop":
- self.speech_open = False
- elif etype == "heartbeat":
- # The heartbeat re-states the mode: a missed `mode` event self-corrects.
- self._set_mode(event.get("mode", self.mode))
- self.stt_ok = bool(event.get("stt_ok", self.stt_ok))
- self.in_flight = bool(event.get("in_flight", self.in_flight))
- self.worker_stall_s = float(event.get("worker_stall_s", self.worker_stall_s) or 0.0)
- if "queue_depth" in event:
- self.queue_depth = int(event["queue_depth"] or 0)
- if "inference_depth" in event:
- self.inference_depth = int(event["inference_depth"] or 0)
- if "inference_in_flight" in event:
- self.inference_in_flight = bool(event["inference_in_flight"])
- if "device_ok" in event:
- self.device_ok = bool(event["device_ok"])
- elif etype == "command_text":
- self.command_text = event.get("text", "")
- self.command_edited = bool(event.get("edited", False))
- self.phase = event.get("phase", self.phase)
- elif etype == "transcript":
- self.transcripts.append(event)
- del self.transcripts[:-MAX_TRANSCRIPTS]
- self.transcripts_total += 1
- elif etype == "chat_message":
- self.chat.append(event)
- del self.chat[:-MAX_CHAT]
- self.append_lower(
- f"{event.get('role', '?')}: {event.get('text', '')}", from_chat=True
- )
- elif etype == "tool_call":
- self.tool = event
- args = event.get("args") or {}
- suffix = f" {args}" if args else ""
- self.append_lower(f"-> tool {event.get('tool')}{suffix}")
- elif etype == "tool_result":
- self.tool_result = event
- verdict = "ok" if event.get("ok") else "failed"
- self.append_lower(f"<- {verdict}: {event.get('result', '')}")
- elif etype == "error":
- self.last_error = event
- self.append_lower(f"! {event.get('code')}: {event.get('message')}")
- elif etype == "cleared":
- self.clear_output()
- def append_lower(self, line: str, from_chat: bool = False) -> None:
- """Append one line to the lower pane log (chat turns, tool calls, results, errors)."""
- self.lower.append((line, from_chat))
- del self.lower[:-MAX_LOWER]
- def clear_chat(self) -> None:
- """Forget chat turns and their rendered lines, keeping tool/error output."""
- self.chat = []
- self.lower = [(line, from_chat) for line, from_chat in self.lower if not from_chat]
- self.chat_epoch += 1
- def clear_output(self) -> None:
- """Clear conversation/command output: chat, tool calls/results, errors."""
- self.chat = []
- self.lower = []
- self.tool = None
- self.tool_result = None
- self.last_error = None
- self.triage = None
- self.chat_epoch += 1
- self.triage_epoch += 1
- def _set_mode(self, mode: str) -> None:
- if self.mode == "chat" and mode != "chat":
- self.clear_chat()
- self.mode = mode
- def lower_lines(self) -> list[str]:
- return [line for line, _ in self.lower]
- def health_ok(self) -> bool:
- """False when the header should go red."""
- return self.stt_ok and self.device_ok
- def mode_style(self) -> str:
- return MODE_STYLES.get(self.mode, "white")
- def transcript_lines(self) -> list[str]:
- return [f"[{e.get('lang', '?')}] {e.get('text', '')}" for e in self.transcripts]
- def chat_lines(self) -> list[str]:
- return [f"{m.get('role', '?')}: {m.get('text', '')}" for m in self.chat]
- def triage_lines(self) -> list[str]:
- """Render the triage panel lines. Pure and defensive: never raises."""
- if self.triage_state != "ok":
- reason = self.triage_reason or self.triage_state or "unavailable"
- return [f"triage: unavailable — {reason}"]
- if not isinstance(self.triage, dict):
- return ["triage: unavailable — no data"]
- order = self.triage.get("order")
- items = self.triage.get("items")
- lines: list[str] = []
- if isinstance(order, list) and isinstance(items, dict):
- for qid in order:
- item = items.get(qid)
- if not isinstance(item, dict):
- lines.append(f"{qid} ⚠" if item else str(qid))
- continue
- kind = item.get("kind")
- flagged = bool(item.get("flagged"))
- mark = " ⚠" if flagged else ""
- if kind == "choice":
- line = f"{qid}: {item.get('choice', '?')} ({_fmt_gate(item)}){mark}"
- elif kind == "noul":
- p = _num(item.get("p"))
- bar = "█" * int(p * 10) if p is not None else ""
- bar = bar.ljust(10)
- ptxt = f"{p:.2f}" if p is not None else "?"
- line = f"{bar} {qid}: {ptxt}{mark}"
- elif kind == "score":
- level = _num(item.get("level"))
- bar = ("█" * int(level)).ljust(10) if level is not None else " " * 10
- label = item.get("label", "?")
- line = f"{bar} {qid}: {label}{mark}"
- else:
- line = f"{qid}{mark}" if flagged else str(qid)
- lines.append(line)
- content = self.triage.get("content")
- if isinstance(content, dict):
- if lines:
- lines.append("")
- summary = content.get("summary")
- if summary:
- lines.append(f"summary: {summary}")
- for key in ("opportunities", "risks", "action_items"):
- values = content.get(key)
- if not isinstance(values, list):
- continue
- for value in values[:3]:
- lines.append(f"{key[:-1]}: {value}")
- if not lines:
- return ["triage: no signals"]
- return lines
|