Răsfoiți Sursa

Prettified transcriptions section

Efren Yevale Varela 1 lună în urmă
părinte
comite
2b3c91b37d
2 a modificat fișierele cu 112 adăugiri și 4 ștergeri
  1. 57 2
      src/stt_daemon/tui/state.py
  2. 55 2
      tests/test_tui_state.py

+ 57 - 2
src/stt_daemon/tui/state.py

@@ -5,6 +5,9 @@ self-correction from the heartbeat, command editing, bounded buffers.
 """
 
 from dataclasses import dataclass, field
+from datetime import datetime
+
+from rich.text import Text
 
 MAX_TRANSCRIPTS = 200
 MAX_CHAT = 200
@@ -17,6 +20,22 @@ MODE_STYLES = {
     "chat": "magenta",
 }
 
+# Time, language tag, and body text must use disjoint colors so they are never
+# confused: these fixed header styles never collide with the body palette.
+TIME_STYLE = "bold cyan"
+LANG_TAG_STYLE = "bold yellow"
+REASON_STYLE = "italic dim"
+
+LANG_TEXT_STYLES = {
+    "es": "green",
+    "en": "bright_blue",
+    "fr": "magenta",
+    "de": "bright_green",
+    "pt": "red",
+    "it": "bright_magenta",
+}
+DEFAULT_LANG_TEXT_STYLE = "white"
+
 
 def _num(value) -> float | None:
     """Best-effort float coercion; None when missing/non-numeric."""
@@ -26,6 +45,22 @@ def _num(value) -> float | None:
         return None
 
 
+def _fmt_clock(ts) -> str:
+    """Render a transcript timestamp as local HH:MM:SS (epoch or ISO-8601)."""
+    value = _num(ts)
+    if value is not None:
+        try:
+            return datetime.fromtimestamp(value).strftime("%H:%M:%S")
+        except (OverflowError, OSError, ValueError):
+            return "--:--:--"
+    if isinstance(ts, str):
+        try:
+            return datetime.fromisoformat(ts).strftime("%H:%M:%S")
+        except ValueError:
+            pass
+    return "--:--:--"
+
+
 def _fmt_gate(item: dict) -> str:
     """Format the gating confidence defensively; '?' when absent."""
     for key in ("answer_confidence", "confidence"):
@@ -186,8 +221,28 @@ class ViewState:
     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 transcript_lines(self) -> list[Text]:
+        """Render each transcript as a styled two-line block: header then text."""
+        blocks: list[Text] = []
+        for e in self.transcripts:
+            lang = str(e.get("lang") or "?").lower()
+            text_style = LANG_TEXT_STYLES.get(lang, DEFAULT_LANG_TEXT_STYLE)
+            reason = str(e.get("end_reason") or "").strip()
+            duration = _num(e.get("duration_s"))
+
+            block = Text()
+            block.append(_fmt_clock(e.get("ts")), style=TIME_STYLE)
+            block.append("  ")
+            block.append(lang.upper(), style=LANG_TAG_STYLE)
+            block.append("  ")
+            block.append(f"{duration:.1f}s" if duration is not None else "?", style="dim")
+            if reason:
+                block.append("  ")
+                block.append(reason, style=REASON_STYLE)
+            block.append("\n")
+            block.append("  " + str(e.get("text", "")), style=text_style)
+            blocks.append(block)
+        return blocks
 
     def chat_lines(self) -> list[str]:
         return [f"{m.get('role', '?')}: {m.get('text', '')}" for m in self.chat]

+ 55 - 2
tests/test_tui_state.py

@@ -114,7 +114,58 @@ def test_transcripts_are_bounded():
 
     assert len(state.transcripts) == MAX_TRANSCRIPTS
     assert state.transcripts[-1]["text"] == f"t{MAX_TRANSCRIPTS + 24}"
-    assert state.transcript_lines()[-1].startswith("[en]")
+    last_block = state.transcript_lines()[-1].plain
+    assert f"t{MAX_TRANSCRIPTS + 24}" in last_block
+    assert "EN" in last_block
+
+
+def test_transcript_block_header_has_clock_lang_duration_and_reason():
+    state = ViewState()
+    state.ingest(
+        {
+            "type": "transcript",
+            "ts": 1759073916.19,
+            "lang": "es",
+            "lang_prob": 0.99,
+            "duration_s": 7.83,
+            "end_reason": "end_of_turn",
+            "text": "hola mundo",
+        }
+    )
+    header, body = state.transcript_lines()[0].plain.split("\n")
+    assert header[2] == ":" and header[5] == ":" and len(header[:8]) == 8
+    assert "ES" in header
+    assert "7.8s" in header
+    assert "end_of_turn" in header
+    assert body.strip() == "hola mundo"
+
+
+def test_transcript_time_language_and_text_colors_are_disjoint():
+    state = ViewState()
+    for lang in ("es", "en", "fr", "de", "pt", "it", "zz"):
+        state.ingest(
+            {
+                "type": "transcript",
+                "ts": 1.0,
+                "lang": lang,
+                "duration_s": 1.0,
+                "end_reason": "pause",
+                "text": "x",
+            }
+        )
+
+    palette = {
+        str(span.style).split()[-1]
+        for block in state.transcript_lines()
+        for span in block.spans
+    }
+    time_color = str(state.transcript_lines()[0].spans[0].style).split()[-1]
+    lang_color = str(state.transcript_lines()[0].spans[1].style).split()[-1]
+    text_colors = {
+        str(block.spans[-1].style).split()[-1] for block in state.transcript_lines()
+    }
+    assert not ({time_color, lang_color} & text_colors), palette
+    assert time_color != lang_color
 
 
 def test_transcripts_total_keeps_counting_past_the_cap():
@@ -272,7 +323,9 @@ def test_cleared_event_wipes_output_and_increments_epoch():
     assert state.last_error is None
     assert state.chat_epoch == epoch + 1
     assert len(state.transcripts) == 1
-    assert state.transcript_lines() == ["[en] preserve this"]
+    block = state.transcript_lines()[0].plain
+    assert "preserve this" in block
+    assert "EN" in block
 
 
 def _signals_triage():