Skip to content
This repository was archived by the owner on May 26, 2026. It is now read-only.

feat(kora): KR-FE-PHRASEBOOK-VIEWER — read + test-render phrasebook in cockpit#167

Merged
rafe-walker merged 1 commit into
feature/phase2-upgradesfrom
feat/kora-KR-FE-PHRASEBOOK-VIEWER
May 24, 2026
Merged

feat(kora): KR-FE-PHRASEBOOK-VIEWER — read + test-render phrasebook in cockpit#167
rafe-walker merged 1 commit into
feature/phase2-upgradesfrom
feat/kora-KR-FE-PHRASEBOOK-VIEWER

Conversation

@rafe-walker

Copy link
Copy Markdown
Owner

Summary

Read-only viewer + live regex tester for the Slack DM short-circuit phrasebook (PR #160). Operator can SEE which patterns the live handler consults before invoking the reasoning engine AND test what would happen for an operator-supplied sample message against the current snapshot.

Read-only v1. Write/edit path is a follow-on bucket (KR-FE-PHRASEBOOK-EDITOR + KR-API-PHRASEBOOK-CRUD).

Live tester — 3 outcome states

PhrasebookPage tester — 3 states

  1. Matched · $0 reply (success-toned card) — snapshot present + all referenced fields populated → handler would short-circuit to the rendered reply, $0 cost
  2. No phrasebook entry matched (warning-toned card) — text doesn't match any pattern → handler would fall through to the reasoning engine (cents)
  3. Matched but would fall through (warning-toned card) — pattern matched, but a referenced field is "unknown" / missing → handler would still defer to reasoning. Operator sees which fields are degraded.

Committed at web/docs/phrasebook-viewer/states.png + reproducible HTML at preview.html.

What's in here

Backend (2 new read-only endpoints):

  • GET /api/phrasebook/slack_dm — entries from override-or-bundled, with per-entry referenced_snapshot_fields extracted via the same regex render_reply walks at runtime. Echoes override_candidate_path even when absent so operator knows where to drop YAML.
  • POST /api/phrasebook/slack_dm/test — matched entry + rendered reply for operator-supplied text. Pure preview; doesn't call the reasoning engine, doesn't send DMs, doesn't mutate state.

Frontend:

  • web/src/pages/PhrasebookPage.tsx — source banner, live tester, entries table with per-row "current viability" affordance (warning-toned badges for fields currently "unknown" in the live snapshot)
  • api.getSlackDmPhrasebook() + api.testSlackDmPhrasebook(text) wrappers + PhrasebookEntryDto / PhrasebookResponse / PhrasebookTestResponse types
  • /phrasebook route + sidebar nav entry (BookOpen icon; placed next to /cost-telemetry since both surface the cheap-substrate thesis — phrasebook hits show up as model_used="short_circuit" in cost telemetry's model breakdown)
  • usePanelView("PhrasebookPage") per feat(kora): KR-PANEL-USE-INSTRUMENTATION — panel-view event emit (data-driven cut prerequisite) #159 discipline

SECURITY notes

  • Test endpoint does NOT echo snapshot contents for unmatched calls. Operator could conceivably call this over a future MCP-tool surface; the endpoint must not become an inadvertent snapshot-readback path for callers without proper auth. Pinned by test_post_does_not_echo_full_snapshot.
  • Placeholder regex drift guard: _PHRASEBOOK_PLACEHOLDER_RE (endpoint side) must match dm_phrasebook._PLACEHOLDER_RE exactly. Otherwise the FE's per-entry "referenced fields" list drifts from what render_reply actually walks at runtime and the "this will fall through" affordance becomes a lie. Pinned by test_placeholder_regex_matches_dm_phrasebook_source.
  • Read-only contract: no edit/write code in v1. The viewer + tester are pure reads; mutations come in the future bucket.

Test plan

  • tests/kora_cli/test_phrasebook_endpoints.py17 tests covering both endpoints + FE source-pins:
    • GET returns bundled default / returns override / extracts referenced fields (sorted+deduped) / echoes override_candidate_path when absent
    • POST: unmatched, matching+no-snapshot, matching+fresh-snapshot, matching+"unknown" sentinel, oversized text cap, SECURITY no-snapshot-echo pin, placeholder regex drift guard
    • FE wiring: api wrappers + types + page + route + usePanelView + per-row degraded-snapshot affordance + 3-state render copy
  • pnpm tsc -b clean
  • pnpm build clean
  • Manual smoke: open /phrasebook against live daemon → entries render, tester input → POST returns + result panel matches one of the 3 states.

STOP-ASK resolutions (none triggered)

  • ✅ No existing /api/phrasebook/* endpoints — clean addition
  • ✅ Override path resolution: used a NEW public-ish accessor (_phrasebook_override_path_or_none) in the endpoint that returns the candidate path EVEN WHEN ABSENT (the private _operator_override_path returns None when file is missing; for the viewer we want to surface "where to put the YAML" even pre-override). Mirrors dm_phrasebook resolution otherwise.
  • KORA_HOME plumbing works via the established kora_constants.get_kora_home pattern (no env divergence)

Refs

🤖 Generated with Claude Code

…n cockpit

Read-only viewer + live regex tester for the Slack DM short-
circuit phrasebook (PR #160). Operator can SEE which patterns the
live handler consults before invoking the reasoning engine AND
test what would happen for an operator-supplied sample message
against the current snapshot — does NOT call the reasoning
engine, does NOT send DMs.

Write/edit path is a follow-on bucket (KR-FE-PHRASEBOOK-EDITOR +
KR-API-PHRASEBOOK-CRUD).

Backend (kora_cli/web_server.py)
=================================

Two new read-only endpoints:

  * GET /api/phrasebook/slack_dm
    Returns the same entries the live handler matches against
    (via dm_phrasebook.load_phrasebook — honors operator
    override at ${KORA_HOME}/phrasebook/slack_dm.yml, falls
    back to bundled default). Each entry's
    referenced_snapshot_fields is the list of snapshot paths
    its reply_template references — operator sees per-entry
    snapshot dependencies without re-parsing client-side.
    Also echoes override_candidate_path even when absent so
    operator knows where to drop the YAML to start overriding.

  * POST /api/phrasebook/slack_dm/test
    Operator-supplied text → matched entry + rendered reply.
    Pure preview of what the live handler would do RIGHT NOW.
    The would_fall_through_to_reasoning_engine boolean is the
    single answer the operator usually wants ("is this $0 or
    cents right now?").

Helper _extract_phrasebook_snapshot_refs(template) → sorted +
deduped list of paths from {snapshot.X.Y} placeholders. The
regex (_PHRASEBOOK_PLACEHOLDER_RE) is pinned by
test_placeholder_regex_matches_dm_phrasebook_source to be
identical to dm_phrasebook._PLACEHOLDER_RE so the FE's "this will
fall through" affordance agrees with what render_reply walks at
runtime.

Frontend
=========

  * web/src/pages/PhrasebookPage.tsx — new top-level page
  * api.getSlackDmPhrasebook() + api.testSlackDmPhrasebook(text)
    wrappers + PhrasebookEntryDto / PhrasebookResponse /
    PhrasebookTestResponse TS types
  * /phrasebook route + sidebar nav entry (BookOpen icon;
    placed next to /cost-telemetry since both surface the
    cheap-substrate thesis — phrasebook hits are the $0
    short_circuit path that show up in cost-telemetry's model
    breakdown)
  * usePanelView("PhrasebookPage") for usage instrumentation

Page layout:
  * Source banner — operator override vs bundled default;
    candidate override path echoed when absent so operator
    knows where to create
  * Live tester — text input + Test button; result panel
    visually differentiates the 3 outcome states (per spec
    "screenshots of matched / unmatched / would-fall-through")
  * Entries table — pattern / category / description / reply
    template + per-row "current viability" badge.
    Referenced snapshot fields render as outline badges
    by default; warning-toned when the live snapshot value
    is "unknown" / missing / null so operator sees per-entry
    which entries would fall through right now

Per-row "current viability" check mirrors render_reply's fall-
through triggers (dm_phrasebook.py:269-307):
  * snapshot is null → universal fall-through
  * any referenced field value is undefined / null / "unknown"
    → entry-specific fall-through

Tests (tests/kora_cli/test_phrasebook_endpoints.py — 17 tests)
================================================================

Backend:
  * GET returns bundled default when no override; returns
    override when present; referenced_snapshot_fields extracted
    (sorted + deduped); override_candidate_path echoed even
    when absent
  * POST: non-matching text → unmatched + fall-through;
    matching text with no snapshot → matched but fall-through
    (per dm_phrasebook semantics — snapshot=None is universal
    fall-through trigger, even for no-placeholder templates);
    matching text with fresh snapshot + all fields → rendered
    reply populated; matching text with "unknown" sentinel →
    fall-through; oversized text truncated to 1024 chars
    defensively
  * SECURITY: non-matching test response does NOT echo snapshot
    contents (operator didn't request a render; endpoint must
    not become an inadvertent snapshot-readback surface for
    callers without proper auth)
  * Placeholder regex drift guard — _PHRASEBOOK_PLACEHOLDER_RE
    must match dm_phrasebook._PLACEHOLDER_RE exactly so
    referenced-fields extraction agrees with runtime walk

Frontend source-pin:
  * api wrappers exist with correct shape (GET + POST signatures)
  * Phrasebook types declared (Dto / Response / TestResponse union)
  * Page exists, uses usePanelView, route registered, nav entry
  * Page renders all 3 tester outcome states
    ("No phrasebook entry matched" / "Matched · $0 reply" /
    "Matched but would fall through")
  * isDegradedSnapshotValue matches the "unknown" sentinel from
    dm_phrasebook.py:293

Verification:
  * 17/17 tests pass
  * tsc -b clean
  * vite build clean
  * Screenshot rendered: web/docs/phrasebook-viewer/states.png
    (all 3 tester states + preview HTML for reproducibility)

Refs:
  * rafe-walker/kora-docs 17_cc_bucket_prompts/KR-FE-PHRASEBOOK-VIEWER_read_test_render.md
  * PR #160 — phrasebook + dm_phrasebook module (data source)
  * PR #157 — snapshot infrastructure (degraded "unknown"
    sentinel + read_snapshot)
  * PR #159 — usePanelView instrumentation pattern
  * PR #162 — api.getSnapshot wrapper (re-used for per-row
    viability check)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@rafe-walker rafe-walker merged commit c011ceb into feature/phase2-upgrades May 24, 2026
@rafe-walker rafe-walker deleted the feat/kora-KR-FE-PHRASEBOOK-VIEWER branch May 24, 2026 02:41
rafe-walker pushed a commit that referenced this pull request May 24, 2026
…tion + revert

Build-list follow-on to PR #167 (phrasebook read-only viewer).
Adds the operator-edit story so the phrasebook can be modified
from the cockpit instead of YAML by hand. Also sets the UX
foundation that the eventual promotion-review panel will reuse
(KR-PROMOTE-PHRASEBOOK proposals are pending edits to this same
surface).

Backend (kora_cli/short_circuit/phrasebook_editor.py — new)
==========================================================

Module hosts everything the read-only viewer didn't need:

  * validate_entries(entries) → [EntryValidationError, ...]
    8 checks (in order; later skipped for an entry that failed
    earlier to avoid noise): payload is list, length cap (200
    entries), required-fields-non-empty, length caps on each
    field, regex compiles with re.IGNORECASE, catastrophic-
    backtracking guard (catches (a+)+, (.*)*, etc), snapshot
    placeholder paths resolve to known scalars, no duplicate
    (pattern, category) tuples.

  * SNAPSHOT_SCALAR_PATHS — static frozen-set of known snapshot
    v4 scalar paths the operator can reference in
    {snapshot.X.Y} placeholders. Drift-guarded by
    test_static_schema_matches_snapshot_collectors which greps
    each leaf key against the snapshot collector source. Only
    scalars (no dynamic-key dicts like listeners.X or
    cost_telemetry.X — those str() to Python repr and would
    render garbage in operator-facing DMs).

  * write_backup_for / rotate_backups / list_backups —
    timestamped backups under ${KORA_HOME}/phrasebook/backups/.
    KORA_PHRASEBOOK_BACKUP_COUNT env var tunes rotation
    (default 10; clamped to [1, 1000]). Filenames sort
    chronologically as plain strings (ISO-Z format).

  * write_phrasebook(entries) — atomic write via
    utils.atomic_replace (same pattern as snapshot writer).
    Deterministic field order for readable YAML diffs.

  * revert_phrasebook(filename=...) — revert to a specific
    backup OR (no filename) the most-recent OR (no backups)
    remove the override entirely. Path-traversal defense
    rejects any filename containing /, \, .., or not matching
    the slack_dm.*.yml shape.

Backend (kora_cli/web_server.py — 3 new endpoints)
==================================================

  PUT  /api/phrasebook/slack_dm           — validate + back up +
                                            atomic-write + audit
  POST /api/phrasebook/slack_dm/revert    — revert to backup
  GET  /api/phrasebook/slack_dm/backups   — list backups

PUT contract:
  * Validation fails → 422 with structured per-entry errors;
    NO write; previous override preserved.
  * Validation passes → backup current (if exists) → atomic
    write → rotate backups → audit row → 200 with echoed
    entries + backup_filename + rotated_count.
  * Write itself fails → 500; backup preserved so operator
    can recover.

Audit seam (new): phrasebook.updated
====================================

Extended SeamName Literal in kora_cli/audit/jsonl_sink.py.
Each successful PUT (or revert) emits one entry with:
  * actor          — "operator" (cockpit-driven; future
                      "kora_proposal_approved" from the
                      promotion-loop bucket reuses this shape)
  * action         — "put" | "revert"
  * entry_count_before / entry_count_after
  * backup_filename (when applicable)
  * rotated_backup_count (when applicable)
  * reverted_to (when action=revert)

Drives the future KR-PROMOTION-REVIEW-PANEL via the existing
audit-panel infrastructure (KR-AUDIT-PANEL-ENDPOINTS PR #155).

Frontend
========

  * web/src/pages/PhrasebookEditor.tsx (new) — hosts the
    editor sub-components so PhrasebookPage stays readable:
    EntryEditorRow (4 inline editable fields + per-field
    validation errors), EditModeControls (Save / Cancel / Add),
    BackupsDialog (modal with newest-first list + per-row
    revert + confirm), ClientSidePreview (mirrors
    dm_phrasebook.match + render_reply in TS so operator can
    preview in-progress edits without saving).

  * api wrappers + types: putSlackDmPhrasebook /
    revertSlackDmPhrasebook / getSlackDmPhrasebookBackups +
    PhrasebookEntryWrite / PhrasebookPutResponse /
    PhrasebookValidationErrorEntry/Body /
    PhrasebookRevertResponse / PhrasebookBackupItem /
    PhrasebookBackupsResponse.

  * PhrasebookPage extended with edit-mode toggle. View-mode
    surface (read-only table + live tester) is unchanged for
    operators who just want to inspect. Edit-mode swaps to
    editor rows + client-side preview + Save/Cancel/Add.
    Backups button in view-mode opens the revert dialog.

  * 422 validation-error parsing: Save handler unmarshals
    fetchJSON's "STATUS: BODY" Error message; on 422 with
    error="validation_failed" the body's per-entry errors are
    routed to the editor for inline rendering.

Tests
=====

  Backend (tests/kora_cli/test_phrasebook_editor.py — 44 tests):

    Validation (14): valid entries, non-list, missing field,
      empty whitespace, all 4 length caps, entries count cap,
      invalid regex, catastrophic-backtracking parametrized
      across 4 pathological patterns, unknown vs known snapshot
      path, duplicate (pattern, category), schema drift guard
      against state_snapshot.py.
    Backups (5): no-override returns None, copies content +
      timestamps filename, rotation keeps N most recent, env
      override + clamping parametrized, list returns newest-
      first with entry_count.
    Write+revert (5): round-trip load, revert by name, revert
      most-recent, revert with no backups removes override,
      path-traversal rejection parametrized across 5 attacks.
    Endpoints (7): PUT valid + audit, PUT invalid + preserve,
      PUT no-existing-override, POST revert + audit, POST
      revert invalid filename → 400, POST revert missing →
      404, GET backups newest-first.
    Audit (1): SeamName Literal includes phrasebook.updated.
    Integration (1): round-trip PUT then revert restores seed.

  FE source-pins (tests/kora_cli/test_phrasebook_editor_fe_pins.py
  — 17 tests):

    api wrappers (3), TS types declared + shape pinned (2),
    editor file exists + exports (1), page wiring (3), edit
    button hidden in edit-mode (1), 422 body parsing branch
    present (1), ClientSidePreview mirrors backend regex flag
    + "unknown" sentinel + null-snapshot fall-through (3),
    SnapshotResponse type covers daemon_health (1).

  All 76 phrasebook tests pass (44 BE editor + 17 FE pins +
  15 existing PR #167 read-side tests).

  tsc -b clean. vite build clean.

Screenshots
===========

  web/docs/phrasebook-editor/edit_mode.png — edit-mode UI with
    client-side preview rendering a $0 reply
  web/docs/phrasebook-editor/validation.png — 422 response with
    4 inline per-field errors (bad regex, unknown snapshot path,
    catastrophic backtracking, missing description) + top-level
    duplicate error
  web/docs/phrasebook-editor/revert.png — backups modal with
    newest-first list, per-row Revert confirm flow, greyed-out
    corrupt backup

Design choices noted (no STOP-ASKs triggered)
=============================================

  * Snapshot field-path validation uses a STATIC allow-list
    (SNAPSHOT_SCALAR_PATHS), NOT a live snapshot walk. Spec §4
    flagged this as a possible STOP-ASK; the static-list
    approach is what the spec offered as the alternative
    ("use SnapshotResponse static schema") and avoids the
    dynamic-snapshot-during-warm-up problem where freshly-
    booted daemons would mark canonical paths as invalid.
    Drift guard test pins each scalar leaf against the
    snapshot collector source.

  * Audit seam SeamName extension follows the
    probe.wake_requested precedent — extend Literal with a
    new value + add the comment explaining the future
    actor extension. No consumer drift.

  * Operator-defined category values: free-form for v1
    (the runtime doesn't reserve any category names yet;
    promotion-loop bucket can add reserved-prefix
    discipline when its UX lands).

Refs
====

  * rafe-walker/kora-docs
    17_cc_bucket_prompts/KR-FE-PHRASEBOOK-EDITOR-AND-CRUD_write_path_with_validation.md
  * PR #160 — phrasebook + dm_phrasebook module (read side)
  * PR #167 — KR-FE-PHRASEBOOK-VIEWER (read-only viewer this extends)
  * PR #170 — snapshot v4 daemon_health (referenced in
    SNAPSHOT_SCALAR_PATHS allow-list)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant