From 00a5cc4aaba205322d8c182ea0eae8912dee674f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81d=C3=A1m=20G=C3=A1borj=C3=A1ni-Szab=C3=B3?= <117768520+Programcode@users.noreply.github.com> Date: Wed, 26 Aug 2026 23:23:57 +0200 Subject: [PATCH 01/84] docs: align roadmap with current product state --- plans/README.md | 44 ++++++++++++++++++++++++++++++++------------ 1 file changed, 32 insertions(+), 12 deletions(-) diff --git a/plans/README.md b/plans/README.md index 66599ccf5..110278a8d 100644 --- a/plans/README.md +++ b/plans/README.md @@ -1,24 +1,44 @@ # Plans -This folder holds implementation plans for remaining DebateLab product work. Keep plans actionable: scope, files, data model, rollout steps, tests, and deployment notes. +This folder tracks remaining DebateLab product work. The source tree is the authority: a plan is active only when it describes work that is not already present on the current branch. -## Active Plans +## NOW + +1. **Sparring v1** — turn DebateLab from a preparation toolbox into a repeatable training loop: motion → side → opponent exchange → qualitative scorecard → targeted next drill. +2. **Verification loop** — keep PR CI deterministic, add browser coverage for the critical product journeys, and prepare authenticated staging E2E without applying or deploying any Supabase changes from this branch. +3. **Post-remediation security validation** — verify the August security findings against current source and add regression tests around the remediated trust boundaries. + +## NEXT + +- [Ask and sitewide search quality](./search-quality.md): finish the remaining Exa domain-filter verification, handler-level integration coverage, and Factoids consulted-source UI. +- Sparring follow-ups driven by product data: format presets, richer drills, voice/live-round experiments, and longitudinal progress only after the core loop proves useful. +- Conversion and retention work based on measured activation and repeat usage. + +## LATER - [Admin announcements](./admin-announcements.md) -- [Ask and sitewide search quality](./search-quality.md) -- [Control honesty and deterministic toggles](./control-honesty.md) -- [Debate sparring and streaming](./sparring-and-streaming.md) -- [Model cost accounting](./model-cost-accounting.md) -- [Prompt evaluation sheet (manual)](./prompt-eval-sheet.md) -- [Quality & Truth release checklist](./release-checklist.md) -- [Rate-limit UI cleanup](./rate-limit-ui-cleanup.md) -- [Source auto-import](./source-auto-import.md) -- [Streaming AI](./streaming-ai.md) -- [System prompt improvement](./system-prompt-improvement.md) - [Animation polish](./animation-polish.md) +- [Rate-limit UI cleanup](./rate-limit-ui-cleanup.md) +- Further model/prompt experiments that are supported by evaluation data. + +## Historical / substantially implemented + +These files are kept for implementation history. Do not treat them as current instructions without checking the source tree first. + +- [Streaming AI](./streaming-ai.md) — Ask streaming endpoint/client/tests are already present. +- [Source auto-import](./source-auto-import.md) — import endpoint, validation, schema, Library integration, and tests are already present. +- [Control honesty and deterministic toggles](./control-honesty.md) — core enforcement is implemented and tested. +- [Model cost accounting](./model-cost-accounting.md) — accounting helpers, audit metadata, and tests are present; remaining work is operational validation. +- [System prompt improvement](./system-prompt-improvement.md) — layered shared prompt architecture is present. +- [Quality & Truth release checklist](./release-checklist.md) — use as a release-history checklist, not a live roadmap. +- [Supabase staging](./supabase-staging.md) and [Agent verification loop](./agent-verification-loop.md) — still useful design references, but any migration/function/secret deployment is explicitly out of scope for this feature branch. ## Done - [Bug and polish cleanup](./done/bugs.txt) - [Admin trial gifts](./done/admin-trial-gifts.md) - [Top users email fallback](./done/top-users-email-fallback.md) + +## Planning rule + +Every active plan should name scope, data ownership, rollout, automated verification, and what is deliberately not being changed. If implementation has landed, update this index in the same branch so agents do not repeatedly rediscover stale work. From 354368c688d50dbf0d52b8323493eae524f1b188 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81d=C3=A1m=20G=C3=A1borj=C3=A1ni-Szab=C3=B3?= <117768520+Programcode@users.noreply.github.com> Date: Wed, 26 Aug 2026 23:24:12 +0200 Subject: [PATCH 02/84] feat: add sparring domain schemas --- src/lib/ai/schemas/sparring.ts | 72 ++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 src/lib/ai/schemas/sparring.ts diff --git a/src/lib/ai/schemas/sparring.ts b/src/lib/ai/schemas/sparring.ts new file mode 100644 index 000000000..71ccfe749 --- /dev/null +++ b/src/lib/ai/schemas/sparring.ts @@ -0,0 +1,72 @@ +import { z } from 'zod'; + +export const SparringSideSchema = z.enum(['pro', 'con']); +export const SparringDifficultySchema = z.enum(['beginner', 'competitive', 'brutal']); +export const SparringTurnRoleSchema = z.enum(['user', 'ai']); +export const SparringTurnPhaseSchema = z.enum(['opening', 'rebuttal']); + +export const SparringTurnSchema = z.object({ + id: z.string().min(1), + role: SparringTurnRoleSchema, + phase: SparringTurnPhaseSchema, + content: z.string().min(1), + createdAt: z.number().finite(), +}); + +export const SparringSetupSchema = z.object({ + motion: z.string().trim().min(8, 'Enter a complete debate motion.').max(1200), + side: SparringSideSchema, + difficulty: SparringDifficultySchema, + useActiveSources: z.boolean().default(true), +}); + +export const SparringOpponentOutputSchema = z.object({ + speech: z.string().trim().min(1), + pressurePoints: z.array(z.string().trim().min(1)).max(4).default([]), +}); + +export const SparringRatingSchema = z.enum(['needs-work', 'solid', 'strong']); + +export const SparringCategoryFeedbackSchema = z.object({ + category: z.enum([ + 'argument-strength', + 'warrant-quality', + 'impact-analysis', + 'engagement', + 'rebuttal-efficiency', + 'weighing', + 'evidence-use', + 'strategy', + ]), + rating: SparringRatingSchema, + evidence: z.string().trim().min(1), + advice: z.string().trim().min(1), +}); + +export const SparringScorecardSchema = z.object({ + verdict: z.enum(['user-edge', 'ai-edge', 'close']), + summary: z.string().trim().min(1), + biggestImprovement: z.string().trim().min(1), + strengths: z.array(z.string().trim().min(1)).max(5).default([]), + missedArguments: z.array(z.string().trim().min(1)).max(5).default([]), + categories: z.array(SparringCategoryFeedbackSchema).min(4).max(8), + nextDrill: z.string().trim().min(1), +}); + +export const SparringSessionSchema = z.object({ + id: z.string().min(1), + setup: SparringSetupSchema, + turns: z.array(SparringTurnSchema).max(8), + scorecard: SparringScorecardSchema.optional(), + status: z.enum(['active', 'complete']), + createdAt: z.number().finite(), + updatedAt: z.number().finite(), +}); + +export type SparringSide = z.infer; +export type SparringDifficulty = z.infer; +export type SparringTurn = z.infer; +export type SparringSetup = z.infer; +export type SparringOpponentOutput = z.infer; +export type SparringScorecard = z.infer; +export type SparringSession = z.infer; From c1173840d0059d7c47979a8f5cd19c5a11ddbbf3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81d=C3=A1m=20G=C3=A1borj=C3=A1ni-Szab=C3=B3?= <117768520+Programcode@users.noreply.github.com> Date: Wed, 26 Aug 2026 23:24:37 +0200 Subject: [PATCH 03/84] feat: add local-first sparring history store --- src/lib/stores/sparring-store.ts | 99 ++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 src/lib/stores/sparring-store.ts diff --git a/src/lib/stores/sparring-store.ts b/src/lib/stores/sparring-store.ts new file mode 100644 index 000000000..229920f09 --- /dev/null +++ b/src/lib/stores/sparring-store.ts @@ -0,0 +1,99 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; +import type { + SparringScorecard, + SparringSession, + SparringSetup, + SparringTurn, +} from '@/lib/ai/schemas/sparring'; + +const MAX_SAVED_SESSIONS = 30; + +function makeId(prefix: string) { + return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; +} + +interface SparringState { + sessions: SparringSession[]; + activeSessionId: string | null; + startSession: (setup: SparringSetup) => SparringSession; + addTurn: (sessionId: string, turn: Omit) => SparringTurn; + completeSession: (sessionId: string, scorecard: SparringScorecard) => void; + setActiveSession: (sessionId: string | null) => void; + deleteSession: (sessionId: string) => void; + clearSparringHistory: () => void; +} + +export const useSparringStore = create()( + persist( + (set, get) => ({ + sessions: [], + activeSessionId: null, + + startSession: (setup) => { + const now = Date.now(); + const session: SparringSession = { + id: makeId('spar'), + setup, + turns: [], + status: 'active', + createdAt: now, + updatedAt: now, + }; + set((state) => ({ + sessions: [session, ...state.sessions].slice(0, MAX_SAVED_SESSIONS), + activeSessionId: session.id, + })); + return session; + }, + + addTurn: (sessionId, turn) => { + const created: SparringTurn = { + ...turn, + id: makeId('turn'), + createdAt: Date.now(), + }; + set((state) => ({ + sessions: state.sessions.map((session) => session.id === sessionId + ? { ...session, turns: [...session.turns, created], updatedAt: Date.now() } + : session), + })); + return created; + }, + + completeSession: (sessionId, scorecard) => { + set((state) => ({ + sessions: state.sessions.map((session) => session.id === sessionId + ? { ...session, scorecard, status: 'complete', updatedAt: Date.now() } + : session), + })); + }, + + setActiveSession: (activeSessionId) => set({ activeSessionId }), + + deleteSession: (sessionId) => set((state) => ({ + sessions: state.sessions.filter((session) => session.id !== sessionId), + activeSessionId: state.activeSessionId === sessionId ? null : state.activeSessionId, + })), + + clearSparringHistory: () => set({ sessions: [], activeSessionId: null }), + }), + { + name: 'debatelab-sparring', + partialize: (state) => ({ sessions: state.sessions, activeSessionId: state.activeSessionId }), + merge: (persisted, current) => { + const saved = persisted as Partial | undefined; + const sessions = Array.isArray(saved?.sessions) ? saved.sessions.slice(0, MAX_SAVED_SESSIONS) : []; + const activeSessionId = typeof saved?.activeSessionId === 'string' && sessions.some((session) => session.id === saved.activeSessionId) + ? saved.activeSessionId + : null; + return { ...current, ...saved, sessions, activeSessionId } as SparringState; + }, + }, + ), +); + +export function getSparringSession(sessionId: string | null): SparringSession | null { + if (!sessionId) return null; + return useSparringStore.getState().sessions.find((session) => session.id === sessionId) ?? null; +} From 2876b9ab0023e12f0ea87b4c6214d072ff629608 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81d=C3=A1m=20G=C3=A1borj=C3=A1ni-Szab=C3=B3?= <117768520+Programcode@users.noreply.github.com> Date: Wed, 26 Aug 2026 23:25:13 +0200 Subject: [PATCH 04/84] feat: add grounded sparring AI client --- src/lib/ai/sparring-client.ts | 159 ++++++++++++++++++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 src/lib/ai/sparring-client.ts diff --git a/src/lib/ai/sparring-client.ts b/src/lib/ai/sparring-client.ts new file mode 100644 index 000000000..324b5277e --- /dev/null +++ b/src/lib/ai/sparring-client.ts @@ -0,0 +1,159 @@ +import { askChat, AIResponseError } from '@/lib/ai/client'; +import type { ProviderConfig } from '@/lib/ai/providers/types'; +import { + SparringOpponentOutputSchema, + SparringScorecardSchema, + type SparringOpponentOutput, + type SparringScorecard, + type SparringSession, + type SparringTurn, +} from '@/lib/ai/schemas/sparring'; + +function opposingSide(side: 'pro' | 'con') { + return side === 'pro' ? 'con' : 'pro'; +} + +function difficultyInstruction(difficulty: SparringSession['setup']['difficulty']) { + switch (difficulty) { + case 'beginner': + return 'Argue clearly and realistically. Pressure obvious gaps, but do not use obscure theory or intentionally tricky framing.'; + case 'brutal': + return 'Argue like a strong elimination-round opponent. Exploit dropped warrants, force comparative weighing, and punish unsupported assertions. Stay fair and do not invent evidence.'; + case 'competitive': + default: + return 'Argue like a competent tournament opponent. Directly engage the user, develop warrants, and create clear clash without manufacturing facts.'; + } +} + +function transcript(turns: SparringTurn[]) { + return turns.map((turn, index) => { + const speaker = turn.role === 'user' ? 'USER' : 'AI OPPONENT'; + return `${index + 1}. ${speaker} (${turn.phase.toUpperCase()}):\n${turn.content}`; + }).join('\n\n'); +} + +function extractJsonObject(text: string): unknown { + const trimmed = text.trim(); + const unfenced = trimmed + .replace(/^```(?:json)?\s*/i, '') + .replace(/\s*```$/i, '') + .trim(); + + try { + return JSON.parse(unfenced); + } catch { + const start = unfenced.indexOf('{'); + const end = unfenced.lastIndexOf('}'); + if (start >= 0 && end > start) { + return JSON.parse(unfenced.slice(start, end + 1)); + } + throw new Error('No JSON object found'); + } +} + +function opponentPrompt(session: SparringSession, userSpeech: string) { + const aiSide = opposingSide(session.setup.side); + return `You are the opponent in a DebateLab sparring round. + +Motion: ${session.setup.motion} +User side: ${session.setup.side} +Your side: ${aiSide} +Difficulty: ${session.setup.difficulty} +Difficulty instruction: ${difficultyInstruction(session.setup.difficulty)} + +Round transcript so far: +${transcript(session.turns)} + +The user's newest speech is: +${userSpeech} + +Respond to the user's actual arguments, not a generic version of the motion. Do not invent facts, quotations, statistics, studies, or sources. If the user's claim lacks support, attack the missing warrant instead of fabricating counter-evidence. Make the speech usable in a real round and keep it under 550 words. + +Inside your normal DebateLab response field, return ONLY this JSON object and no markdown fences: +{ + "speech": "the opponent speech", + "pressurePoints": ["up to four specific vulnerabilities in the user's latest speech"] +}`; +} + +function scorecardPrompt(session: SparringSession) { + return `Judge this completed DebateLab sparring round as a demanding but fair debate coach. + +Motion: ${session.setup.motion} +User side: ${session.setup.side} +AI opponent side: ${opposingSide(session.setup.side)} +Difficulty: ${session.setup.difficulty} +Active Source Library grounding was ${session.setup.useActiveSources ? 'enabled' : 'not requested'} for the round. + +FULL TRANSCRIPT: +${transcript(session.turns)} + +Evaluate only what appears in the transcript. Do not pretend the user cited evidence they did not cite. Do not infer delivery, tone of voice, eye contact, speaking speed, or other audio qualities from typed text. For every category, point to a specific argument, omission, comparison, or response from the transcript. Scores are deliberately qualitative because false numeric precision is not useful here. + +Inside your normal DebateLab response field, return ONLY this JSON object and no markdown fences: +{ + "verdict": "user-edge | ai-edge | close", + "summary": "short judge-style explanation of the round", + "biggestImprovement": "the single highest-leverage change for the user's next round", + "strengths": ["specific strengths, maximum five"], + "missedArguments": ["arguments, responses, or weighing opportunities the user missed, maximum five"], + "categories": [ + { "category": "argument-strength", "rating": "needs-work | solid | strong", "evidence": "specific transcript evidence", "advice": "concrete improvement" }, + { "category": "warrant-quality", "rating": "needs-work | solid | strong", "evidence": "specific transcript evidence", "advice": "concrete improvement" }, + { "category": "impact-analysis", "rating": "needs-work | solid | strong", "evidence": "specific transcript evidence", "advice": "concrete improvement" }, + { "category": "engagement", "rating": "needs-work | solid | strong", "evidence": "specific transcript evidence", "advice": "concrete improvement" }, + { "category": "rebuttal-efficiency", "rating": "needs-work | solid | strong", "evidence": "specific transcript evidence", "advice": "concrete improvement" }, + { "category": "weighing", "rating": "needs-work | solid | strong", "evidence": "specific transcript evidence", "advice": "concrete improvement" }, + { "category": "evidence-use", "rating": "needs-work | solid | strong", "evidence": "specific transcript evidence", "advice": "concrete improvement" }, + { "category": "strategy", "rating": "needs-work | solid | strong", "evidence": "specific transcript evidence", "advice": "concrete improvement" } + ], + "nextDrill": "one focused drill the user can do immediately" +}`; +} + +async function askForJson(prompt: string, config: ProviderConfig) { + const output = await askChat(prompt, [], 'general', config, { + citationMode: false, + webSearch: false, + prosConsMode: false, + argumentMapMode: false, + }); + return output.response; +} + +export async function requestSparringOpponent( + session: SparringSession, + userSpeech: string, + config: ProviderConfig, +): Promise { + const response = await askForJson(opponentPrompt(session, userSpeech), config); + try { + return SparringOpponentOutputSchema.parse(extractJsonObject(response)); + } catch { + // A plain-text opponent speech is still useful and safer than discarding a + // successful provider response because it ignored the inner JSON request. + const speech = response.trim(); + if (speech) return { speech, pressurePoints: [] }; + throw new AIResponseError('The sparring opponent returned an empty response.'); + } +} + +export async function requestSparringScorecard( + session: SparringSession, + config: ProviderConfig, +): Promise { + const first = await askForJson(scorecardPrompt(session), config); + try { + return SparringScorecardSchema.parse(extractJsonObject(first)); + } catch { + const repairPrompt = `${scorecardPrompt(session)}\n\nYour previous response could not be parsed as the requested scorecard. Re-evaluate from the transcript and return the raw JSON object only. Do not add prose or markdown fences.`; + const repaired = await askForJson(repairPrompt, config); + try { + return SparringScorecardSchema.parse(extractJsonObject(repaired)); + } catch (error) { + throw new AIResponseError('The sparring scorecard did not match the expected schema.', { + details: { cause: error instanceof Error ? error.message : String(error) }, + }); + } + } +} From 8bc104d76e8c8c7bc63a20310b5524fbc492b7cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81d=C3=A1m=20G=C3=A1borj=C3=A1ni-Szab=C3=B3?= <117768520+Programcode@users.noreply.github.com> Date: Wed, 26 Aug 2026 23:25:50 +0200 Subject: [PATCH 05/84] feat: add sparring model configuration --- src/lib/stores/settings-store.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/lib/stores/settings-store.ts b/src/lib/stores/settings-store.ts index e80581b83..4b9b15e81 100644 --- a/src/lib/stores/settings-store.ts +++ b/src/lib/stores/settings-store.ts @@ -4,7 +4,7 @@ import i18n from '@/i18n'; import type { ProviderConfig } from '../ai/providers/types'; import { supabase } from '@/lib/supabase/client'; -export type FeatureTab = 'generate' | 'attack' | 'brainstorm' | 'factoids' | 'ask'; +export type FeatureTab = 'generate' | 'attack' | 'brainstorm' | 'factoids' | 'ask' | 'sparring'; interface SettingsState { language: 'en' | 'es' | 'hu'; @@ -37,6 +37,7 @@ const getDefaultFeatureConfigs = (): Record => ({ brainstorm: { ...defaultConfig, temperature: 0.9 }, factoids: { ...defaultConfig, temperature: 0.3 }, ask: { ...defaultConfig }, + sparring: { ...defaultConfig, temperature: 0.75 }, }); function normalizeFeatureConfigs(configs: unknown): Record { @@ -207,4 +208,4 @@ export const useSettingsStore = create()( }, } ) -); +); \ No newline at end of file From 05165a52d35031ca27c4da09ff997cc9e91b4185 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81d=C3=A1m=20G=C3=A1borj=C3=A1ni-Szab=C3=B3?= <117768520+Programcode@users.noreply.github.com> Date: Wed, 26 Aug 2026 23:26:18 +0200 Subject: [PATCH 06/84] feat: add sparring setup UI --- .../features/sparring/SparringSetup.tsx | 168 ++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 src/components/features/sparring/SparringSetup.tsx diff --git a/src/components/features/sparring/SparringSetup.tsx b/src/components/features/sparring/SparringSetup.tsx new file mode 100644 index 000000000..76fc32b5e --- /dev/null +++ b/src/components/features/sparring/SparringSetup.tsx @@ -0,0 +1,168 @@ +import { useMemo, useState } from 'react'; +import { BookOpenCheck, Shield, Sparkles, Swords, Zap } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; +import { Textarea } from '@/components/ui/textarea'; +import { ModelSelector } from '@/components/ModelSelector'; +import { cn } from '@/lib/utils'; +import { useSessionStore } from '@/lib/stores/session-store'; +import { SparringSetupSchema, type SparringDifficulty, type SparringSetup } from '@/lib/ai/schemas/sparring'; + +interface SparringSetupProps { + onStart: (setup: SparringSetup) => void; + isStarting?: boolean; + defaultMotion?: string; +} + +const difficulties: Array<{ + id: SparringDifficulty; + label: string; + description: string; + icon: typeof Shield; +}> = [ + { + id: 'beginner', + label: 'Beginner', + description: 'Clear clash and forgiving pressure. Good for drilling fundamentals.', + icon: Shield, + }, + { + id: 'competitive', + label: 'Competitive', + description: 'A competent tournament opponent that directly engages your case.', + icon: Swords, + }, + { + id: 'brutal', + label: 'Brutal', + description: 'Elimination-round pressure. Dropped warrants and weak weighing get punished.', + icon: Zap, + }, +]; + +export function SparringSetup({ onStart, isStarting = false, defaultMotion = '' }: SparringSetupProps) { + const [motion, setMotion] = useState(defaultMotion); + const [side, setSide] = useState<'pro' | 'con'>('pro'); + const [difficulty, setDifficulty] = useState('competitive'); + const [useActiveSources, setUseActiveSources] = useState(true); + const activeSourceCount = useSessionStore((state) => state.sourceLibrary.filter((source) => source.isActive).length); + + const validation = useMemo(() => SparringSetupSchema.safeParse({ motion, side, difficulty, useActiveSources }), [motion, side, difficulty, useActiveSources]); + + const handleStart = () => { + if (!validation.success) return; + onStart(validation.data); + }; + + return ( +
+ + +
+
+ +
+
+ Set up a round + Start small: two speeches from you, two from the AI, then an evidence-backed scorecard. +
+
+
+ +
+ +