Prechádzať zdrojové kódy

Added option to send chat to triage

Efren Yevale Varela 1 mesiac pred
rodič
commit
e636b7007a

+ 15 - 6
docs/ASSISTANT.md

@@ -60,6 +60,13 @@ While in chat mode, spoken transcripts are captured automatically. The TUI's
 automatic capture without leaving chat: typed `chat-send` turns still reach the model,
 and triage/brief keep consuming transcripts independently.
 
+With the triage panel enabled, a second `send to triage` checkbox
+(`{"type":"triage_chat","on":true}`) mirrors chat turns into the triage window and brief.
+Because triage already hears spoken turns from the microphone, the toggle is
+content-aware: when `send transcripts` is on it adds only the assistant reply; when it is
+off (so chat turns are typed) it adds both your message and the assistant reply. The reply
+is always added, since it is independent of the transcriptions.
+
 ## The two-press command flow
 
 1. Arm (`stt-ctl command-start`, or `a` in the TUI).
@@ -89,12 +96,14 @@ Speaker control adds the inbound types `{"type":"speaker_list"}` and
 and `{"type":"error","code":"bad_message"}` for an empty name.
 
 Assistant → clients: `mode`, `command_text`, `transcript`, `speech_start`/`speech_stop`,
-`tool_call`, `tool_result`, `chat_message`, `chat_capture`, `speakers`, `cleared`, `heartbeat`,
-`error`, plus the direct replies `ack` (command accepted), `status`, and `speaker_list`.
+`tool_call`, `tool_result`, `chat_message`, `chat_capture`, `triage_chat`, `speakers`, `cleared`,
+`heartbeat`, `error`, plus the direct replies `ack` (command accepted), `status`, and
+`speaker_list`.
 
-`chat_capture` (inbound set, outbound broadcast) carries `{"on": bool}` and gates whether spoken
-transcripts are auto-fed into the chat session. `status` echoes the current value as
-`chat_capture`.
+`chat_capture` and `triage_chat` (inbound set, outbound broadcast) each carry `{"on": bool}`.
+`chat_capture` gates whether spoken transcripts are auto-fed into the chat session; `triage_chat`
+gates whether chat turns are mirrored into the triage window and brief (see above for the
+content rule). `status` echoes both as `chat_capture` and `triage_chat`.
 
 The `speakers` event carries `{"speakers":[...]}`. The daemon fires it on **mint / confirm /
 rename / prune only** (not on every adapt); the assistant caches that roster to answer
@@ -223,7 +232,7 @@ daemon, remove the id from `speakers/speakers.json` and `prototypes.npz`, then r
 
 `t` cycles the lower panes through command output, conversation, and hidden. Selecting the conversation turns chat mode on; cycling away turns it off. The conversation is kept across those transitions — only `c` discards it. Arming or executing a command (by key or typed input) leaves chat mode if it was on and reveals the command-output pane; this never happens from within chat itself, since a command always pre-empts it. Transcripts are not affected.
 
-The row below the transcript holds two inputs, split 50/50: the command buffer on the left and a **chat** box on the right. Typing a message into the chat box and pressing Enter turns chat mode on (if it was off) and shows the conversation pane, sends the message immediately (no turn-end wait), clears the box, and keeps it focused for the next message. Chat mode does not need to be entered first. To the right of the conversation pane sits a **send transcripts** checkbox: turning it off stops spoken transcripts from being fed to the model while staying in chat (typed messages still send, and triage/brief are unaffected); it is clickable, or focusable with `Tab` outside the maximized view and toggled with Space. Maximizing the conversation pane (`m`) hides the command buffer, keeps the chat box docked at the bottom of the screen, and docks the checkbox beneath the pane; maximizing the command-output pane keeps the command buffer docked there instead. While maximized, `Tab` / `Shift+Tab` cycle focus between the pane and its docked input.
+The row below the transcript holds two inputs, split 50/50: the command buffer on the left and a **chat** box on the right. Typing a message into the chat box and pressing Enter turns chat mode on (if it was off) and shows the conversation pane, sends the message immediately (no turn-end wait), clears the box, and keeps it focused for the next message. Chat mode does not need to be entered first. To the right of the conversation pane sits a **send transcripts** checkbox: turning it off stops spoken transcripts from being fed to the model while staying in chat (typed messages still send, and triage/brief are unaffected); it is clickable, or focusable with `Tab` outside the maximized view and toggled with Space. When the triage panel is enabled, a second **send to triage** checkbox mirrors chat turns into triage (assistant reply always, plus your message when `send transcripts` is off). Maximizing the conversation pane (`m`) hides the command buffer, keeps the chat box docked at the bottom of the screen, and docks the checkboxes beneath the pane; maximizing the command-output pane keeps the command buffer docked there instead. While maximized, `Tab` / `Shift+Tab` cycle focus between the pane and its docked input.
 
 `c` asks for confirmation, then wipes the conversation and command-output panes, hides whichever of them was showing, and resets the assistant's in-memory chat history (the LLM session), while staying in the current mode. Transcripts are not affected.
 

+ 3 - 0
src/stt_daemon/assistant/control.py

@@ -264,6 +264,9 @@ class ControlServer:
         elif command == "chat_capture":
             ok = service.set_chat_capture(bool(message.get("on", True)))
             self._ack_or_error(client, command, ok, "cannot set chat capture")
+        elif command == "triage_chat":
+            ok = service.set_triage_chat(bool(message.get("on", False)))
+            self._ack_or_error(client, command, ok, "cannot set triage chat")
         elif command == "clear":
             self._ack_or_error(client, command, service.clear(), "nothing to clear")
         elif command == "speaker_list":

+ 41 - 2
src/stt_daemon/assistant/service.py

@@ -31,6 +31,7 @@ TOOL_CALL_EVENT = "tool_call"
 TOOL_RESULT_EVENT = "tool_result"
 CHAT_MESSAGE_EVENT = "chat_message"
 CHAT_CAPTURE_EVENT = "chat_capture"
+TRIAGE_CHAT_EVENT = "triage_chat"
 CLEARED_EVENT = "cleared"
 TRIAGE_EVENT = "triage"
 
@@ -124,6 +125,7 @@ class AssistantService:
         self._pending_chat: list[str] = []
         self.triage_history: list[str] = []
         self.chat_capture = True
+        self.triage_chat = False
 
         # Speaker roster cache (display-name join only; the daemon owns identity).
         self._speakers: dict[str, dict] = {}
@@ -329,6 +331,7 @@ class AssistantService:
             "session_dir": self._session_dir,
             "chat_turns": len(self.chat_history),
             "chat_capture": self.chat_capture,
+            "triage_chat": self.triage_chat,
             "inference_depth": self._inference.depth(),
             "inference_in_flight": self._inference.in_flight(),
             "triage_state": triage_state,
@@ -498,6 +501,16 @@ class AssistantService:
         self._emit(CHAT_CAPTURE_EVENT, on=on)
         return True
 
+    def set_triage_chat(self, on: bool) -> bool:
+        """Gate whether chat turns are fed into the triage window and brief."""
+        on = bool(on)
+        with self._lock:
+            if on == self.triage_chat:
+                return True
+            self.triage_chat = on
+        self._emit(TRIAGE_CHAT_EVENT, on=on)
+        return True
+
     def submit_chat_text(self, text: str) -> bool:
         """Send one typed chat turn immediately, bypassing the turn-end gate."""
         text = (text or "").strip()
@@ -924,6 +937,8 @@ class AssistantService:
         self._debug_emit("queue-full", job="chat")
 
     def _run_chat(self, text: str) -> None:
+        if self.triage_chat and not self.chat_capture:
+            self._capture_chat_turn("You", text)
         self._emit(CHAT_MESSAGE_EVENT, role="user", text=text)
         chat_fn = self.chat_fn
         if chat_fn is None:
@@ -937,7 +952,25 @@ class AssistantService:
                 self._debug_emit("llm-timeout", job="chat")
             return
         if reply:
+            if self.triage_chat:
+                self._capture_chat_turn("Assistant", reply)
             self._emit(CHAT_MESSAGE_EVENT, role="assistant", text=reply)
+            if self.triage_chat:
+                self._triage_check(force=True)
+
+    def _capture_chat_turn(self, label: str, text: str) -> None:
+        """Append one labeled chat turn to the triage window and brief deltas."""
+        if self._triage is None:
+            return
+        text = (text or "").strip()
+        if not text:
+            return
+        line = f"{label}: {text}"
+        with self._lock:
+            self.triage_history.append(line)
+            del self.triage_history[:-_TRIAGE_HISTORY_MAX]
+            self._pending_brief.append(line)
+            self._cap_pending_brief_locked()
 
     def _periodic_s(self) -> float:
         """Accumulating-speech cadence; <= 0 disables the periodic trigger."""
@@ -948,6 +981,12 @@ class AssistantService:
 
     def _maybe_triage(self) -> None:
         """Classify recent utterances on a settled turn, or periodically while speech grows."""
+        self._triage_check(force=False)
+
+    def _triage_check(self, force: bool) -> None:
+        """Submit the retained window to the Laya lane when a trigger fires."""
+        if self._triage is None or self._laya_lane is None:
+            return
         questions = self._triage.questions
         if not questions:
             return
@@ -962,14 +1001,14 @@ class AssistantService:
                 and self._triage_next_due is not None
                 and now >= self._triage_next_due
             )
-            if not settled and not periodic_due:
+            if not force and not settled and not periodic_due:
                 return
             window = tuple(self.triage_history[-int(self.cfg.triage.window):])
             if not window or window == self._last_triage_window:
                 return
             generation = self._triage_generation
             self._last_triage_window = window
-        trigger = "settled" if settled else "periodic"
+        trigger = "settled" if settled else ("periodic" if periodic_due else "chat")
         state = self._triage.build_state(window)
         if not state:
             self._debug_emit("triage-skip", reason="empty_state", generation=generation)

+ 26 - 12
src/stt_daemon/tui/app.py

@@ -53,7 +53,7 @@ class AssistantTUI(App):
     #transcript.-maximized { height: 1fr; }
     #transcript.-grow { height: 1fr; }
     Screen.-maximized-view #inputs { dock: bottom; }
-    Screen.-maximized-view #chat-controls { dock: bottom; width: 100%; height: 3; }
+    Screen.-maximized-view #chat-controls { dock: bottom; width: 100%; height: 4; }
     """
 
     BINDINGS = [
@@ -88,7 +88,7 @@ class AssistantTUI(App):
         self._show_questions = False
         self._lower_view = "hidden"
         self._maximized_input = None
-        self._syncing_capture = False
+        self._syncing_controls: set[str] = set()
         self._seen_view = "triage"
         self._timer = None
         self._last_seg_title = ""
@@ -108,16 +108,21 @@ class AssistantTUI(App):
                 with ChatPaneRow(id="chat-row"):
                     yield RichLog(id="chat", wrap=True, markup=False)
                     with Vertical(id="chat-controls"):
-                        yield Checkbox("send transcripts", value=True, id="chat-capture")
+                        yield from self._chat_controls()
                 yield RichLog(id="output", wrap=True, markup=False)
         else:
             yield RichLog(id="output", wrap=True, markup=False)
             with ChatPaneRow(id="chat-row"):
                 yield RichLog(id="chat", wrap=True, markup=False)
                 with Vertical(id="chat-controls"):
-                    yield Checkbox("send transcripts", value=True, id="chat-capture")
+                    yield from self._chat_controls()
         yield Footer()
 
+    def _chat_controls(self):
+        yield Checkbox("send transcripts", value=True, id="chat-capture")
+        if self.triage_enabled:
+            yield Checkbox("send to triage", value=False, id="triage-chat")
+
     def _set_titles(self) -> None:
         self.query_one("#command", Input).border_title = "command buffer"
         self.query_one("#chat-input", Input).border_title = "chat"
@@ -261,12 +266,17 @@ class AssistantTUI(App):
                 for item in renderables:
                     triage_log.write(item)
                 self._seen_view = view
-        if self.query("#chat-capture"):
-            capture = self.query_one("#chat-capture", Checkbox)
-            if capture.value != state.chat_capture:
-                self._syncing_capture = True
-                capture.value = state.chat_capture
-                self._syncing_capture = False
+        for control_id, value in (
+            ("chat-capture", state.chat_capture),
+            ("triage-chat", state.triage_chat),
+        ):
+            if not self.query(f"#{control_id}"):
+                continue
+            control = self.query_one(f"#{control_id}", Checkbox)
+            if control.value != value:
+                self._syncing_controls.add(control_id)
+                control.value = value
+                self._syncing_controls.discard(control_id)
         self._update_main_visibility()
 
     def _send(self, type_: str, **fields) -> None:
@@ -515,9 +525,13 @@ class AssistantTUI(App):
         self.query_one("#chat-input", Input).focus()
 
     def on_checkbox_changed(self, event: Checkbox.Changed) -> None:
-        if event.checkbox.id != "chat-capture" or self._syncing_capture:
+        control_id = event.checkbox.id
+        if control_id not in ("chat-capture", "triage-chat"):
+            return
+        if control_id in self._syncing_controls:
             return
-        self._send("chat_capture", on=bool(event.value))
+        type_ = "chat_capture" if control_id == "chat-capture" else "triage_chat"
+        self._send(type_, on=bool(event.value))
 
     def on_unmount(self) -> None:
         if self._timer is not None:

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

@@ -91,6 +91,7 @@ class ViewState:
     command_edited: bool = False
     in_flight: bool = False
     chat_capture: bool = True
+    triage_chat: bool = False
     transcripts: list[dict] = field(default_factory=list)
     transcripts_total: int = 0
     chat: list[dict] = field(default_factory=list)
@@ -123,6 +124,8 @@ class ViewState:
             self.stt_ok = bool(event.get("stt_ok", self.stt_ok))
             if "chat_capture" in event:
                 self.chat_capture = bool(event["chat_capture"])
+            if "triage_chat" in event:
+                self.triage_chat = bool(event["triage_chat"])
             if "speech_open" in event:
                 self.speech_open = bool(event["speech_open"])
             changed = False
@@ -187,6 +190,8 @@ class ViewState:
             self.transcripts_total += 1
         elif etype == "chat_capture":
             self.chat_capture = bool(event.get("on", self.chat_capture))
+        elif etype == "triage_chat":
+            self.triage_chat = bool(event.get("on", self.triage_chat))
         elif etype == "chat_message":
             self.chat.append(event)
             del self.chat[:-MAX_CHAT]

+ 47 - 0
tests/test_assistant_service.py

@@ -329,6 +329,53 @@ def test_set_chat_capture_emits_once_and_reports_status():
     assert emitted == []
 
 
+def test_triage_chat_off_ignores_chat_turns():
+    service, _, _, _, lane = _triage_service()
+
+    service._run_chat("typed")
+
+    assert lane.submits == []
+    assert service.triage_history == []
+
+
+def test_triage_chat_sends_reply_only_when_capture_on():
+    service, _, _, _, lane = _triage_service()
+    service.toggle_chat()
+    service.set_triage_chat(True)
+
+    service._run_chat("spoken, already in triage")
+
+    assert service.triage_history == ["Assistant: ok"]
+    assert lane.submits[0][0] == "- Assistant: ok"
+
+
+def test_triage_chat_sends_user_and_reply_when_capture_off():
+    service, _, _, _, lane = _triage_service()
+    service.toggle_chat()
+    service.set_chat_capture(False)
+    service.set_triage_chat(True)
+
+    service._run_chat("typed")
+
+    assert service.triage_history == ["You: typed", "Assistant: ok"]
+    assert lane.submits[0][0] == "- You: typed\n- Assistant: ok"
+
+
+def test_set_triage_chat_emits_once_and_reports_status():
+    service, emitted, _, _, _ = _triage_service()
+    assert service.status()["triage_chat"] is False
+
+    assert service.set_triage_chat(True) is True
+    assert [e for e in emitted if e["type"] == "triage_chat"] == [
+        {"type": "triage_chat", "on": True}
+    ]
+    assert service.status()["triage_chat"] is True
+
+    emitted.clear()
+    service.set_triage_chat(True)
+    assert emitted == []
+
+
 def test_submit_chat_text_requires_chat_and_dispatches_immediately():
     cfg = Config()
     cfg.assistant.turn_gap_s = 999.0

+ 18 - 0
tests/test_control.py

@@ -280,6 +280,23 @@ def test_chat_capture_command_sets_and_broadcasts(harness):
     client.close()
 
 
+def test_triage_chat_command_sets_and_broadcasts(harness):
+    client = Client(harness.path)
+    client.send(type="subscribe", viewer=True)
+    status = client.wait_for(lambda m: m["type"] == "status")
+    assert status is not None
+    assert status["triage_chat"] is False
+
+    client.send(type="triage_chat", on=True)
+    assert client.wait_for(lambda m: m["type"] == "ack" and m["command"] == "triage_chat")
+    assert harness.service.triage_chat is True
+    assert client.wait_for(
+        lambda m: m["type"] == "triage_chat" and m["on"] is True
+    ) is not None
+
+    client.close()
+
+
 def test_last_viewer_disconnect_ends_chat(harness):
     client = Client(harness.path)
     client.send(type="subscribe", viewer=True)
@@ -331,6 +348,7 @@ def test_status_reply_contains_fields(harness):
         "speech_open",
         "chat_turns",
         "chat_capture",
+        "triage_chat",
     ):
         assert field in status
 

+ 54 - 0
tests/test_tui_app.py

@@ -2661,3 +2661,57 @@ def test_maximize_chat_docks_capture_controls_below(server):
     assert observed["controls_display"] is True
     assert observed["controls_below_chat"] is True
     assert observed["controls_span_row"] is True
+
+
+def test_triage_chat_checkbox_only_when_triage_enabled(server):
+    """The send-to-triage checkbox is absent without the triage panel."""
+    pytest.importorskip("textual")
+
+    from stt_daemon.tui.app import AssistantTUI
+
+    path, _service = server
+    disabled = AssistantTUI(path)
+    enabled = AssistantTUI(path, triage_enabled=True)
+
+    async def scenario():
+        async with disabled.run_test() as pilot:
+            await pilot.pause()
+            observed_disabled = bool(disabled.query("#triage-chat"))
+        async with enabled.run_test() as pilot:
+            await pilot.pause()
+            observed_enabled = bool(enabled.query("#triage-chat"))
+        return observed_disabled, observed_enabled
+
+    observed_disabled, observed_enabled = asyncio.run(scenario())
+    assert observed_disabled is False
+    assert observed_enabled is True
+
+
+def test_triage_chat_checkbox_toggles_and_sends_control(server):
+    """Toggling #triage-chat on sends a triage_chat control message."""
+    pytest.importorskip("textual")
+    from textual.widgets import Checkbox
+
+    from stt_daemon.tui.app import AssistantTUI
+
+    path, _service = server
+    app = AssistantTUI(path, triage_enabled=True)
+    sent: list[tuple] = []
+    observed: dict = {}
+
+    async def scenario():
+        async with app.run_test() as pilot:
+            await pilot.pause()
+            app._timer.stop()  # no background drain: server status must not clobber state
+            app._drain()
+            app.client.send = lambda type_, **fields: sent.append((type_, fields))
+            control = app.query_one("#triage-chat", Checkbox)
+            observed["initial"] = control.value
+            control.value = True
+            await pilot.pause()
+            observed["after_on"] = control.value
+
+    asyncio.run(scenario())
+    assert observed["initial"] is False
+    assert observed["after_on"] is True
+    assert ("triage_chat", {"on": True}) in sent

+ 11 - 0
tests/test_tui_state.py

@@ -127,6 +127,17 @@ def test_chat_capture_defaults_on_and_follows_event_and_status():
     assert state.chat_capture is True
 
 
+def test_triage_chat_defaults_off_and_follows_event_and_status():
+    state = ViewState()
+    assert state.triage_chat is False
+
+    state.ingest({"type": "triage_chat", "on": True})
+    assert state.triage_chat is True
+
+    state.ingest({"type": "status", "triage_chat": False})
+    assert state.triage_chat is False
+
+
 def test_transcripts_are_bounded():
     state = ViewState()
     for i in range(MAX_TRANSCRIPTS + 25):