Judge Paradigm Picker

Lets a user pick a round's AI judge paradigm — one of the six built-in paradigms (Flow, Lay, Policymaker, Kritikal, Educator, Truth Over Tech) or a custom paradigm built from a real judge's own publicly stated preferences — and lists every round with a saved selection, with a "Clear" action per round.

  • Route: /paradigms
  • Nav: the Tools page's Prep & Practice group; the Reason Editor's Workspace menu (t paradigm in Ctrl/Cmd-Shift-Space's command palette)
  • Package: debate-speech-writer

What it shows

A form to save a round's paradigm: a round ID, a radio choice among the six built-in paradigms (each showing its name and description) or "Custom judge paradigm," which reveals a judge-name and preferences-notes field. Below the form, every round with a saved JudgeParadigmSelection is listed — round ID, the paradigm's name, a "Preview prompt" toggle (see "Inline prompt preview" below), a "Get AI judge decision →" link to /judge-decision pre-filled with that round's ID (see "AI Judge Decision" under Data flow below), and a "Clear" action — sorted by roundId.

Inline prompt preview

Closes this doc's own Known gap: the panel used to only get a user one click closer to an actual decision, never showing what the paradigm actually tells the AI judge to do. Each saved selection's "Preview prompt" button expands an inline block (toggled per round, collapsed by default) showing exactly what judge/judge-paradigms.ts's buildJudgeParadigmPrompt(selection.paradigm) returns for that selection — the same text round/judge-decision-ai.ts composes into a real AI judge-decision request. A "Copy" button next to it calls navigator.clipboard.writeText directly (the same pattern AiAnalysisSidebar.tsx in debate-research-evidence already uses), showing "Copied!" for two seconds; a denied clipboard permission fails silently since the prompt text is already visible to copy by hand. No new prompt-building logic was added — this only renders the existing, already Vitest-covered buildJudgeParadigmPrompt output.

Data flow

state/judgeParadigmSelections.ts (localStorage: judgeParadigmSelections)
  → buildJudgeParadigmSelectionsPanelView() — sorts every persisted
                                                JudgeParadigmSelection by roundId
  → panels/JudgeParadigmPickerPanel.tsx     — renders the picker form + list
  → apps/debate-ai.com/app/paradigms/page.tsx — mounts the panel as a route

Saving a round's paradigm:
panels/JudgeParadigmPickerPanel.tsx
  → judge/judge-paradigms.ts's buildCustomJudgeParadigm() for a custom paradigm,
    or a lookup into listJudgeParadigms() for a built-in one
  → saveJudgeParadigmSelection({ roundId, paradigm }) — state/judgeParadigmSelections.ts
  → panel re-reads buildJudgeParadigmSelectionsPanelView() to refresh

Clearing a round's paradigm:
panels/JudgeParadigmPickerPanel.tsx
  → deleteJudgeParadigmSelection(roundId) — state/judgeParadigmSelections.ts
  → panel re-reads buildJudgeParadigmSelectionsPanelView() to refresh

AI Judge Decision (a saved selection's "Get AI judge decision →" link):
panels/JudgeParadigmPickerPanel.tsx
  → buildJudgeDecisionDeepLink(roundId) — state/judgeParadigmSelections.ts,
    e.g. "/judge-decision?roundId=round-1"
  → apps/debate-ai.com/app/judge-decision/page.tsx (debate-round)
  → panels/JudgeDecisionPanel.tsx — reads `?roundId=` via next/navigation's
    useSearchParams to pre-fill the Round ID field, then resolves that
    round's saved paradigm via getJudgeParadigmSelection as usual

Every paradigm definition and persistence rule already existed and was Vitest-covered; this feature closes follow-up (b), "a paradigm-picker UI for selecting a built-in paradigm or entering a custom judge's notes that reads/writes through the persistence store," named under idea #5 ("AI Judge Decision Modes") in TODO.md's Product Feature Ideas list, adding one small ordering helper (buildJudgeParadigmSelectionsPanelView) to state/judgeParadigmSelections.ts rather than introducing new paradigm-resolution logic. Vitest-covered in packages/debate-speech-writer/test/judgeParadigmSelections.test.ts. A later slice closed follow-up (a): round/judge-decision-ai.ts composes buildJudgeParadigmPrompt's output with a round's flow summary into an AI judge-decision request, round/judge-decision-client.ts calls the existing /api/reason-ai proxy with it, and round/judge-decision-store-wiring.ts's buildJudgeDecisionInputFromStores resolves the round's saved paradigm via getJudgeParadigmSelection — wired into panels/JudgeDecisionPanel.tsx (mounted at /judge-decision) and panels/PracticeRoundSimulatorPanel.tsx. A further slice closed the "doesn't itself invoke a judge decision" Known gap below: state/judgeParadigmSelections.ts's new buildJudgeDecisionDeepLink(roundId) builds a /judge-decision?roundId=… link, rendered as a "Get AI judge decision →" button next to each saved selection in JudgeParadigmPickerPanel.tsx; JudgeDecisionPanel.tsx reads that roundId query param via next/navigation's useSearchParams to pre-fill its form, mirroring debate-research-evidence's EvidenceLibraryPanel/?checkUrl=/buildReuseCheckDeepLink convention. Vitest-covered in packages/debate-speech-writer/test/judgeParadigmSelections.test.ts.

Decision history

TODO.md idea #5's "(b) a decision history log per round instead of only the latest result" follow-up. Before this, JudgeDecisionPanel.tsx's state/judgeDecisions.ts kept only one JudgeDecisionRecord per roundId — requesting a second AI decision for the same round silently overwrote the first. Every requested decision is now appended to that round's history log instead, each with its own generated id (decision-<timestamp>-<random>, mirroring this package's existing FlowAnnotationsPanel/FlowEditLogPanel id-generation convention) so several decisions can coexist for one round. buildJudgeDecisionsPanelView groups the full list by roundId (sorted by roundId), sorting each round's decisions newest-first; JudgeDecisionPanel.tsx renders one section per round with its own "Clear" action per decision, rather than one row per round.

Account sync: like wordCountRounds, decision history is now synced to the account rather than staying per-browser — a new saved_judge_decisions D1 table (migration 0016_furry_skrulls.sql) holds one row per generated decision, keyed by the decision's own id (clientId) rather than its roundId, since many decisions can share a round; a plain roundId column is indexed (not unique) for per-round history lookups. GET /api/judge-decisions returns every synced decision in full (small payload, same as /api/word-count-rounds), and PUT/DELETE /api/judge-decisions/[decisionId] upsert/remove one, validated by state/savedJudgeDecisions.ts's isValidJudgeDecisionRecord. The new local-first hook hooks/useJudgeDecisions.ts — JudgeDecisionPanel's sole entry point into state/judgeDecisions.ts now — merges local and remote history by each decision's own id on mount (a remote-only decision is adopted locally via adoptJudgeDecision; a local-only decision is best-effort pushed to the account), then appendDecision/deleteDecision apply locally first and best-effort sync the same change outward, mirroring useWordCountRounds's merge model exactly. The panel shows a "Decision history is synced to your account" / "Sign in to sync your decision history" hint, the same convention as WordCountRoundsPanel.

Bulk clear a round's history: closing idea #5's third "Next" bullet ("a bulk 'clear all history for this round' action, and/or a per-round decision count cap"), each round's section in JudgeDecisionPanel.tsx also has a "Clear all history for this round" button next to its heading, alongside the existing per-decision "Clear" action. state/judgeDecisions.ts's deleteJudgeDecisionsForRound(roundId) removes every record for that round in one write and returns the removed ids (newest-first); hooks/useJudgeDecisions.ts's deleteRoundHistory(roundId) applies that locally first, then — when signed in — best-effort deletes each of those ids from the account too, one DELETE /api/judge-decisions/[decisionId] call per id (no new bulk-delete route; the existing single-decision endpoint is reused), mirroring deleteDecision's local-first-then-sync order. A round with no history is a no-op (no state write, no network calls).

Per-round decision count cap: closing idea #5's remaining "Next" bullet. state/judgeDecisions.ts's MAX_JUDGE_DECISIONS_PER_ROUND (20) caps how many decisions one round's history log can hold — appendJudgeDecision now trims the oldest entries for that round (by generatedAt) once the cap is exceeded, returning { record, trimmedIds } instead of the bare record. hooks/useJudgeDecisions.ts's appendDecision best-effort deletes each trimmed id from the account too, the same per-id cleanup deleteRoundHistory already does. This mirrors wordLimitPresets.ts's MAX_WORD_LIMIT_PRESETS cap-constant convention rather than adding a new Settings-driven numeric preference. One known edge case: the cap isn't re-checked during the mount-time remote merge, so a remote copy of an already-trimmed decision (only possible if its account delete failed) could still be adopted back on a later mount — accepted for this slice rather than defended against.

state/judgeDecisions.ts        — id-keyed append-only log, grouped panel view
state/savedJudgeDecisions.ts   — isValidJudgeDecisionRecord, size cap
round/judge-decisions-client.ts — fetch wrapper (list/save/delete)
hooks/useJudgeDecisions.ts     — local-first merge + sync, by id
panels/JudgeDecisionPanel.tsx  — renders history grouped by round
lib/database/schema.ts (apps/debate-ai.com) — saved_judge_decisions table
app/api/judge-decisions/route.ts                  — GET: list the user's synced decisions
app/api/judge-decisions/[decisionId]/route.ts     — PUT/DELETE: upsert/remove one decision

Vitest-covered: judgeDecisions.test.ts (append-only behavior, per-round listing newest-first, adopt/delete-by-id, grouped panel view); savedJudgeDecisions.test.ts (isValidJudgeDecisionRecord's full validation surface); judge-decisions-client.test.ts (the fetch wrapper's request shapes and 401/error handling). useJudgeDecisions and its wiring in JudgeDecisionPanel remain intentionally untested, matching this package's existing convention for account-synced, localStorage-backed hooks and their UI (useWordCountRounds follows the same pattern).

Multi-judge panel mode

Idea #5's "a multi-judge 'panel' mode that runs several paradigms against the same round and shows a combined decision" fresh next-step. Until now, a round could only be judged under the one paradigm saved for it in state/judgeParadigmSelections.ts (which still upserts one selection per roundId, unchanged) — there was no way to see how the same flow summary reads under several judging styles at once, or how much a decision would have changed under a different judge.

JudgeDecisionPanel.tsx gained a Multi-judge panel section below the existing single-decision form, reusing that form's Round ID and side-name fields. It offers a checkbox per built-in paradigm (listJudgeParadigms()); picking two or more and clicking "Run multi-judge panel" requests one AI decision per selected paradigm — via the same requestJudgeDecision call the single-decision flow uses, just built against a caller-supplied paradigm instead of the round's one saved selection through a new round/judge-decision-store-wiring.ts#buildJudgeDecisionInputForParadigm — and appends each result to the round's history log (hooks/useJudgeDecisions.ts#appendDecision) tagged with one shared batchId (state/judgeDecisions.ts#generateJudgeDecisionBatchId). No new storage layer, D1 table, or API route was needed: JudgeDecisionRecord just gained an optional batchId field (validated as an optional string by state/savedJudgeDecisions.ts#isValidJudgeDecisionRecord), so a panel run's decisions sync to the account exactly like any other decision.

The combined decision itself is derived, not separately stored: state/judgeDecisions.ts#buildJudgeDecisionHistoryItems regroups a round's newest-first decisions so every 2+ decisions sharing a batchId render as one panel run card instead of unrelated single-decision cards (a lone surviving member of a batch — e.g. after clearing its batch-mates individually — falls back to a plain single-decision card, since combining needs at least two). buildJudgeDecisionsPanelView now exposes this as historyItems alongside the existing flat decisions list, so nothing that already reads .decisions needs to change. debate-round's new round/judge-decision-panel.ts#combineJudgePanelDecisions is the pure combination logic: it tallies each paradigm's vote into a majority winner ("split" on an exact tie), reports the vote counts and whether every paradigm agreed (unanimous), and unions every paradigm's keyVotingIssues (de-duplicated, first-seen order) — this also doubles as the "side-by-side paradigm comparison" half of the same TODO.md bullet, since the panel run's card renders a table with one row per paradigm (vote and rationale) below the combined summary, so a user can see exactly where the paradigms agreed or split before picking which judge to prep for. A "Clear this panel run" action removes every decision in the batch in one click (looping deleteDecision per id — no new bulk-delete route).

round/judge-decision-panel.ts (debate-round)     — combineJudgePanelDecisions (pure)
round/judge-decision-store-wiring.ts             — buildJudgeDecisionInputForParadigm
state/judgeDecisions.ts                          — batchId field, generateJudgeDecisionBatchId,
                                                    buildJudgeDecisionHistoryItems
state/savedJudgeDecisions.ts                     — isValidJudgeDecisionRecord accepts optional batchId
panels/JudgeDecisionPanel.tsx                    — "Multi-judge panel" section + panel-run cards

Vitest-covered: packages/debate-round/test/judge-decision-panel.test.ts (combineJudgePanelDecisions — throws below 2 results, majority winner, split tie, unanimity, key-voting-issue union/de-dup); buildJudgeDecisionHistoryItems itself now also has direct coverage in packages/debate-practice-drills/test/judgeDecisions.test.ts (see "Rubric agreement breakdown" below). The panel's own UI state remains intentionally untested, matching this doc's existing convention for localStorage-backed hooks and their UI.

Rubric agreement breakdown

Closes this doc's own Known gap — the fresh next-step named once the panel mode above shipped: "letting a panel run reuse each paradigm's own scoring rubric via buildJudgeDecisionRubric to show a rubric-based agreement breakdown, not just the raw winner/keyVotingIssues union." The combined decision above answers who won; it says nothing about how thoroughly the decision actually engaged each paradigm's own priorities — a unanimous 5-0 panel that never mentioned four of five paradigms' top criteria reads identically to one that engaged every criterion, under the old summary.

round/judge-decision-panel.ts's new buildJudgePanelRubricAgreement reuses the existing, already-Vitest-covered buildJudgeDecisionRubric (round/judge-decision-ai.ts) once per paradigm in the panel — no new scoring logic — and sums the result: each paradigm's own addressed/total count (perParadigm), plus an overall totalAddressed/totalCriteria/agreementRate across the whole panel. Throws below 2 entries, mirroring combineJudgePanelDecisions.

Wiring a JudgeDecisionRecord's stored paradigmName (a display string, not a JudgeParadigmId) back to the full JudgeParadigm object buildJudgeDecisionRubric needs (for its votingPriorities) needed a new getJudgeParadigmByName in debate-speech-writer's judge-paradigms.ts, an exact-match lookup against listJudgeParadigms(). This only ever resolves a built-in paradigm — a custom paradigm's "Custom: <judge name>" label never matches — which is fine here since the multi-judge panel's checkboxes only ever offer the six built-ins.

state/judgeDecisions.ts's buildJudgeDecisionHistoryItems now resolves every batch member's paradigm by name and, when 2 or more resolve, attaches the breakdown as each panel JudgeDecisionHistoryItem's new rubricAgreement field (null otherwise — defensive, not expected to happen through the current UI). JudgeDecisionPanel.tsx's panel-run card shows an overall "X/Y rubric criteria addressed (Z%)" badge next to the existing winner/Unanimous badges, plus a "Rubric agreement" section listing each paradigm's own criteria with a ✓/✗ per row.

round/judge-decision-panel.ts (debate-round)          — buildJudgePanelRubricAgreement (pure)
debate-speech-writer/src/judge/judge-paradigms.ts     — getJudgeParadigmByName
state/judgeDecisions.ts                               — rubricAgreement on each panel history item
panels/JudgeDecisionPanel.tsx                          — "Rubric agreement" section + summary badge

Vitest-covered: packages/debate-speech-writer/test/judge-paradigms.test.ts (getJudgeParadigmByName — exact match, unknown/custom names, case sensitivity); packages/debate-round/test/judge-decision-panel.test.ts (buildJudgePanelRubricAgreement — throws below 2 entries, per-paradigm and overall counts, zero-criteria division guard); packages/debate-practice-drills/test/judgeDecisions.test.ts (buildJudgeDecisionHistoryItems — a resolvable panel gets a non-null breakdown, an unresolvable paradigm name falls back to null, a lone single decision has no rubricAgreement at all since it isn't a panel item).

Known gap: a panel run always judges every selected paradigm against the round's current saved flow summary and the side names typed into the form at run time — it doesn't snapshot which flow summary version was used the way a single decision doesn't either, so re-running a panel after the flow summary changes isn't distinguishable from the first run except by timestamp. This matches the existing single-decision flow's behavior, so is not treated as a new regression.

Cross-tab live update

JudgeParadigmPickerPanel used to only refresh its rendered selection list on mount or right after its own save/clear actions — a teammate saving or clearing a round's paradigm in a second open tab (or a second browser window on the same machine) never showed up without a manual reload, the same still-open gap noted in shared-flow-sync.md. This is the first live-update.ts in debate-practice-drills, following the same pattern debate-round's flow/live-update.ts, debate-search-evidence's state/live-update.ts, and debate-speech-writer's state/live-update.ts already use for their own panels.

state/live-update.ts's isJudgeParadigmPickerPanelLiveUpdateStorageEvent checks whether a storage event's key is the panel's one backing store (judgeParadigmSelections) or null (a localStorage.clear()). JudgeParadigmPickerPanel.tsx now subscribes to window's storage event (the browser only fires this in other same-origin tabs, never the one that made the write) and calls its existing refresh() closure when the predicate matches. The in-progress "save a round's paradigm" form draft is left untouched — only the persisted selection list re-reads, matching every other closed panel's "refresh the derived view, not the draft" convention.

Vitest-covered in packages/debate-practice-drills/test/live-update.test.ts (the one backing-store key, the null-key clear-all case, and unrelated/substring-matching keys staying ignored).

JudgeDecisionPanel had the same gap through its own hooks/useJudgeDecisions.ts: a decision requested, cleared, or bulk-cleared for a round in one tab never showed up in another tab's already-rendered history without a manual reload. state/live-update.ts's isJudgeDecisionPanelLiveUpdateStorageEvent checks whether a storage event's key is the hook's one backing store (judgeDecisions, from state/judgeDecisions.ts) or null. useJudgeDecisions now subscribes to window's storage event and re-derives groups via buildJudgeDecisionsPanelView() when the predicate matches — since the panel's form state (Round ID, side names, selected panel paradigms) lives in the panel component itself, not the hook, this refresh never touches an in-progress "request a decision" form. Vitest-covered alongside the case above in the same live-update.test.ts file.

Known gaps

  • Saved selections sync to a signed-in user's account as the judgeParadigmSelections collection (see Tool Data Sync); a signed-out browser still keeps them in localStorage only.
  • The multi-judge panel mode above closes idea #5's remaining named next-step. No further follow-up is currently tracked for this idea; a future run should pick a fresh next-step (e.g. letting a panel run reuse each paradigm's own scoring rubric via buildJudgeDecisionRubric to show a rubric-based agreement breakdown, not just the raw winner/keyVotingIssues union) if one becomes worth doing. Closed: see "Rubric agreement breakdown" above. No further follow-up is currently tracked for idea #5.