diff --git a/kora_cli/web_server.py b/kora_cli/web_server.py index ff2df02d5bed..a4119976bc37 100644 --- a/kora_cli/web_server.py +++ b/kora_cli/web_server.py @@ -6128,6 +6128,155 @@ async def get_cost_telemetry(): return get_telemetry().snapshot() +# --------------------------------------------------------------------------- +# DM phrasebook viewer + tester (KR-FE-PHRASEBOOK-VIEWER) +# --------------------------------------------------------------------------- +# +# Read-only v1 — operator can SEE the phrasebook + test patterns +# interactively. Edit/write path is a follow-on bucket +# (KR-FE-PHRASEBOOK-EDITOR + KR-API-PHRASEBOOK-CRUD). +# +# Why surface this in the cockpit at all: +# * Phrasebook + snapshot interpolation is the cheap-substrate +# thesis applied to DM handling — operator should be able to +# audit which patterns short-circuit AND test which would +# fall through to the reasoning engine given the current +# snapshot (e.g., "cost_ladder.model_default is unknown +# today so the burn-query entry will fall through") +# * Same row shape will be reused by the eventual promotion- +# loop review panel (proposals add phrasebook entries) +# +# Pattern: ALL reads go through dm_phrasebook.load_phrasebook() +# which honors the operator override at +# ${KORA_HOME}/phrasebook/slack_dm.yml — same source-of-truth as +# the live DM handler, so the cockpit can't drift from runtime. + +# Snapshot-placeholder regex (mirrors dm_phrasebook.py:252) so the +# extracted-fields list matches what render_reply will actually +# walk at runtime. Kept in sync via the test-pin in +# tests/kora_cli/test_phrasebook_endpoints.py. +import re as _phrasebook_re + +_PHRASEBOOK_PLACEHOLDER_RE = _phrasebook_re.compile( + r"\{snapshot\.([a-zA-Z0-9_.]+)\}" +) + + +def _extract_phrasebook_snapshot_refs(template: str) -> list: + """Return the sorted, deduped list of snapshot field paths the + template references via ``{snapshot.X.Y.Z}`` placeholders.""" + return sorted(set(_PHRASEBOOK_PLACEHOLDER_RE.findall(template or ""))) + + +def _phrasebook_override_path_or_none(): + """Public-ish accessor for the override path WITHOUT requiring + the file to exist (the private helper inside dm_phrasebook + returns None when the file is absent; for the viewer we want + the candidate path even when it doesn't exist yet — operator + needs to know where to create it).""" + try: + from kora_constants import get_kora_home + + return get_kora_home() / "phrasebook" / "slack_dm.yml" + except Exception: + return None + + +@app.get("/api/phrasebook/slack_dm") +async def get_slack_dm_phrasebook() -> Dict[str, Any]: + """Read-only view of the current Slack DM phrasebook. + + Returns the same entries the live handler would match against + (via dm_phrasebook.load_phrasebook with no override path — + honors ``${KORA_HOME}/phrasebook/slack_dm.yml`` when present; + falls back to bundled default otherwise). + + Each entry's ``referenced_snapshot_fields`` is the list of + snapshot paths the reply_template references — operator can + see at a glance which fields each entry depends on. + """ + from kora_cli.short_circuit import dm_phrasebook + + entries = dm_phrasebook.load_phrasebook() + override_candidate = _phrasebook_override_path_or_none() + override_exists = ( + override_candidate is not None and override_candidate.is_file() + ) + return { + "source": "override" if override_exists else "bundled_default", + "source_path": str(override_candidate) if override_exists else "bundled", + # Echoes the candidate path even when absent so operator + # knows where to drop the YAML to start overriding. + "override_candidate_path": str(override_candidate) + if override_candidate is not None + else None, + "entries": [ + { + "pattern": entry.pattern.pattern, + "category": entry.category, + "description": entry.description, + "reply_template": entry.reply_template, + "referenced_snapshot_fields": ( + _extract_phrasebook_snapshot_refs(entry.reply_template) + ), + } + for entry in entries + ], + } + + +@app.post("/api/phrasebook/slack_dm/test") +async def test_phrasebook_match(payload: Dict[str, Any]) -> Dict[str, Any]: + """Operator-supplied test text → matched entry + rendered reply. + + Read-only. Does NOT call the reasoning engine, does NOT send + DMs, does NOT mutate any state. Pure preview of what the live + handler would do RIGHT NOW for the given text. + + Result shape mirrors the operator's mental model: + * matched=False → would fall through to reasoning (no entry + matched) + * matched=True + rendered_reply present → short-circuit reply + (handler would skip the reasoning engine entirely) + * matched=True + rendered_reply null → matched but the + snapshot is stale / has degraded fields → would still + fall through to reasoning + + The ``would_fall_through_to_reasoning_engine`` boolean is the + single answer the operator usually wants ("does this text + cost me $0 or cents right now?"). + """ + from kora_cli.short_circuit import dm_phrasebook + from kora_cli.snapshot import read_snapshot + + test_text = str(payload.get("text", ""))[:1024] + entries = dm_phrasebook.load_phrasebook() + matched = dm_phrasebook.match_message(test_text, entries) + + if matched is None: + return { + "matched": False, + "would_fall_through_to_reasoning_engine": True, + } + + snap = read_snapshot() + rendered = dm_phrasebook.render_reply(matched, snap) + + return { + "matched": True, + "category": matched.category, + "description": matched.description, + "pattern": matched.pattern.pattern, + "reply_template": matched.reply_template, + "referenced_snapshot_fields": _extract_phrasebook_snapshot_refs( + matched.reply_template + ), + "rendered_reply": rendered, + "would_fall_through_to_reasoning_engine": rendered is None, + "snapshot_present": snap is not None, + } + + # --------------------------------------------------------------------------- # Profile management endpoints (minimal — list/create/rename/delete + SOUL.md) # --------------------------------------------------------------------------- diff --git a/tests/kora_cli/test_phrasebook_endpoints.py b/tests/kora_cli/test_phrasebook_endpoints.py new file mode 100644 index 000000000000..44ef93b800df --- /dev/null +++ b/tests/kora_cli/test_phrasebook_endpoints.py @@ -0,0 +1,426 @@ +"""Backend + source-pin tests for KR-FE-PHRASEBOOK-VIEWER. + +Two new endpoints (GET /api/phrasebook/slack_dm + POST .../test) + +FE wiring. All read-only; no edit path in v1 — that's the +KR-FE-PHRASEBOOK-EDITOR follow-on. + +Scenarios: + GET endpoint: + 1. Returns entries from bundled default when no override exists + 2. Returns from override when override file exists + 3. referenced_snapshot_fields extracted from each entry's + reply_template via the same regex dm_phrasebook uses (so + the FE's per-entry dependency list agrees with what + render_reply will walk at runtime) + 4. _extract_phrasebook_snapshot_refs is sorted + deduped + 5. override_candidate_path echoed even when file is absent + (operator needs to know where to put a YAML) + + POST /test endpoint: + 6. Non-matching text → matched=False + would_fall_through=True + 7. Matching text + fresh snapshot + all-fields-present → + matched=True + rendered_reply populated + + would_fall_through=False + 8. Matching text + missing snapshot → matched=True + + rendered_reply=null + would_fall_through=True + 9. Matching text + snapshot field is "unknown" sentinel → + same fall-through outcome + 10. Oversized text truncated to 1024 (defensive cap) + 11. Result shape SECURITY: the test endpoint must NOT echo + any internal snapshot beyond rendered_reply (no field + dumps that could leak operational state to a caller + without snapshot-read auth) + + FE wiring: + 12. api.getSlackDmPhrasebook + testSlackDmPhrasebook wrappers + 13. PhrasebookEntryDto + PhrasebookResponse + PhrasebookTestResponse + types declared + 14. PhrasebookPage exists + uses usePanelView + route registered + 15. Page renders the 3-state outcome correctly (source-pin) + 16. _PHRASEBOOK_PLACEHOLDER_RE matches dm_phrasebook's regex + exactly (drift guard) +""" + +import re +from pathlib import Path +from typing import Any, Dict + +import pytest + +from tests.kora_cli._panel_test_helpers import isolated_kora_home + + +_REPO_ROOT = Path(__file__).resolve().parents[2] +_API_TS = _REPO_ROOT / "web" / "src" / "lib" / "api.ts" +_APP_TSX = _REPO_ROOT / "web" / "src" / "App.tsx" +_PAGE = _REPO_ROOT / "web" / "src" / "pages" / "PhrasebookPage.tsx" +_DM_PHRASEBOOK_PY = _REPO_ROOT / "kora_cli" / "short_circuit" / "dm_phrasebook.py" + + +# ---- Fixtures ---------------------------------------------- + + +@pytest.fixture +def env(tmp_path, monkeypatch): + return isolated_kora_home(tmp_path, monkeypatch) + + +def _write_override(env_path: Path, yaml_text: str) -> Path: + """Write an operator-override phrasebook so load_phrasebook + picks it up.""" + override = env_path / "phrasebook" / "slack_dm.yml" + override.parent.mkdir(parents=True, exist_ok=True) + override.write_text(yaml_text, encoding="utf-8") + return override + + +# ---- 1-5. GET endpoint -------------------------------------- + + +@pytest.mark.asyncio +async def test_get_returns_bundled_default_when_no_override(env): + from kora_cli import web_server + + result = await web_server.get_slack_dm_phrasebook() + assert result["source"] == "bundled_default" + assert result["source_path"] == "bundled" + assert isinstance(result["entries"], list) + assert len(result["entries"]) > 0 + # The bundled default ships with at least the greeting entry. + categories = {e["category"] for e in result["entries"]} + assert "greeting" in categories + + +@pytest.mark.asyncio +async def test_get_returns_override_when_override_exists(env): + _write_override( + env, + """ +entries: + - pattern: "^test-pattern$" + category: test_cat + description: A test entry + reply_template: "test reply {snapshot.foo.bar}" +""".strip(), + ) + from kora_cli import web_server + + result = await web_server.get_slack_dm_phrasebook() + assert result["source"] == "override" + assert "slack_dm.yml" in result["source_path"] + assert len(result["entries"]) == 1 + e = result["entries"][0] + assert e["category"] == "test_cat" + assert e["pattern"] == "^test-pattern$" + assert e["referenced_snapshot_fields"] == ["foo.bar"] + + +@pytest.mark.asyncio +async def test_get_extracts_referenced_snapshot_fields(env): + _write_override( + env, + """ +entries: + - pattern: "^x$" + category: multi + description: multi-field template + reply_template: "{snapshot.a.b} and {snapshot.c} and {snapshot.a.b}" +""".strip(), + ) + from kora_cli import web_server + + result = await web_server.get_slack_dm_phrasebook() + fields = result["entries"][0]["referenced_snapshot_fields"] + # Sorted + deduped + assert fields == ["a.b", "c"] + + +@pytest.mark.asyncio +async def test_override_candidate_path_echoed_when_absent(env): + from kora_cli import web_server + + result = await web_server.get_slack_dm_phrasebook() + # No override created in this test → source is bundled but the + # candidate path is echoed so the FE can show "create at X" hint + assert result["source"] == "bundled_default" + assert result["override_candidate_path"] is not None + assert result["override_candidate_path"].endswith("phrasebook/slack_dm.yml") + + +# ---- 6-11. POST /test endpoint ------------------------------ + + +@pytest.mark.asyncio +async def test_post_non_matching_text_returns_unmatched(env): + from kora_cli import web_server + + result = await web_server.test_phrasebook_match( + {"text": "completely-unrecognized-message-xyz-12345"} + ) + assert result["matched"] is False + assert result["would_fall_through_to_reasoning_engine"] is True + + +@pytest.mark.asyncio +async def test_post_matching_text_with_no_snapshot_falls_through(env): + """Per dm_phrasebook.py:285-286 (`if snapshot is None: return + None`), missing snapshot is the FIRST fall-through trigger — + universal, regardless of whether the template has placeholders. + A no-placeholder template like "Hey. What's up?" still falls + through when snapshot is None — the handler treats absence + of fresh state as a signal to defer.""" + from kora_cli import web_server + + result = await web_server.test_phrasebook_match({"text": "hey"}) + assert result["matched"] is True + assert result["category"] == "greeting" + assert result["snapshot_present"] is False + # No snapshot → universal fall-through (no rendered reply) + assert result["rendered_reply"] is None + assert result["would_fall_through_to_reasoning_engine"] is True + + +@pytest.mark.asyncio +async def test_post_matching_text_with_fresh_snapshot_renders(env): + """Seed a snapshot so render_reply has fields to interpolate + against. The 'status' query template references + snapshot.operational_state.primary + snapshot.alerts.active_count. + + Path matches state_snapshot._SNAPSHOT_RELATIVE_PATH: + ${KORA_HOME}/cache/daemon_snapshot.json""" + import json + from datetime import datetime, timezone + snap_path = env / "cache" / "daemon_snapshot.json" + snap_path.parent.mkdir(parents=True, exist_ok=True) + snap = { + "schema_version": 2, + "computed_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + "operational_state": { + "primary": "RUNNING", + "paused": False, + "pause_reason": None, + }, + "alerts": { + "active_count": 2, + "by_severity": {"critical": 0, "warning": 1, "info": 1}, + "by_category": {}, + }, + "cost_ladder": { + "current_tier": "normal", + "monthly_budget_pct_used": 23.4, + "model_default": "claude-haiku-4-5", + }, + "service_health": { + "supabase": "healthy", + "fly": "healthy", + "vercel": "healthy", + "sentry": "healthy", + "doppler": "healthy", + }, + } + snap_path.write_text(json.dumps(snap)) + + from kora_cli import web_server + + result = await web_server.test_phrasebook_match({"text": "status"}) + assert result["matched"] is True + assert result["category"] == "status_query" + assert result["rendered_reply"] is not None + assert "RUNNING" in result["rendered_reply"] + assert "2" in result["rendered_reply"] + assert result["would_fall_through_to_reasoning_engine"] is False + + +@pytest.mark.asyncio +async def test_post_matching_text_with_unknown_field_falls_through(env): + """Field literally == 'unknown' is the PR #157 degraded + sentinel; render_reply returns None → would fall through.""" + import json + from datetime import datetime, timezone + snap_path = env / "cache" / "daemon_snapshot.json" + snap_path.parent.mkdir(parents=True, exist_ok=True) + snap = { + "schema_version": 2, + "computed_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + "cost_ladder": { + "current_tier": "unknown", # degraded sentinel + "monthly_budget_pct_used": "unknown", + "model_default": "unknown", + }, + } + snap_path.write_text(json.dumps(snap)) + + from kora_cli import web_server + + result = await web_server.test_phrasebook_match({"text": "burn"}) + assert result["matched"] is True + assert result["category"] == "burn_query" + # Template references cost_ladder fields that are "unknown" → + # rendered_reply should be None + assert result["rendered_reply"] is None + assert result["would_fall_through_to_reasoning_engine"] is True + + +@pytest.mark.asyncio +async def test_post_oversized_text_truncated_to_1024(env): + """Defensive cap matches the spec — the operator can't blow up + the server by submitting an 8MB string.""" + from kora_cli import web_server + + long_text = "a" * 100_000 # 100k chars + # Doesn't matter that it doesn't match; we just want the + # endpoint to return cleanly (no crash on the long input). + result = await web_server.test_phrasebook_match({"text": long_text}) + assert isinstance(result, dict) + + +@pytest.mark.asyncio +async def test_post_does_not_echo_full_snapshot(env): + """SECURITY: the test response shape must NOT include a dump + of the snapshot or any internal state beyond the + rendered_reply text. Operator could call this endpoint over a + Kora-MCP-tool surface in future; we never want to accidentally + expose snapshot internals via a 'preview' endpoint.""" + from kora_cli import web_server + + # Seed a snapshot with a sentinel string the response must not contain. + import json + from datetime import datetime, timezone + snap_path = env / "cache" / "daemon_snapshot.json" + snap_path.parent.mkdir(parents=True, exist_ok=True) + sentinel = "SECRET_DAEMON_INTERNAL_SHOULD_NEVER_LEAK" + snap = { + "schema_version": 2, + "computed_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + "operational_state": { + "primary": sentinel, + "paused": False, + "pause_reason": None, + }, + "alerts": { + "active_count": 0, + "by_severity": {"critical": 0, "warning": 0, "info": 0}, + "by_category": {}, + }, + } + snap_path.write_text(json.dumps(snap)) + + from kora_cli import web_server + + # Use a non-matching text so we don't surface the sentinel + # via rendered_reply (matched=True with a status query would + # render the sentinel into the reply text, which is by design — + # the operator EXPLICITLY asked to preview that template). + result = await web_server.test_phrasebook_match( + {"text": "completely-unrecognized-text-xyz"} + ) + # No matched → response is just {matched: False, would_fall_through: True} + # — no snapshot data at all. + blob = json.dumps(result) + assert sentinel not in blob, ( + "Non-matching test response must NOT carry any snapshot " + "contents (operator didn't request a render)" + ) + + +# ---- 12-14. FE wiring --------------------------------------- + + +def test_api_wrappers_exist(): + src = _API_TS.read_text() + assert re.search( + r"getSlackDmPhrasebook:\s*\(\)\s*=>\s*fetchJSON\(\"/api/phrasebook/slack_dm\"\)", + src, + ) + assert re.search( + r"testSlackDmPhrasebook:\s*\(text:\s*string\)\s*=>", + src, + ) + assert '"/api/phrasebook/slack_dm/test"' in src + assert 'method: "POST"' in src + + +def test_phrasebook_types_declared(): + src = _API_TS.read_text() + for ty in ( + "export interface PhrasebookEntryDto", + "export interface PhrasebookResponse", + "export type PhrasebookTestResponse", + ): + assert ty in src + # PhrasebookEntryDto carries the per-entry snapshot-field deps + assert "referenced_snapshot_fields" in src + + +def test_page_exists_and_registers_route(): + assert _PAGE.is_file() + app_src = _APP_TSX.read_text() + assert '"/phrasebook": PhrasebookPage' in app_src + # Nav entry + assert re.search( + r'path:\s*"/phrasebook"[^}]+labelKey:\s*"phrasebook"', + app_src, + re.DOTALL, + ) + + +def test_page_uses_panel_view_hook(): + src = _PAGE.read_text() + assert 'usePanelView("PhrasebookPage")' in src + + +# ---- 15. Page renders 3-state outcome --------------------- + + +def test_page_renders_three_tester_outcomes(): + """The TesterResult component must visibly differentiate all 3 + states (unmatched / matched+rendered / matched+null-render) + that the spec calls out for screenshots.""" + src = _PAGE.read_text() + # Unmatched state copy + assert "No phrasebook entry matched" in src + # Matched + $0 reply + assert "Matched · $0 reply" in src + # Matched but would fall through + assert "Matched but would fall through" in src + + +def test_page_marks_degraded_snapshot_fields_per_entry(): + """Per-row affordance: when a referenced field is currently + 'unknown' in the live snapshot, the entry's badge for that + field renders 'warning' tone so operator can see at a glance + which entries would fall through.""" + src = _PAGE.read_text() + assert "isDegradedSnapshotValue" in src + assert re.search( + r'value\s*===\s*"unknown"', + src, + ), "FE must treat 'unknown' as the degraded sentinel (matches dm_phrasebook.py:293)" + + +# ---- 16. Placeholder regex drift guard -------------------- + + +def test_placeholder_regex_matches_dm_phrasebook_source(): + """SECURITY-of-correctness: the GET endpoint's + _PHRASEBOOK_PLACEHOLDER_RE must match dm_phrasebook.py's + _PLACEHOLDER_RE exactly. Otherwise FE's per-entry + referenced_snapshot_fields list will drift from what + render_reply actually walks at runtime — operator's "this + will fall through" affordance becomes a lie.""" + backend_src = _DM_PHRASEBOOK_PY.read_text() + backend_re = re.search( + r'_PLACEHOLDER_RE\s*=\s*re\.compile\(r"([^"]+)"\)', + backend_src, + ) + assert backend_re, "dm_phrasebook._PLACEHOLDER_RE not found" + + from kora_cli.web_server import _PHRASEBOOK_PLACEHOLDER_RE + + assert _PHRASEBOOK_PLACEHOLDER_RE.pattern == backend_re.group(1), ( + f"Endpoint's _PHRASEBOOK_PLACEHOLDER_RE drifted from " + f"dm_phrasebook's _PLACEHOLDER_RE — referenced-fields " + f"extraction will diverge from runtime walk:\n" + f"endpoint: {_PHRASEBOOK_PLACEHOLDER_RE.pattern!r}\n" + f"runtime: {backend_re.group(1)!r}" + ) diff --git a/web/docs/phrasebook-viewer/preview.html b/web/docs/phrasebook-viewer/preview.html new file mode 100644 index 000000000000..f02c836f99ee --- /dev/null +++ b/web/docs/phrasebook-viewer/preview.html @@ -0,0 +1,152 @@ + + + + + + Kora — PhrasebookPage tester states (KR-FE-PHRASEBOOK-VIEWER) + + + +

KR-FE-PHRASEBOOK-VIEWER — Live tester (3 states)

+

+ Read-only viewer + live regex tester for the Slack DM phrasebook. + Operator types a sample DM → preview of what the live handler would + do for that text against the current snapshot. Three outcome states. +

+ +

1. Matched · $0 reply (snapshot present, all fields populated)

+
+
+ + Live tester +
+

+ Type a sample DM. Read-only preview of what the live handler would do + for this text against the current snapshot. +

+
+ + +
+
+
+ +
+
+ Matched · $0 reply + status_query +
+
+ General "are you alive / how are things" +
+
+
+
Pattern^(status|state|how ?are (you|things)|how'?s? it going|whats up|what'?s? up)[\s.?!]*$
+
Reply templateOperational state: {snapshot.operational_state.primary}. {snapshot.alerts.active_count} active alert(s).
+
Referenced fields
operational_state.primaryalerts.active_count
+
Rendered reply
Operational state: RUNNING. 2 active alert(s).
+
+
+ +

2. No match → reasoning fallback (operator's text isn't in the phrasebook)

+
+
+ + Live tester +
+
+ + +
+
+
+ +
+
No phrasebook entry matched
+
+ Would fall through to the reasoning engine → costs cents. +
+
+
+
+
+ +

3. Matched but would fall through (referenced field is "unknown" in current snapshot)

+
+
+ + Live tester +
+
+ + +
+
+
+ +
+
+ Matched but would fall through + burn_query +
+
+ Single-clause cost / burn inquiry +
+
+
+
Pattern^(burn|cost|spend|how ?much|what'?s? my burn|what'?s? the burn|whats the spend)[\s.?!]*$
+
Reply template{snapshot.cost_ladder.monthly_budget_pct_used}% of monthly budget used. Tier: {snapshot.cost_ladder.current_tier}.
+
Referenced fields
cost_ladder.monthly_budget_pct_usedcost_ladder.current_tier
+
Rendered replynull (snapshot stale OR field "unknown" → fall through to reasoning)
+
+
+ + diff --git a/web/docs/phrasebook-viewer/states.png b/web/docs/phrasebook-viewer/states.png new file mode 100644 index 000000000000..f9b338bc4a1a Binary files /dev/null and b/web/docs/phrasebook-viewer/states.png differ diff --git a/web/src/App.tsx b/web/src/App.tsx index 25f0c7ce51fb..ad3791133a17 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -99,6 +99,7 @@ import BootStatusPage from "@/pages/BootStatusPage"; import DRStatePage from "@/pages/DRStatePage"; import CostStatePage from "@/pages/CostStatePage"; import CostTelemetryPage from "@/pages/CostTelemetryPage"; +import PhrasebookPage from "@/pages/PhrasebookPage"; import CapabilitiesPage from "@/pages/CapabilitiesPage"; import CharterPage from "@/pages/CharterPage"; import KoraControlPage from "@/pages/KoraControlPage"; @@ -161,6 +162,7 @@ const BUILTIN_ROUTES_CORE: Record = { "/dr-state": DRStatePage, "/cost-state": CostStatePage, "/cost-telemetry": CostTelemetryPage, + "/phrasebook": PhrasebookPage, "/capabilities": CapabilitiesPage, "/charter": CharterPage, "/kora-control": KoraControlPage, @@ -295,6 +297,18 @@ const BUILTIN_NAV_REST: NavItem[] = [ label: "Cost Telemetry", icon: BarChart3, }, + { + // KR-FE-PHRASEBOOK-VIEWER: read-only viewer + live tester for + // the Slack DM short-circuit phrasebook. Adjacent to Cost + // Telemetry in the sidebar since both surface the cheap- + // substrate thesis (phrasebook hits are the $0 reply path + // that show up as model_used="short_circuit" in cost + // telemetry's model breakdown). + path: "/phrasebook", + labelKey: "phrasebook", + label: "Phrasebook", + icon: BookOpen, + }, { path: "/capabilities", labelKey: "capabilities", diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 0cfe794aa7fe..51eee67ebab4 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -145,6 +145,15 @@ export const api = { // two windows (snapshot covers rolling_24h + monthly only). getCostTelemetry: () => fetchJSON("/api/cost_telemetry"), + // KR-FE-PHRASEBOOK-VIEWER: read-only phrasebook + live regex tester. + getSlackDmPhrasebook: () => + fetchJSON("/api/phrasebook/slack_dm"), + testSlackDmPhrasebook: (text: string) => + fetchJSON("/api/phrasebook/slack_dm/test", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ text }), + }), getSessions: (limit = 20, offset = 0) => fetchJSON(`/api/sessions?limit=${limit}&offset=${offset}`), getSessionMessages: (id: string) => @@ -1867,3 +1876,46 @@ export interface CostTelemetryResponse { rolling_24h: Record; monthly: Record; } + +// DM phrasebook (KR-FE-PHRASEBOOK-VIEWER). Source-of-truth shape +// mirrors kora_cli/short_circuit/dm_phrasebook.py PhrasebookEntry. +// The endpoint adds referenced_snapshot_fields so the FE can +// visualize per-entry snapshot-field dependencies without +// re-parsing reply_template client-side. +export interface PhrasebookEntryDto { + pattern: string; // source regex string (Python re.IGNORECASE) + category: string; + description: string; + reply_template: string; + referenced_snapshot_fields: string[]; +} + +export interface PhrasebookResponse { + source: "override" | "bundled_default"; + source_path: string; + // Echoed even when absent so operator knows where to put a YAML + // to start overriding. null when KORA_HOME isn't resolvable. + override_candidate_path: string | null; + entries: PhrasebookEntryDto[]; +} + +export type PhrasebookTestResponse = + | { + matched: false; + would_fall_through_to_reasoning_engine: true; + } + | { + matched: true; + category: string; + description: string; + pattern: string; + reply_template: string; + referenced_snapshot_fields: string[]; + // null when snapshot is missing/stale OR any referenced + // field is "unknown" — in both cases, would_fall_through is + // true and the live DM handler would defer to the + // reasoning engine. + rendered_reply: string | null; + would_fall_through_to_reasoning_engine: boolean; + snapshot_present: boolean; + }; diff --git a/web/src/pages/PhrasebookPage.tsx b/web/src/pages/PhrasebookPage.tsx new file mode 100644 index 000000000000..9b4d0b74e0ca --- /dev/null +++ b/web/src/pages/PhrasebookPage.tsx @@ -0,0 +1,515 @@ +// DM phrasebook viewer + live tester — KR-FE-PHRASEBOOK-VIEWER. +// +// Read-only v1. Surfaces the bundled-or-overridden phrasebook the +// live Slack DM handler consults before invoking the reasoning +// engine, plus a tester input that previews what the handler would +// do for an operator-supplied sample message AGAINST THE CURRENT +// SNAPSHOT. +// +// The would_fall_through_to_reasoning_engine signal is the headline +// answer for each tester call — operator wants to know "is this +// going to short-circuit ($0) or go to the engine (cents)?" The +// fall-through happens in three cases (per dm_phrasebook.py:269-307): +// 1. No entry matched (matched=false) +// 2. Snapshot is null/stale +// 3. Any referenced snapshot field is "unknown" or null +// +// Write/edit path is a future bucket — KR-FE-PHRASEBOOK-EDITOR + +// KR-API-PHRASEBOOK-CRUD. + +import { useCallback, useEffect, useState } from "react"; +import { + AlertTriangle, + ArrowDownToLine, + Bot, + CheckCircle2, + FileEdit, + Info, + RefreshCw, + XCircle, +} from "lucide-react"; +import { Badge } from "@nous-research/ui/ui/components/badge"; +import { Button } from "@nous-research/ui/ui/components/button"; +import { Spinner } from "@nous-research/ui/ui/components/spinner"; +import { H2 } from "@/components/NouiTypography"; +import { Card, CardContent } from "@/components/ui/card"; +import { Toast } from "@/components/Toast"; +import { useToast } from "@/hooks/useToast"; +import { usePanelView } from "@/hooks/usePanelView"; +import { api } from "@/lib/api"; +import type { + PhrasebookEntryDto, + PhrasebookResponse, + PhrasebookTestResponse, + SnapshotResponse, + SnapshotUnavailable, +} from "@/lib/api"; + +// Walk a dotted path through nested dicts. Mirrors +// dm_phrasebook._walk_snapshot so the FE's "is this field unknown?" +// affordance agrees with what render_reply would see at runtime. +function walkSnapshotField( + snapshot: SnapshotResponse | null, + dotted: string, +): unknown { + if (snapshot === null) return undefined; + // The placeholder paths in dm_phrasebook templates are written + // like "{snapshot.cost_ladder.current_tier}" — the endpoint's + // extractor strips the "snapshot." prefix so we walk against + // the snapshot dict directly. + let cur: unknown = snapshot; + for (const seg of dotted.split(".")) { + if (cur === null || typeof cur !== "object" || Array.isArray(cur)) { + return undefined; + } + cur = (cur as Record)[seg]; + if (cur === undefined) return undefined; + } + return cur; +} + +// "unknown" sentinel match — dm_phrasebook.render_reply treats the +// literal string "unknown" as degraded (alongside null/missing). +function isDegradedSnapshotValue(value: unknown): boolean { + return value === undefined || value === null || value === "unknown"; +} + +interface EntryRowProps { + entry: PhrasebookEntryDto; + snapshot: SnapshotResponse | null; +} + +function EntryRow({ entry, snapshot }: EntryRowProps) { + // Identify which of this entry's referenced fields are currently + // "unknown" / missing / null in the live snapshot. Drives the + // "would fall through" badge — operator sees per-entry whether + // the short-circuit path is viable right now. + const degradedFields = entry.referenced_snapshot_fields.filter((path) => + isDegradedSnapshotValue(walkSnapshotField(snapshot, path)), + ); + const willFallThrough = + snapshot === null || degradedFields.length > 0; + return ( + + + + {entry.category} + + + + + {entry.pattern} + + {entry.description && ( +
+ {entry.description} +
+ )} + + + + {entry.reply_template} + + {entry.referenced_snapshot_fields.length > 0 && ( +
+ {entry.referenced_snapshot_fields.map((path) => { + const degraded = degradedFields.includes(path); + return ( + + {path} + + ); + })} +
+ )} + + + {willFallThrough ? ( + + + → reasoning + + ) : ( + + + $0 reply + + )} + + + ); +} + +interface LiveTesterProps { + onTest: (text: string) => Promise; + result: PhrasebookTestResponse | null; + testing: boolean; + testError: string | null; +} + +function LiveTester({ onTest, result, testing, testError }: LiveTesterProps) { + const [text, setText] = useState("hey"); + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!text.trim()) return; + void onTest(text); + } + return ( + + +
+ + Live tester +
+

+ Type a sample DM. Read-only preview of what the live handler + would do for this text against the current snapshot — does + NOT call the reasoning engine, does NOT send DMs. +

+
+ setText(e.target.value)} + placeholder="hey / what's my burn? / any alerts?" + className="flex-1 px-3 py-1.5 text-sm bg-background border border-border rounded-md font-mono" + maxLength={1024} + /> + +
+ + {testError && ( +
+ + {testError} +
+ )} + + {result && !testError && } +
+
+ ); +} + +function TesterResult({ result }: { result: PhrasebookTestResponse }) { + // Three outcome shapes per spec § "3 tester states": + // * Unmatched → reasoning fallback + // * Matched + rendered → short-circuit $0 reply + // * Matched + null render → would-fall-through-because-field-unknown + if (!result.matched) { + return ( +
+ +
+
No phrasebook entry matched
+
+ Would fall through to the reasoning engine → costs cents. +
+
+
+ ); + } + + const isFallThrough = result.would_fall_through_to_reasoning_engine; + return ( +
+
+ {isFallThrough ? ( + + ) : ( + + )} +
+
+ {isFallThrough ? "Matched but would fall through" : "Matched · $0 reply"} + + {result.category} + +
+ {result.description && ( +
+ {result.description} +
+ )} +
+
+ +
+
+ Pattern + + {result.pattern} + +
+
+ + Reply template + + + {result.reply_template} + +
+ {result.referenced_snapshot_fields.length > 0 && ( +
+ + Referenced fields + +
+ {result.referenced_snapshot_fields.map((path) => ( + + {path} + + ))} +
+
+ )} +
+ + Rendered reply + + {result.rendered_reply !== null ? ( +
+ {result.rendered_reply} +
+ ) : ( + + null (snapshot stale OR field "unknown" → fall through to + reasoning) + + )} +
+ {!result.snapshot_present && ( +
+ + + No snapshot available — every matched entry would fall + through to the reasoning engine until the next snapshot + refresh. + +
+ )} +
+
+ ); +} + +export default function PhrasebookPage() { + usePanelView("PhrasebookPage"); + + const [phrasebook, setPhrasebook] = useState( + null, + ); + const [snapshot, setSnapshot] = useState(null); + const [loadError, setLoadError] = useState(null); + const [refreshing, setRefreshing] = useState(false); + + const [testResult, setTestResult] = useState( + null, + ); + const [testing, setTesting] = useState(false); + const [testError, setTestError] = useState(null); + + const { toast, showToast } = useToast(); + + const loadAll = useCallback( + async (isManual: boolean) => { + if (isManual) setRefreshing(true); + setLoadError(null); + try { + const [pb, snap] = await Promise.all([ + api.getSlackDmPhrasebook(), + api.getSnapshot(), + ]); + setPhrasebook(pb); + // Snapshot may be unavailable — that's a valid render state + // (the per-row "would fall through" affordance treats null + // snapshot as universal fall-through). + setSnapshot( + snap !== null && !("error" in (snap as SnapshotUnavailable)) + ? (snap as SnapshotResponse) + : null, + ); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + setLoadError(msg); + if (isManual) showToast(`Failed to load: ${msg}`, "error"); + } finally { + if (isManual) setRefreshing(false); + } + }, + [showToast], + ); + + const handleTest = useCallback(async (text: string) => { + setTesting(true); + setTestError(null); + try { + const result = await api.testSlackDmPhrasebook(text); + setTestResult(result); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + setTestError(msg); + setTestResult(null); + } finally { + setTesting(false); + } + }, []); + + useEffect(() => { + void loadAll(false); + }, [loadAll]); + + return ( +
+ + +
+
+

Phrasebook (Slack DM short-circuit)

+

+ Read-only view of the regex patterns the live Slack DM + handler consults before invoking the reasoning engine. + Matched + renderable → $0 reply; otherwise falls through + to reasoning (cents). +

+
+ +
+ + {loadError && ( + + + +
+
Failed to load phrasebook
+
{loadError}
+
+
+
+ )} + + {phrasebook === null && !loadError && ( +
+ +
+ )} + + {phrasebook !== null && ( + <> + {/* Source banner — operator needs to know whether they're + looking at the bundled default or their override. */} + + + Source: + {phrasebook.source === "override" ? ( + + + operator override + + ) : ( + bundled default + )} + + {phrasebook.source_path} + + {phrasebook.source === "bundled_default" && + phrasebook.override_candidate_path && ( + + Override path:{" "} + + {phrasebook.override_candidate_path} + {" "} + (create to override) + + )} + + + + {/* Live tester */} + + + {/* Entries table */} + + +
+ All entries ({phrasebook.entries.length}) +
+
+ + + + + + + + + + + {phrasebook.entries.map((entry, i) => ( + + ))} + +
+ Category + + Pattern + + Reply template + referenced snapshot fields + + Current viability +
+
+
+ "Current viability" is computed against the live snapshot: + if any referenced field is currently "unknown" or missing, + the live handler would fall through to the reasoning + engine for that entry. Editor follow-on: + KR-FE-PHRASEBOOK-EDITOR + KR-API-PHRASEBOOK-CRUD. +
+
+
+ + )} +
+ ); +}