Prechádzať zdrojové kódy

Added questions view

Efren Yevale Varela 1 mesiac pred
rodič
commit
2b2a9649c8

+ 3 - 1
docs/ASSISTANT.md

@@ -183,10 +183,12 @@ daemon, remove the id from `speakers/speakers.json` and `prototypes.npz`, then r
 
 ## TUI keys
 
-`a` arm · `Enter` execute · `Esc` cancel · `e` edit · `t` chat toggle · `c` clear · `q` quit
+`a` arm · `Enter` execute · `Esc` cancel · `e` edit · `t` chat toggle · `s` questions view · `c` clear · `q` quit
 
 `c` asks for confirmation, then wipes the conversation / command-output pane and resets the assistant's in-memory chat history (the LLM session), while staying in the current mode. Transcripts are not affected.
 
+`s` swaps the triage pane between the live signals view and a structured view of the loaded `questions.json` (one block per question with its id, type, instructions, and criteria); it is a no-op when `[triage].enabled = false`. See [TRIAGE.md](TRIAGE.md).
+
 **Live queue counters:** The pane borders show real-time queue depths. `transcripts [seg N]` displays the number of audio segments queued for transcription (the daemon's backlog). `conversation / command output [inf N]` shows the number of LLM inference jobs queued, with `· busy` appearing while one is running. These counters refresh about once per second, driven by the heartbeat cadence.
 
 **Selecting / copying text:** the TUI captures mouse events (Textual enables mouse

+ 9 - 2
docs/TRIAGE.md

@@ -30,8 +30,15 @@ opt-in, so a plain `uv sync` and the existing test suite behave exactly as befor
 - **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
-cached `triage_state` / `triage_reason` in `status()`. It never feeds anything back into
-the assistant.
+cached `triage_state` / `triage_reason` / `questions` in `status()`. It never feeds anything
+back into the assistant.
+
+Pressing `s` in the TUI swaps the pane to a structured view of the loaded `questions.json`
+and pressing it again swaps back. The view renders one block per qid in file order — the
+qid, its type, the instructions, and the criteria (choice labels, a label-to-description
+map, ordered score levels, or the `true`/`false` descriptions of a `noul` question). The
+questions come from the assistant's validated payload (the same dict Laya uses), not from
+disk, and it is a no-op when `[triage].enabled = false`.
 
 ## How the cascade works
 

+ 1 - 0
src/stt_daemon/assistant/service.py

@@ -233,6 +233,7 @@ class AssistantService:
             "triage_state": triage_state,
             "triage_reason": triage_reason,
             "triage": self.last_triage,
+            "questions": self._triage.questions if self._triage is not None else None,
         }
 
     # ------------------------------------------------------------------ actions

+ 33 - 4
src/stt_daemon/tui/app.py

@@ -47,6 +47,7 @@ class AssistantTUI(App):
         Binding("escape", "cancel", "cancel"),
         Binding("e", "edit", "edit command"),
         Binding("t", "chat", "chat toggle"),
+        Binding("s", "toggle_questions", "questions view"),
         Binding("question_mark", "toggle_help_panel", "help"),
         Binding("q", "quit", "quit"),
         Binding("m", "toggle_maximize", "maximize pane", show=False),
@@ -67,6 +68,9 @@ class AssistantTUI(App):
         self._seen_lower = 0
         self._seen_epoch = 0
         self._seen_triage_epoch = 0
+        self._seen_questions_epoch = 0
+        self._show_questions = False
+        self._seen_view = "triage"
         self._timer = None
         self._last_seg_title = ""
         self._last_inf_title = ""
@@ -86,9 +90,12 @@ class AssistantTUI(App):
     def _set_titles(self) -> None:
         self.query_one("#command", Input).border_title = "command buffer"
         if self.triage_enabled:
-            self.query_one("#triage", RichLog).border_title = "triage"
+            self.query_one("#triage", RichLog).border_title = self._triage_title()
         self._update_titles(self.state)
 
+    def _triage_title(self) -> str:
+        return "questions" if self._show_questions else "triage"
+
     def _update_titles(self, state) -> None:
         seg_title = f"transcripts  [seg {state.queue_depth}]"
         if seg_title != self._last_seg_title:
@@ -179,11 +186,25 @@ class AssistantTUI(App):
 
         if self.triage_enabled:
             triage_log = self.query_one("#triage", RichLog)
-            if self._seen_triage_epoch != state.triage_epoch:
+            if self._show_questions:
+                view = "questions"
+                epoch = state.questions_epoch
+                seen_epoch = self._seen_questions_epoch
+            else:
+                view = "triage"
+                epoch = state.triage_epoch
+                seen_epoch = self._seen_triage_epoch
+            if self._seen_view != view or seen_epoch != epoch:
                 triage_log.clear()
-                self._seen_triage_epoch = state.triage_epoch
-                for item in state.triage_renderables():
+                if self._show_questions:
+                    renderables = state.questions_renderables()
+                    self._seen_questions_epoch = epoch
+                else:
+                    renderables = state.triage_renderables()
+                    self._seen_triage_epoch = epoch
+                for item in renderables:
                     triage_log.write(item)
+                self._seen_view = view
         self._update_main_visibility()
 
     def _send(self, type_: str, **fields) -> None:
@@ -207,6 +228,14 @@ class AssistantTUI(App):
     def action_clear(self) -> None:
         self.push_screen(ConfirmClear(), self._on_clear_confirmed)
 
+    def action_toggle_questions(self) -> None:
+        if not self.triage_enabled:
+            return
+        self._show_questions = not self._show_questions
+        if self.query("#triage"):
+            self.query_one("#triage", RichLog).border_title = self._triage_title()
+        self._render()
+
     def _on_clear_confirmed(self, confirmed: bool) -> None:
         if confirmed:
             self._send("clear")

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

@@ -103,6 +103,8 @@ class ViewState:
     triage_reason: str = ""
     triage_epoch: int = 0
     triage_ts: float | None = None
+    questions: dict | None = None
+    questions_epoch: int = 0
     speakers: list[dict] = field(default_factory=list)
 
     def ingest(self, event: dict) -> None:
@@ -131,6 +133,10 @@ class ViewState:
                 changed = True
             if changed:
                 self.triage_epoch += 1
+            if "questions" in event:
+                # Static config from the analyzer: independent of the triage epoch.
+                self.questions = event["questions"]
+                self.questions_epoch += 1
         elif etype == "triage":
             stage = event.get("stage")
             if stage == "cleared":
@@ -331,3 +337,69 @@ class ViewState:
         if not lines:
             return ["triage: no signals"]
         return lines
+
+    def questions_lines(self) -> list[str]:
+        """Render the loaded questions as structured lines. Pure and defensive: never raises."""
+        entries = self._question_entries()
+        if not entries:
+            reason = self.triage_reason or self.triage_state or "no questions loaded"
+            return [f"questions: unavailable — {reason}"]
+        lines: list[str] = []
+        for qid, qtype, body in entries:
+            lines.append(f"{qid}  [{qtype}]")
+            lines.extend(body)
+        return lines
+
+    def questions_renderables(self) -> list[Text]:
+        """questions_lines() as Text, with the qid header styled (bold qid, dim type)."""
+        entries = self._question_entries()
+        if not entries:
+            return [Text(line) for line in self.questions_lines()]
+        out: list[Text] = []
+        for qid, qtype, body in entries:
+            header = Text()
+            header.append(qid, style="bold")
+            header.append("  ")
+            header.append(f"[{qtype}]", style="dim")
+            out.append(header)
+            for line in body:
+                out.append(Text(line))
+        return out
+
+    def _question_entries(self) -> list[tuple[str, str, list[str]]]:
+        """(qid, type, body lines) per usable question, in file order. Never raises."""
+        entries: list[tuple[str, str, list[str]]] = []
+        if not (isinstance(self.questions, dict) and self.questions):
+            return entries
+        for qid, qdef in self.questions.items():
+            if not isinstance(qdef, dict):
+                continue
+            qtype = str(qdef.get("type") or "?")
+            body: list[str] = []
+            instructions = qdef.get("instructions")
+            if isinstance(instructions, str) and instructions:
+                body.append(f"  {instructions}")
+            body.extend(self._criteria_lines(qtype, qdef.get("criteria")))
+            entries.append((str(qid), qtype, body))
+        return entries
+
+    @staticmethod
+    def _criteria_lines(qtype: str, criteria) -> list[str]:
+        """One line per criterion for a known type; malformed criteria degrade to none."""
+        lines: list[str] = []
+        if qtype == "choice":
+            if isinstance(criteria, dict):
+                for label, description in criteria.items():
+                    lines.append(f"    {label}  — {description}")
+            elif isinstance(criteria, list):
+                for label in criteria:
+                    lines.append(f"    {label}")
+        elif qtype == "score":
+            if isinstance(criteria, list):
+                for index, level in enumerate(criteria, start=1):
+                    lines.append(f"    {index}. {level}")
+        elif qtype == "noul":
+            if isinstance(criteria, dict):
+                for value, description in criteria.items():
+                    lines.append(f"    {value}  — {description}")
+        return lines

+ 29 - 0
tests/test_assistant_service.py

@@ -621,6 +621,35 @@ def test_triage_status_reflects_analyzer():
     assert status["triage"] is None
 
 
+def test_status_contains_questions_from_analyzer():
+    questions = {
+        "urgency": {"type": "noul", "instructions": "how urgent"},
+        "sentiment": {"type": "score", "instructions": "tone", "criteria": ["low", "high"]},
+    }
+    service, _, _, triage, _ = _triage_service(triage=FakeTriage(questions=questions))
+
+    status = service.status()
+
+    assert status["questions"] == questions
+    assert status["questions"] is triage.questions
+
+
+def test_status_questions_none_without_analyzer():
+    service, _, _ = _service()
+
+    assert service.status()["questions"] is None
+
+
+def test_status_event_carries_questions():
+    service, emitted, _, triage, _ = _triage_service()
+
+    service.tick()
+
+    statuses = [e for e in emitted if e["type"] == "status"]
+    assert len(statuses) == 1
+    assert statuses[0]["questions"] is triage.questions
+
+
 def test_triage_captures_transcript_lang():
     service, _, clock, _, _ = _triage_service()
     assert service._last_transcript_lang is None

+ 179 - 3
tests/test_tui_app.py

@@ -1115,7 +1115,7 @@ def test_question_mark_key_toggles_help_panel(server):
 
 
 def test_app_bindings_list():
-    """BINDINGS: 7 visible footer entries; hidden ones after; no h binding."""
+    """BINDINGS: 8 visible footer entries; hidden ones after; no h binding."""
     pytest.importorskip("textual")
     from stt_daemon.tui.app import AssistantTUI
 
@@ -1128,6 +1128,7 @@ def test_app_bindings_list():
     assert ("escape", "cancel") in pairs
     assert ("e", "edit command") in pairs
     assert ("t", "chat toggle") in pairs
+    assert ("s", "questions view") in pairs
     assert ("c", "clear") in pairs
     assert ("q", "quit") in pairs
     assert ("ctrl+p", "instructions") in pairs
@@ -1138,11 +1139,12 @@ def test_app_bindings_list():
     assert not any(key == "ctrl+k" for key, _ in pairs)
     by_key = {b.key: b for b in AssistantTUI.BINDINGS}
     assert by_key["question_mark"].show is True
+    assert by_key["s"].show is True
     assert by_key["m"].show is False
     assert by_key["c"].show is False
     assert by_key["ctrl+s"].show is False
     assert by_key["ctrl+p"].show is False
-    # Visible seven first, in footer order.
+    # Visible eight first, in footer order.
     visible = [(b.key, b.description) for b in AssistantTUI.BINDINGS if b.show]
     assert visible == [
         ("a", "arm command"),
@@ -1150,6 +1152,7 @@ def test_app_bindings_list():
         ("escape", "cancel"),
         ("e", "edit command"),
         ("t", "chat toggle"),
+        ("s", "questions view"),
         ("question_mark", "help"),
         ("q", "quit"),
     ]
@@ -1157,7 +1160,7 @@ def test_app_bindings_list():
 
 
 def test_footer_shows_ordered_entries(server):
-    """Left list is the 7 visible bindings (? for help); instructions only right-aligned."""
+    """Left list is the 8 visible bindings (? for help); instructions only right-aligned."""
     pytest.importorskip("textual")
     from textual.widgets import Footer
     from textual.widgets._footer import FooterKey
@@ -1191,6 +1194,7 @@ def test_footer_shows_ordered_entries(server):
         ("escape", "cancel"),
         ("e", "edit command"),
         ("t", "chat toggle"),
+        ("s", "questions view"),
         ("question_mark", "help"),
         ("q", "quit"),
     ]
@@ -1622,3 +1626,175 @@ def test_transcript_pane_keeps_updating_past_cap(server):
     asyncio.run(scenario())
     assert any("after cap" in line for line in observed["lines"])
     assert len(observed["lines"]) > MAX_TRANSCRIPTS
+
+
+def _questions_payload():
+    return {
+        "topic": {
+            "type": "choice",
+            "instructions": "What is the main theme?",
+            "criteria": {"planning": "roadmap or prioritization"},
+        }
+    }
+
+
+def _rendered_lines(log):
+    return [line.text for line in log.lines]
+
+
+def _ingest_triage_signals(app):
+    app.state.ingest(
+        {
+            "type": "triage",
+            "stage": "signals",
+            "triage": {
+                "order": ["topic"],
+                "items": {
+                    "topic": {
+                        "kind": "choice",
+                        "choice": "planning",
+                        "answer_confidence": 0.91,
+                    }
+                },
+            },
+        }
+    )
+    app.state.ingest(
+        {
+            "type": "status",
+            "triage_state": "ok",
+            "triage_reason": "",
+            "questions": _questions_payload(),
+        }
+    )
+
+
+def test_s_key_swaps_triage_pane_to_questions_view(server):
+    """Press s: #triage border flips to questions and renders the structured blocks."""
+    pytest.importorskip("textual")
+    from textual.widgets import RichLog
+
+    from stt_daemon.tui.app import AssistantTUI
+
+    path, _service = server
+    app = AssistantTUI(path, triage_enabled=True)
+    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()
+            _ingest_triage_signals(app)
+            app._render()
+            await pilot.pause()
+            observed["default_title"] = app.query_one("#triage", RichLog).border_title
+            observed["default_lines"] = _rendered_lines(app.query_one("#triage", RichLog))
+
+            await pilot.press("s")
+            await pilot.pause()
+            observed["questions_title"] = app.query_one("#triage", RichLog).border_title
+            observed["questions_lines"] = _rendered_lines(app.query_one("#triage", RichLog))
+
+    asyncio.run(scenario())
+    assert observed["default_title"] == "triage"
+    assert any("topic: planning (0.91)" in line for line in observed["default_lines"])
+    assert observed["questions_title"] == "questions"
+    rendered = "\n".join(observed["questions_lines"])
+    assert "topic  [choice]" in rendered
+    assert "What is the main theme?" in rendered
+    assert "planning  — roadmap or prioritization" in rendered
+    assert "topic: planning" not in rendered
+
+
+def test_s_key_again_restores_triage_view(server):
+    """Second s: border flips back to triage and the signals view is rewritten."""
+    pytest.importorskip("textual")
+    from textual.widgets import RichLog
+
+    from stt_daemon.tui.app import AssistantTUI
+
+    path, _service = server
+    app = AssistantTUI(path, triage_enabled=True)
+    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()
+            _ingest_triage_signals(app)
+            app._render()
+            await pilot.pause()
+
+            await pilot.press("s")
+            await pilot.pause()
+            observed["mid_title"] = app.query_one("#triage", RichLog).border_title
+
+            await pilot.press("s")
+            await pilot.pause()
+            observed["triage_title"] = app.query_one("#triage", RichLog).border_title
+            observed["triage_lines"] = _rendered_lines(app.query_one("#triage", RichLog))
+
+    asyncio.run(scenario())
+    assert observed["mid_title"] == "questions"
+    assert observed["triage_title"] == "triage"
+    rendered = "\n".join(observed["triage_lines"])
+    assert "topic: planning (0.91)" in rendered
+    assert "What is the main theme?" not in rendered
+
+
+def test_s_key_is_noop_when_triage_disabled(server):
+    """triage_enabled=False: action_toggle_questions and s do nothing and never crash."""
+    pytest.importorskip("textual")
+    from stt_daemon.tui.app import AssistantTUI
+
+    path, _service = server
+    app = AssistantTUI(path)
+
+    async def scenario():
+        async with app.run_test() as pilot:
+            await pilot.pause()
+            app.action_toggle_questions()
+            await pilot.pause()
+            assert app._show_questions is False
+            assert not app.query("#triage")
+            await pilot.press("s")
+            await pilot.pause()
+            assert app._show_questions is False
+            assert not app.query("#triage")
+
+    asyncio.run(scenario())
+
+
+def test_status_event_questions_populate_questions_view(server):
+    """A status event carrying questions fills state and the toggled pane."""
+    pytest.importorskip("textual")
+    from textual.widgets import RichLog
+
+    from stt_daemon.tui.app import AssistantTUI
+
+    path, _service = server
+    app = AssistantTUI(path, triage_enabled=True)
+    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()
+            observed["before_epoch"] = app.state.questions_epoch
+            app.state.ingest({"type": "status", "questions": _questions_payload()})
+            app._render()
+            await pilot.pause()
+            observed["questions"] = app.state.questions
+            observed["epoch"] = app.state.questions_epoch
+
+            await pilot.press("s")
+            await pilot.pause()
+            observed["lines"] = _rendered_lines(app.query_one("#triage", RichLog))
+
+    asyncio.run(scenario())
+    assert observed["questions"] == _questions_payload()
+    assert observed["epoch"] == observed["before_epoch"] + 1
+    assert any("topic  [choice]" in line for line in observed["lines"])

+ 180 - 0
tests/test_tui_state.py

@@ -743,3 +743,183 @@ def test_status_adoption_preserves_last_triage_time():
 
     assert state.triage_ts == ts
     assert state.triage_renderables()[0].plain == _fmt_clock(ts)
+
+
+def _questions():
+    return {
+        "topic": {
+            "type": "choice",
+            "instructions": "What is the main theme?",
+            "criteria": {
+                "planning": "roadmap or prioritization",
+                "problem": "a blocker or risk",
+            },
+        },
+        "urgency": {
+            "type": "choice",
+            "instructions": "How urgent?",
+            "criteria": ["low", "high"],
+        },
+        "sentiment": {
+            "type": "score",
+            "instructions": "Overall tone?",
+            "criteria": ["negative", "neutral", "positive"],
+        },
+        "blocker": {
+            "type": "noul",
+            "instructions": "A blocker was raised.",
+            "criteria": {"true": "an obstacle was raised", "false": "no obstacle"},
+        },
+        "owner_named": {
+            "type": "noul",
+            "instructions": "A responsible person was named.",
+        },
+    }
+
+
+def test_status_adopts_questions_and_bumps_questions_epoch_only():
+    state = ViewState()
+    triage_epoch = state.triage_epoch
+    questions = _questions()
+    state.ingest({"type": "status", "questions": questions})
+    assert state.questions == questions
+    assert state.questions_epoch == 1
+    assert state.triage_epoch == triage_epoch
+
+
+def test_status_without_questions_preserves_loaded_questions():
+    state = ViewState()
+    questions = _questions()
+    state.ingest({"type": "status", "questions": questions})
+    state.ingest({"type": "status", "mode": "idle", "ready": True})
+    assert state.questions == questions
+    assert state.questions_epoch == 1
+
+
+def test_questions_lines_render_choice_dict_choice_list_score_and_noul():
+    state = ViewState()
+    state.questions = _questions()
+    assert state.questions_lines() == [
+        "topic  [choice]",
+        "  What is the main theme?",
+        "    planning  — roadmap or prioritization",
+        "    problem  — a blocker or risk",
+        "urgency  [choice]",
+        "  How urgent?",
+        "    low",
+        "    high",
+        "sentiment  [score]",
+        "  Overall tone?",
+        "    1. negative",
+        "    2. neutral",
+        "    3. positive",
+        "blocker  [noul]",
+        "  A blocker was raised.",
+        "    true  — an obstacle was raised",
+        "    false  — no obstacle",
+        "owner_named  [noul]",
+        "  A responsible person was named.",
+    ]
+
+
+def test_questions_render_while_triage_lane_is_loading():
+    state = ViewState()
+    state.questions = _questions()
+    state.triage_state = "loading"
+    state.triage_reason = "warming up"
+    assert state.questions_lines()[0] == "topic  [choice]"
+    assert not any("unavailable" in line for line in state.questions_lines())
+
+
+def test_questions_lines_unavailable_when_missing():
+    state = ViewState()
+    state.triage_state = "loading"
+    state.triage_reason = "model warming up"
+    assert state.questions_lines() == ["questions: unavailable — model warming up"]
+
+
+def test_questions_lines_unavailable_falls_back_to_state_then_generic():
+    state = ViewState()
+    assert state.questions_lines() == ["questions: unavailable — disabled"]
+
+    state.triage_state = ""
+    state.triage_reason = ""
+    assert state.questions_lines() == ["questions: unavailable — no questions loaded"]
+
+
+def test_questions_lines_unavailable_for_empty_or_non_dict_payload():
+    state = ViewState()
+    state.triage_state = "no_questions"
+    state.triage_reason = "no questions loaded from questions.json"
+    for payload in (None, {}, [], "junk", 7):
+        state.ingest({"type": "status", "questions": payload})
+        assert state.questions_lines() == [
+            "questions: unavailable — no questions loaded from questions.json"
+        ]
+
+
+def test_questions_lines_malformed_entries_never_raise():
+    state = ViewState()
+    state.questions = {
+        "good": {"type": "choice", "instructions": "pick", "criteria": {"a": "b"}},
+        "not_a_dict": "boom",
+        "no_instructions": {"type": "noul"},
+        "bad_criteria": {"type": "score", "instructions": "level?", "criteria": {}},
+        "empty_criteria": {"type": "choice", "instructions": "choose", "criteria": []},
+        "no_type": {"instructions": "huh"},
+    }
+    lines = state.questions_lines()
+    assert lines == [
+        "good  [choice]",
+        "  pick",
+        "    a  — b",
+        "no_instructions  [noul]",
+        "bad_criteria  [score]",
+        "  level?",
+        "empty_criteria  [choice]",
+        "  choose",
+        "no_type  [?]",
+        "  huh",
+    ]
+
+
+def test_questions_lines_all_malformed_falls_back_to_unavailable():
+    state = ViewState()
+    state.triage_state = "ok"
+    state.questions = {"broken": "not a dict"}
+    assert state.questions_lines() == ["questions: unavailable — ok"]
+
+
+def test_questions_renderables_plain_matches_lines_with_styled_header():
+    state = ViewState()
+    state.questions = _questions()
+    renderables = state.questions_renderables()
+    assert [item.plain for item in renderables] == state.questions_lines()
+    header = renderables[0]
+    assert header.plain == "topic  [choice]"
+    assert [(header.plain[s.start : s.end], str(s.style)) for s in header.spans] == [
+        ("topic", "bold"),
+        ("[choice]", "dim"),
+    ]
+    assert all(not item.spans for item in renderables[1:4])
+
+
+def test_questions_renderables_unavailable_is_plain_single_line():
+    state = ViewState()
+    state.triage_state = "loading"
+    state.triage_reason = "warming up"
+    renderables = state.questions_renderables()
+    assert [item.plain for item in renderables] == [
+        "questions: unavailable — warming up"
+    ]
+    assert not renderables[0].spans
+
+
+def test_clear_output_keeps_questions():
+    state = ViewState()
+    state.questions = _questions()
+    state.ingest({"type": "triage", "stage": "signals", "triage": _signals_triage()})
+    state.clear_output()
+    assert state.questions == _questions()
+    assert state.questions_epoch == 0
+    assert state.questions_lines()[0] == "topic  [choice]"