Преглед изворни кода

Added time to triage section

Efren Yevale Varela пре 1 месец
родитељ
комит
65c6827bda

+ 2 - 0
docs/TRIAGE.md

@@ -25,6 +25,8 @@ opt-in, so a plain `uv sync` and the existing test suite behave exactly as befor
   `opportunities`, `risks`, and `action_items`.
   `opportunities`, `risks`, and `action_items`.
 - **A reason line** while the lane is not `ok`, for example when the model is still loading
 - **A reason line** while the lane is not `ok`, for example when the model is still loading
   or the `questions.json` file is missing. The panel never renders blank.
   or the `questions.json` file is missing. The panel never renders blank.
+- **A last-triage clock** as the first line whenever a real triage is shown: the server-side
+  time of the last triage in the transcript pane's `HH:MM:SS` format and `bold cyan` style.
 - **Cleared on reset**: `clear` empties the pane. Leaving chat mode does not.
 - **Cleared on reset**: `clear` empties the pane. Leaving chat mode does not.
 
 
 The panel is a view over events (`stage` of `signals`, `content`, or `cleared`) plus the
 The panel is a view over events (`stage` of `signals`, `content`, or `cleared`) plus the

+ 5 - 4
src/stt_daemon/assistant/service.py

@@ -687,6 +687,7 @@ class AssistantService:
             if generation != self._triage_generation:
             if generation != self._triage_generation:
                 return
                 return
             window = self._last_triage_window
             window = self._last_triage_window
+        ts = self._now()
         signals = None
         signals = None
         if payload is not None:
         if payload is not None:
             signals = self._triage.normalize(payload)
             signals = self._triage.normalize(payload)
@@ -696,17 +697,17 @@ class AssistantService:
                 if generation != self._triage_generation:
                 if generation != self._triage_generation:
                     return
                     return
                 self.last_triage = signals
                 self.last_triage = signals
-            self._emit(TRIAGE_EVENT, stage="signals", triage=signals)
+            self._emit(TRIAGE_EVENT, stage="signals", triage=signals, ts=ts)
         if self._triage_extract_fn is not None and getattr(
         if self._triage_extract_fn is not None and getattr(
             self.cfg.triage, "extract_enabled", True
             self.cfg.triage, "extract_enabled", True
         ):
         ):
             job = InferenceJob(
             job = InferenceJob(
                 name="triage-extract",
                 name="triage-extract",
-                run=lambda: self._run_triage_extract(generation, window, signals),
+                run=lambda: self._run_triage_extract(generation, window, signals, ts),
             )
             )
             self._inference.submit_if_room(job)
             self._inference.submit_if_room(job)
 
 
-    def _run_triage_extract(self, generation, window, signals) -> None:
+    def _run_triage_extract(self, generation, window, signals, ts=None) -> None:
         if self._triage is None or self._triage_extract_fn is None:
         if self._triage is None or self._triage_extract_fn is None:
             return
             return
         with self._lock:
         with self._lock:
@@ -729,7 +730,7 @@ class AssistantService:
                 return
                 return
             merged = {**(signals or {}), "content": parsed}
             merged = {**(signals or {}), "content": parsed}
             self.last_triage = merged
             self.last_triage = merged
-        self._emit(TRIAGE_EVENT, stage="content", triage=merged)
+        self._emit(TRIAGE_EVENT, stage="content", triage=merged, ts=ts)
 
 
     def _reset_triage(self) -> None:
     def _reset_triage(self) -> None:
         if self._triage is None and self._laya_lane is None:
         if self._triage is None and self._laya_lane is None:

+ 2 - 1
src/stt_daemon/tui/app.py

@@ -182,7 +182,8 @@ class AssistantTUI(App):
             if self._seen_triage_epoch != state.triage_epoch:
             if self._seen_triage_epoch != state.triage_epoch:
                 triage_log.clear()
                 triage_log.clear()
                 self._seen_triage_epoch = state.triage_epoch
                 self._seen_triage_epoch = state.triage_epoch
-                triage_log.write("\n".join(state.triage_lines()))
+                for item in state.triage_renderables():
+                    triage_log.write(item)
         self._update_main_visibility()
         self._update_main_visibility()
 
 
     def _send(self, type_: str, **fields) -> None:
     def _send(self, type_: str, **fields) -> None:

+ 17 - 0
src/stt_daemon/tui/state.py

@@ -102,6 +102,7 @@ class ViewState:
     triage_state: str = "disabled"
     triage_state: str = "disabled"
     triage_reason: str = ""
     triage_reason: str = ""
     triage_epoch: int = 0
     triage_epoch: int = 0
+    triage_ts: float | None = None
     speakers: list[dict] = field(default_factory=list)
     speakers: list[dict] = field(default_factory=list)
 
 
     def ingest(self, event: dict) -> None:
     def ingest(self, event: dict) -> None:
@@ -134,11 +135,13 @@ class ViewState:
             stage = event.get("stage")
             stage = event.get("stage")
             if stage == "cleared":
             if stage == "cleared":
                 self.triage = None
                 self.triage = None
+                self.triage_ts = None
             else:
             else:
                 # A triage payload can only come from a ready lane: authoritative.
                 # A triage payload can only come from a ready lane: authoritative.
                 self.triage_state = "ok"
                 self.triage_state = "ok"
                 self.triage_reason = ""
                 self.triage_reason = ""
                 self.triage = event.get("triage")
                 self.triage = event.get("triage")
+                self.triage_ts = event.get("ts", self.triage_ts)
             self.triage_epoch += 1
             self.triage_epoch += 1
         elif etype == "speech_start":
         elif etype == "speech_start":
             self.speech_open = True
             self.speech_open = True
@@ -208,6 +211,7 @@ class ViewState:
         self.tool_result = None
         self.tool_result = None
         self.last_error  = None
         self.last_error  = None
         self.triage      = None
         self.triage      = None
+        self.triage_ts   = None
         self.chat_epoch += 1
         self.chat_epoch += 1
         self.triage_epoch += 1
         self.triage_epoch += 1
 
 
@@ -260,6 +264,19 @@ class ViewState:
     def chat_lines(self) -> list[str]:
     def chat_lines(self) -> list[str]:
         return [f"{m.get('role', '?')}: {m.get('text', '')}" for m in self.chat]
         return [f"{m.get('role', '?')}: {m.get('text', '')}" for m in self.chat]
 
 
+    def triage_renderables(self) -> list[Text]:
+        """triage_lines() as Text, with the last-triage clock header when available."""
+        out = [Text(line) for line in self.triage_lines()]
+        if (
+            self.triage_ts is not None
+            and self.triage_state == "ok"
+            and isinstance(self.triage, dict)
+        ):
+            clock = Text()
+            clock.append(_fmt_clock(self.triage_ts), style=TIME_STYLE)
+            out.insert(0, clock)
+        return out
+
     def triage_lines(self) -> list[str]:
     def triage_lines(self) -> list[str]:
         """Render the triage panel lines. Pure and defensive: never raises."""
         """Render the triage panel lines. Pure and defensive: never raises."""
         if self.triage_state != "ok":
         if self.triage_state != "ok":

+ 26 - 0
tests/test_assistant_service.py

@@ -785,6 +785,31 @@ def test_on_triage_result_emits_signals():
     assert service.status()["triage"] == service.last_triage
     assert service.status()["triage"] == service.last_triage
 
 
 
 
+def test_triage_events_carry_the_server_timestamp():
+    service, emitted, clock, _, lane = _triage_service(extract_fn=lambda prompt: "RAW")
+    service.toggle_chat()
+    service.handle_event(_transcript(clock, "hello"))
+    clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
+    service.tick()
+    _, _, _, gen = lane.submits[0]
+    expected_ts = clock()
+
+    service._on_triage_result(gen, _escalated_payload())
+
+    signals = [e for e in _triage_events(emitted) if e.get("stage") == "signals"]
+    assert len(signals) == 1
+    assert isinstance(signals[0]["ts"], (int, float))
+    assert signals[0]["ts"] == expected_ts
+
+    job = [j for j in service._inference._queue if j.name == "triage-extract"][0]
+    job.run()
+
+    contents = [e for e in _triage_events(emitted) if e.get("stage") == "content"]
+    assert len(contents) == 1
+    assert isinstance(contents[0]["ts"], (int, float))
+    assert contents[0]["ts"] == expected_ts  # content reuses the signals timestamp
+
+
 def test_on_triage_result_suppresses_stale_generation():
 def test_on_triage_result_suppresses_stale_generation():
     service, emitted, clock, _, lane = _triage_service()
     service, emitted, clock, _, lane = _triage_service()
     service.toggle_chat()
     service.toggle_chat()
@@ -855,6 +880,7 @@ def test_none_payload_enqueues_state_only_extract():
     contents = [e for e in _triage_events(emitted) if e.get("stage") == "content"]
     contents = [e for e in _triage_events(emitted) if e.get("stage") == "content"]
     assert len(contents) == 1
     assert len(contents) == 1
     assert contents[0]["triage"] == {"content": triage.parse_extraction("RAW")}
     assert contents[0]["triage"] == {"content": triage.parse_extraction("RAW")}
+    assert contents[0]["ts"] == clock()
     assert service.last_triage == contents[0]["triage"]
     assert service.last_triage == contents[0]["triage"]
     assert triage.prompt_calls == [(service._last_triage_window, None)]
     assert triage.prompt_calls == [(service._last_triage_window, None)]
 
 

+ 89 - 0
tests/test_tui_state.py

@@ -6,7 +6,9 @@ from stt_daemon.tui.state import (
     MAX_TRANSCRIPTS,
     MAX_TRANSCRIPTS,
     SPEAKER_NAMED_STYLE,
     SPEAKER_NAMED_STYLE,
     SPEAKER_UNKNOWN_STYLE,
     SPEAKER_UNKNOWN_STYLE,
+    TIME_STYLE,
     ViewState,
     ViewState,
+    _fmt_clock,
 )
 )
 
 
 
 
@@ -654,3 +656,90 @@ def test_cleared_event_after_lone_signals_leaves_state_usable():
     state.ingest({"type": "triage", "stage": "cleared"})
     state.ingest({"type": "triage", "stage": "cleared"})
     assert state.triage is None
     assert state.triage is None
     assert state.triage_lines() == ["triage: unavailable — no data"]
     assert state.triage_lines() == ["triage: unavailable — no data"]
+
+
+def test_triage_event_with_ts_records_last_triage_time():
+    state = ViewState()
+    state.ingest(
+        {"type": "triage", "stage": "signals", "triage": _signals_triage(), "ts": 1759073916.19}
+    )
+    assert state.triage_ts == 1759073916.19
+
+
+def test_triage_event_without_ts_keeps_previous_time():
+    state = ViewState()
+    state.ingest(
+        {"type": "triage", "stage": "signals", "triage": _signals_triage(), "ts": 1759073916.19}
+    )
+    state.ingest(
+        {
+            "type": "triage",
+            "stage": "content",
+            "triage": {**_signals_triage(), "content": {"summary": "s"}},
+        }
+    )
+    assert state.triage_ts == 1759073916.19
+
+
+def test_triage_renderables_prepends_clock_in_transcript_time_style():
+    ts = 1759073916.19
+    state = ViewState()
+    state.ingest({"type": "triage", "stage": "signals", "triage": _signals_triage(), "ts": ts})
+
+    renderables = state.triage_renderables()
+
+    assert renderables[0].plain == _fmt_clock(ts)
+    assert str(renderables[0].spans[0].style) == TIME_STYLE
+    assert [item.plain for item in renderables[1:]] == state.triage_lines()
+
+
+def test_triage_cleared_event_and_clear_output_drop_the_clock():
+    ts = 1759073916.19
+    state = ViewState()
+    state.ingest({"type": "triage", "stage": "signals", "triage": _signals_triage(), "ts": ts})
+
+    state.ingest({"type": "triage", "stage": "cleared"})
+
+    assert state.triage_ts is None
+    assert all(item.plain != _fmt_clock(ts) for item in state.triage_renderables())
+
+    state.ingest({"type": "triage", "stage": "signals", "triage": _signals_triage(), "ts": ts})
+    state.clear_output()
+
+    assert state.triage_ts is None
+    assert all(item.plain != _fmt_clock(ts) for item in state.triage_renderables())
+
+
+def test_triage_renderables_clock_only_for_ok_dict_triage():
+    ts = 1759073916.19
+    clock = _fmt_clock(ts)
+    state = ViewState()
+    state.ingest({"type": "triage", "stage": "signals", "triage": _signals_triage(), "ts": ts})
+
+    state.triage_state = "loading"
+    state.triage_reason = "warming up"
+    assert state.triage_renderables()[0].plain == "triage: unavailable — warming up"
+    assert all(item.plain != clock for item in state.triage_renderables())
+
+    state.triage_state = "ok"
+    state.triage = None
+    assert state.triage_renderables()[0].plain == "triage: unavailable — no data"
+    assert all(item.plain != clock for item in state.triage_renderables())
+
+
+def test_status_adoption_preserves_last_triage_time():
+    ts = 1759073916.19
+    state = ViewState()
+    state.ingest({"type": "triage", "stage": "signals", "triage": _signals_triage(), "ts": ts})
+
+    state.ingest(
+        {
+            "type": "status",
+            "triage": _signals_triage(),
+            "triage_state": "ok",
+            "triage_reason": "",
+        }
+    )
+
+    assert state.triage_ts == ts
+    assert state.triage_renderables()[0].plain == _fmt_clock(ts)