From 0cf192dfb60d3f6ae9ebf1273faec146b67e76ef Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 30 Aug 2026 21:34:13 +0000 Subject: [PATCH 1/6] feat: ship debate sparring mode --- e2e/authed-pages.spec.ts | 1 + e2e/sparring.spec.ts | 106 ++++ plans/README.md | 2 +- plans/sparring-and-streaming.md | 14 +- public/sw.js | 1 + src/App.tsx | 2 + .../features/sparring/SparringRound.tsx | 278 +++++++++ .../features/sparring/SparringScorecard.tsx | 188 ++++++ .../features/sparring/SparringSetup.tsx | 278 +++++++++ src/components/layout/AppLayout.tsx | 2 + src/lib/ai/client.ts | 172 +++++- src/lib/ai/providers/mock-provider.ts | 104 ++++ src/lib/ai/schemas/sparring.ts | 147 +++++ src/lib/export-utils.ts | 141 ++++- src/lib/pdf-export-utils.ts | 149 +++++ src/lib/sparring/formats.ts | 96 +++ src/lib/stores/settings-store.ts | 7 +- src/lib/stores/sparring-store.ts | 347 +++++++++++ src/lib/supabase/AuthProvider.tsx | 11 +- src/locales/en.json | 220 ++++++- src/locales/es.json | 220 ++++++- src/locales/hu.json | 220 ++++++- src/offline-route-preload.ts | 1 + src/pages/HistoryPage.tsx | 99 ++- src/pages/HomePage.tsx | 22 + src/pages/SettingsPage.tsx | 14 +- src/pages/SparringPage.tsx | 252 ++++++++ src/test/ai/sparring.test.ts | 180 ++++++ src/test/pages/HistoryPage.test.tsx | 2 +- src/test/pages/SparringPage.test.tsx | 372 ++++++++++++ src/test/sparring-ai.test.ts | 69 +++ src/test/sparring/exports.test.ts | 197 ++++++ src/test/sparring/formats.test.ts | 82 +++ src/test/stores/sparring-store.test.ts | 286 +++++++++ supabase/functions/_shared/prompts.test.ts | 11 +- supabase/functions/_shared/prompts.ts | 40 +- supabase/functions/ai/index.ts | 163 ++++- supabase/functions/ai/prompt-builder.test.ts | 21 + supabase/functions/ai/prompt-builder.ts | 44 +- .../functions/ai/sparring-persistence.test.ts | 253 ++++++++ supabase/functions/ai/sparring-persistence.ts | 574 ++++++++++++++++++ ...60830000000_complete_sparring_sessions.sql | 172 ++++++ vitest.config.ts | 3 + 43 files changed, 5535 insertions(+), 28 deletions(-) create mode 100644 e2e/sparring.spec.ts create mode 100644 src/components/features/sparring/SparringRound.tsx create mode 100644 src/components/features/sparring/SparringScorecard.tsx create mode 100644 src/components/features/sparring/SparringSetup.tsx create mode 100644 src/lib/ai/schemas/sparring.ts create mode 100644 src/lib/sparring/formats.ts create mode 100644 src/lib/stores/sparring-store.ts create mode 100644 src/pages/SparringPage.tsx create mode 100644 src/test/ai/sparring.test.ts create mode 100644 src/test/pages/SparringPage.test.tsx create mode 100644 src/test/sparring-ai.test.ts create mode 100644 src/test/sparring/exports.test.ts create mode 100644 src/test/sparring/formats.test.ts create mode 100644 src/test/stores/sparring-store.test.ts create mode 100644 supabase/functions/ai/sparring-persistence.test.ts create mode 100644 supabase/functions/ai/sparring-persistence.ts create mode 100644 supabase/migrations/20260830000000_complete_sparring_sessions.sql diff --git a/e2e/authed-pages.spec.ts b/e2e/authed-pages.spec.ts index 5b0fa3ae8..5f48b7a91 100644 --- a/e2e/authed-pages.spec.ts +++ b/e2e/authed-pages.spec.ts @@ -12,6 +12,7 @@ const AUTH_ROUTES = [ { path: '/app/library', name: 'library' }, { path: '/app/history', name: 'history' }, { path: '/app/timer', name: 'timer' }, + { path: '/app/sparring', name: 'sparring' }, { path: '/app/flowchart', name: 'flowchart' }, { path: '/app/settings', name: 'settings' }, ] as const; diff --git a/e2e/sparring.spec.ts b/e2e/sparring.spec.ts new file mode 100644 index 000000000..6096b9498 --- /dev/null +++ b/e2e/sparring.spec.ts @@ -0,0 +1,106 @@ +import { expect, test } from '@playwright/test'; +import { setLocale, stubSupabaseSession } from './helpers/auth'; + +const corsHeaders = { + 'access-control-allow-origin': '*', + 'access-control-allow-headers': 'authorization, x-client-info, apikey, content-type', + 'access-control-allow-methods': 'POST, OPTIONS', + 'content-type': 'application/json', +}; + +const opponentSpeech = (index: number) => ({ + action: 'respond', + speech: { + content: `Opponent response ${index}: the policy needs a stronger comparative case against narrower alternatives.`, + strategy: 'Test the warrant, then weigh the alternative.', + targetedPoints: ['The comparative mechanism is still underdeveloped.'], + }, +}); + +const scorecard = { + action: 'score', + scorecard: { + userScore: 78, + aiScore: 74, + categories: [ + { name: 'Logic', userScore: 20, aiScore: 18, feedback: 'The causal chain was clear.' }, + { name: 'Evidence', userScore: 18, aiScore: 18, feedback: 'The factual warrant needed more support.' }, + { name: 'Delivery', userScore: 19, aiScore: 19, feedback: 'Judged from organization, not vocal performance.' }, + { name: 'Strategy', userScore: 21, aiScore: 19, feedback: 'The user prioritized the central clash.' }, + ], + overallFeedback: 'The user won a narrow edge by weighing the main mechanism.', + keyMoments: ['The rebuttal answered the opponent’s strongest objection.'], + improvementAreas: ['Compare the alternative policy explicitly.'], + winner: 'user', + verdict: 'The user had the edge.', + }, +}; + +test.beforeEach(async ({ page }) => { + await setLocale(page, 'en'); + await stubSupabaseSession(page); +}); + +test('completes an LD sparring round through mocked opponent turns and judging', async ({ page }) => { + let responseNumber = 0; + const actions: string[] = []; + + await page.route('**/functions/v1/ai**', async (route) => { + if (route.request().method() === 'OPTIONS') { + await route.fulfill({ status: 200, headers: corsHeaders, body: 'ok' }); + return; + } + + const body = route.request().postDataJSON() as { input?: { action?: string } }; + const action = body.input?.action ?? 'unknown'; + actions.push(action); + const output = action === 'score' ? scorecard : opponentSpeech(++responseNumber); + await route.fulfill({ status: 200, headers: corsHeaders, body: JSON.stringify({ output }) }); + }); + + await page.goto('/app/sparring', { waitUntil: 'domcontentloaded' }); + await expect(page.getByRole('heading', { name: 'Spar with an opponent who listens.' })).toBeVisible(); + + await page.getByLabel('Motion').fill('This House would make public transport free at the point of use.'); + await page.getByRole('button', { name: /Lincoln-Douglas · 5 speeches/ }).click(); + await page.getByRole('button', { name: 'Start round' }).click(); + + const userSpeeches = [ + ['Affirmative constructive', 'Universal access removes the direct price barrier to participation.'], + ['First affirmative rebuttal', 'Targeted discounts preserve eligibility gaps and administrative friction.'], + ['Second affirmative rebuttal', 'The universal policy is more reliable because every marginal trip is covered.'], + ] as const; + + for (const [turnLabel, content] of userSpeeches) { + const draft = page.getByRole('textbox', { name: `Draft for ${turnLabel}` }); + await expect(draft).toBeVisible(); + await draft.fill(content); + await page.getByRole('button', { name: 'Submit speech' }).click(); + if (turnLabel !== 'Second affirmative rebuttal') { + await expect(page.getByText(`Opponent response ${responseNumber + 1}:`)).toBeVisible(); + } + } + + await expect(page.getByText('Judge is weighing the round')).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Here is what happened in the room.' })).toBeVisible(); + await expect(page.getByText('You won the ballot')).toBeVisible(); + await expect(page.getByText('The user had the edge.')).toBeVisible(); + expect(actions).toEqual(['respond', 'respond', 'score']); + + const hasHorizontalOverflow = await page.evaluate(() => document.documentElement.scrollWidth > document.documentElement.clientWidth + 1); + expect(hasHorizontalOverflow).toBe(false); +}); + +test('keeps the setup usable on mobile with reduced motion', async ({ page }) => { + await page.emulateMedia({ reducedMotion: 'reduce' }); + await page.goto('/app/sparring', { waitUntil: 'domcontentloaded' }); + await expect(page.getByRole('heading', { name: 'Spar with an opponent who listens.' })).toBeVisible(); + + const hasHorizontalOverflow = await page.evaluate(() => document.documentElement.scrollWidth > document.documentElement.clientWidth + 1); + expect(hasHorizontalOverflow).toBe(false); + + const animationNames = await page.locator('[class*="animate-"]').evaluateAll((elements) => elements + .map((element) => getComputedStyle(element).animationName) + .filter((name) => name !== 'none')); + expect(animationNames).toEqual([]); +}); diff --git a/plans/README.md b/plans/README.md index 66599ccf5..f2e355957 100644 --- a/plans/README.md +++ b/plans/README.md @@ -7,7 +7,6 @@ This folder holds implementation plans for remaining DebateLab product work. Kee - [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) @@ -19,6 +18,7 @@ This folder holds implementation plans for remaining DebateLab product work. Kee ## Done +- [Debate sparring](./sparring-and-streaming.md) - [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) diff --git a/plans/sparring-and-streaming.md b/plans/sparring-and-streaming.md index bc2611e38..9cb670141 100644 --- a/plans/sparring-and-streaming.md +++ b/plans/sparring-and-streaming.md @@ -1,5 +1,7 @@ # Debate Sparring Mode — Implementation Plan +**Status:** Implemented locally on 2026-08-30. Production migration and deployment remain separate release operations. + ## Overview A turn-based AI debate practice mode where the user argues one side and the AI argues the other, simulating a real debate round with timed speeches, rebuttals, and scoring. @@ -82,7 +84,17 @@ interface SparringScorecard { - Save completed sessions to History (new `savedSparring` in session store) - Export sparring transcript to Word/PDF -## Estimated Effort +## Delivered + +- Standard, Lincoln-Douglas, and Public Forum turn plans with either user side +- Deadline-based timers, pause/resume/reset, offline-safe tab persistence, retries, and keyboard submit +- Transcript-aware opponent turns, signed AI-turn integrity, atomic/idempotent judging, and a four-category scorecard +- History, settings, navigation, home, auth reset, delete-all, DOCX/PDF export, service-worker preload, and en/es/hu integration +- Unit, integration, Deno, desktop/mobile browser, reduced-motion, overflow, and live provider-contract coverage + +Streaming remains tracked independently in `plans/streaming-ai.md`; it is not part of the turn-based sparring contract. + +## Original Estimated Effort - Setup + Round UI: 4 hours - AI integration (prompts, edge function): 3 hours - Scorecard + judging: 2 hours diff --git a/public/sw.js b/public/sw.js index 3819c7ac8..39edd6368 100644 --- a/public/sw.js +++ b/public/sw.js @@ -12,6 +12,7 @@ const APP_SHELL = [ '/app/ask', '/app/library', '/app/timer', + '/app/sparring', '/app/flowchart', '/app/history', '/app/settings', diff --git a/src/App.tsx b/src/App.tsx index f68db8b2e..359d91e40 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -29,6 +29,7 @@ const SettingsPage = lazy(() => import("./pages/SettingsPage")); const HistoryPage = lazy(() => import("./pages/HistoryPage")); const LibraryPage = lazy(() => import("./pages/LibraryPage")); const TimerPage = lazy(() => import("./pages/TimerPage")); +const SparringPage = lazy(() => import("./pages/SparringPage")); const AdminPage = lazy(() => import("./pages/AdminPage")); const SharedPage = lazy(() => import("./pages/SharedPage")); const FlowchartPage = lazy(() => import("./pages/FlowchartPage")); @@ -121,6 +122,7 @@ function AnimatedRoutes() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/components/features/sparring/SparringRound.tsx b/src/components/features/sparring/SparringRound.tsx new file mode 100644 index 000000000..b70f9705b --- /dev/null +++ b/src/components/features/sparring/SparringRound.tsx @@ -0,0 +1,278 @@ +import { useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { AlertCircle, Bot, Check, ChevronRight, CirclePause, CirclePlay, Clock3, CloudOff, RotateCcw, Send, UserRound, Wifi } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Textarea } from '@/components/ui/textarea'; +import { Badge } from '@/components/ui/badge'; +import { Progress } from '@/components/ui/progress'; +import { Markdown } from '@/components/ui/markdown'; +import { cn } from '@/lib/utils'; +import { formatRoundTime } from '@/lib/sparring/formats'; +import type { SparringSession } from '@/lib/stores/sparring-store'; +import { useSparringStore } from '@/lib/stores/sparring-store'; + +interface SparringRoundProps { + session: SparringSession; + aiLoading?: boolean; + aiError?: string | null; + judging?: boolean; + judgingError?: string | null; + onSubmit: (content: string) => void; + onRetryAi?: () => void; + onRetryJudging?: () => void; +} + +function wordCount(value: string) { + const trimmed = value.trim(); + return trimmed ? trimmed.split(/\s+/).length : 0; +} + +export function SparringRound({ + session, + aiLoading = false, + aiError, + judging = false, + judgingError, + onSubmit, + onRetryAi, + onRetryJudging, +}: SparringRoundProps) { + const { t } = useTranslation(); + const setDraft = useSparringStore((state) => state.setDraft); + const tickTimer = useSparringStore((state) => state.tickTimer); + const pauseTimer = useSparringStore((state) => state.pauseTimer); + const resumeTimer = useSparringStore((state) => state.resumeTimer); + const resetTurnTimer = useSparringStore((state) => state.resetTurnTimer); + const [isOnline, setIsOnline] = useState(() => typeof navigator === 'undefined' || navigator.onLine); + const currentTurn = session.turns[session.currentTurnIndex]; + const currentWordCount = wordCount(session.draft); + const totalWords = useMemo(() => session.speeches.reduce((sum, speech) => sum + wordCount(speech.content), 0), [session.speeches]); + const userTurn = currentTurn?.speaker === 'user'; + const timerProgress = currentTurn?.timeLimitSeconds ? Math.max(0, Math.min(100, (session.timerRemainingSeconds / currentTurn.timeLimitSeconds) * 100)) : 0; + const isUrgent = session.timerRemainingSeconds <= 30 && session.timerRemainingSeconds > 0; + + useEffect(() => { + const handleOnline = () => setIsOnline(true); + const handleOffline = () => setIsOnline(false); + window.addEventListener('online', handleOnline); + window.addEventListener('offline', handleOffline); + return () => { + window.removeEventListener('online', handleOnline); + window.removeEventListener('offline', handleOffline); + }; + }, []); + + useEffect(() => { + if (!userTurn || !session.timerRunning) return; + const intervalId = window.setInterval(() => tickTimer(), 250); + return () => window.clearInterval(intervalId); + }, [session.timerRunning, tickTimer, userTurn]); + + const handleKeyDown = (event: React.KeyboardEvent) => { + if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') { + event.preventDefault(); + if (userTurn && session.draft.trim() && !judging && isOnline) onSubmit(session.draft); + } + }; + + return ( +
+
+
+
+ {session.format.toUpperCase()} + {t('sparring.round.inProgress', 'Round in progress')} + + {session.userSide === 'pro' ? t('sparring.round.youArePro', 'You are Pro') : t('sparring.round.youAreCon', 'You are Con')} +
+

{session.motion}

+
+
+ {isOnline ?
+
+ +
+
+ + +
+
+

{judging ? t('sparring.round.complete', 'Round complete') : currentTurn ? t('sparring.round.turnOf', 'Turn {{current}} of {{total}}', { current: session.currentTurnIndex + 1, total: session.turns.length }) : t('sparring.round.preparingResult', 'Preparing your result')}

+

{judging ? t('sparring.round.judgeReading', 'The judge is reading the transcript.') : currentTurn ? t(`sparring.turns.${currentTurn.id}`, currentTurn.label) : t('sparring.round.scoring', 'Scoring the round')}

+
+
+
+
+ div]:bg-amber-500' : '[&>div]:bg-primary')} aria-label={t('sparring.round.timeRemaining', 'Time remaining')} /> + {!judging && userTurn && ( +
+ + + {t('sparring.round.clockNote', 'The clock uses the saved deadline, not tab focus.')} +
+ )} +
+
+ + {judging ? ( + + + {judgingError ? ( + <> +
+

{t('sparring.round.scoreFailed', 'Could not score this round')}

+

{judgingError}

+ + + ) : ( + <> + +

{t('sparring.round.judgeWeighing', 'Judge is weighing the round')}

+

{t('sparring.round.judgeDescription', 'Reading the claims, responses, and strategic choices. This usually takes one request.')}

+ + )} +
+
+ ) : ( + <> + {userTurn ? ( + + +
+
+ +

{t('sparring.round.yourSpeechDescription', 'Make the case in the time you have. The opponent will respond to this text.')}

+
+ {t('sparring.round.wordCount', '{{count}} words', { count: currentWordCount })} +
+
+ +