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 paradigmin 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 usualEvery 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 decisionVitest-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 cardsVitest-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 badgeVitest-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
judgeParadigmSelectionscollection (see Tool Data Sync); a signed-out browser still keeps them inlocalStorageonly. 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 viaClosed: see "Rubric agreement breakdown" above. No further follow-up is currently tracked for idea #5.buildJudgeDecisionRubricto show a rubric-based agreement breakdown, not just the raw winner/keyVotingIssues union) if one becomes worth doing.
