Repository navigation
feat: add sparring training loop #11
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from all commits
Commits
Show all changes
84 commits
Select commit
Hold shift + click to select a range
00a5cc4
docs: align roadmap with current product state
Programcode 354368c
feat: add sparring domain schemas
Programcode c117384
feat: add local-first sparring history store
Programcode 2876b9a
feat: add grounded sparring AI client
Programcode 8bc104d
feat: add sparring model configuration
Programcode 05165a5
feat: add sparring setup UI
Programcode 0b46889
feat: add playable sparring round UI
Programcode be47464
feat: add evidence-backed sparring scorecard UI
Programcode 1f16460
feat: wire complete sparring training loop
Programcode ad26d94
feat: route sparring mode
Programcode a81a6ef
feat: add sparring to app navigation
Programcode a2f7c21
feat: surface sparring from app home
Programcode b296e3a
feat: integrate sparring rounds into history
Programcode e688cce
feat: add sparring transcript exports
Programcode d2d900d
feat: add sparring Word and PDF actions
Programcode f039e59
feat: export and discuss sparring feedback
Programcode 2cdd273
fix: keep sparring source grounding controls honest
Programcode 6e0bf88
test: cover sparring local history store
Programcode 19454fc
test: cover sparring opponent and scorecard contracts
Programcode 7cc1b3c
test: include sparring in authenticated route smoke
Programcode ae2239e
test: add deterministic sparring browser journey
Programcode b00f258
fix: export sparring phase type
Programcode 4e924cb
fix: validate persisted sparring history
Programcode 3e5b340
fix: clear sparring data across auth boundaries
Programcode d4752dd
fix: make sparring source grounding explicit
Programcode e07749a
fix: allow retrying failed sparring opponent turns
Programcode e513019
feat: respect output language in sparring prompts
Programcode 1371e2e
feat: connect sparring feedback to prep tools
Programcode 5e98910
feat: complete sparring recovery and tool handoffs
Programcode 8d71154
fix: provide sparring navigation copy fallback
Programcode 2bfe695
fix: render sparring navigation labels cleanly
Programcode e99708d
test: cover sparring persistence safety
Programcode 584f120
test: cover sparring recovery and tool handoff
Programcode f16f4d6
test: retain browser failure evidence
Programcode ba83fad
test: make verify include browser journeys
Programcode 5a1f83f
ci: gate pull requests on browser verification
Programcode b1b99c0
docs: add agent verification contract
Programcode 47d1264
test: support deterministic staging frontend selection
Programcode 4dd6ede
fix: preserve staging anon credential exactly
Programcode fac1ec3
test: isolate stateful staging browser suite
Programcode f815fba
test: prepare opt-in authenticated staging suite
Programcode fd85ceb
test: add real staging auth setup
Programcode 820e9df
test: add authenticated staging canary
Programcode 6b0435f
chore: ignore staging browser artifacts
Programcode 7031d4b
test: expose opt-in staging verification command
Programcode c866b67
docs: document stateful staging verification
Programcode 029f535
security: bound source-import body read time
Programcode 5654a4c
test: cover bounded source response consumption
Programcode d4f6a85
security: move source import behind fixed Exa egress
Programcode d505148
security: remove misleading pre-fetch DNS check helper
Programcode db005a5
test: prevent arbitrary source-import egress regression
Programcode 927c7e5
fix: persist preferences without privileged insert
Programcode 1baac07
fix: restore output-language preference privilege
Programcode f2eabb8
test: enforce least-privilege settings persistence
Programcode b7bcb28
test: update security invariants for fixed egress
Programcode 0887417
fix: scope atomic daily counters by workload
Programcode 64d6991
fix: isolate source-import quota reservations
Programcode db1cb9d
test: match executable fetch calls only
Programcode e812a42
test: match executable importer fetch calls only
Programcode 29c72d8
test: isolate and cover sparring output language
Programcode 6093cb9
feat: model Exa search filters as request fields
Programcode 9966b71
feat: use Exa domain and category filters
Programcode 0414e29
test: cover first-class Exa source filters
Programcode 1535762
test: verify Exa filter request bodies and fallback
Programcode 87f4cb1
feat: preserve Factoids consulted-source provenance
Programcode f8da4ad
feat: show Factoids consulted-source provenance
Programcode 5431309
feat: render all Factoids consulted sources
Programcode 3c34dd3
test: keep Factoids page compatible with legacy mocks
Programcode 1ffe13a
test: cover Factoids consulted-source quality panel
Programcode 7bf6203
test: preserve Factoids discovered-source provenance
Programcode ce037ea
test: support Deno serve handler overload in Vitest
Programcode bfa93c3
test: add controllable Supabase Edge import adapter
Programcode 5d8f0c1
test: resolve Edge Supabase imports in Vitest
Programcode daf90d6
test: exercise Factoids research through Edge handler boundary
Programcode b631db3
fix: use current Exa contents field names
Programcode b7b1438
fix: classify filtered public-sector domains consistently
Programcode 5f2ce5c
test: lock current Exa contents request shape
Programcode 18b120c
docs: close search quality implementation plan
Programcode 3187544
chore: track agent and security review docs
Programcode ea86c3a
security: record post-remediation source validation
Programcode 38b97fa
docs: mark branch implementation phase complete
Programcode eac91c7
test: avoid source-filter reference-equality assumptions
Programcode 52c4224
fix: avoid duplicating sparring Ask handoff prompt
Programcode 10d5a95
test: verify sparring Ask handoff history
Programcode File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,149 @@ | ||
| # DebateLab Agent Guide | ||
|
|
||
| This file is the verification contract for coding agents working in DebateLab. Read it before changing code. | ||
|
|
||
| ## Runtime and package manager | ||
|
|
||
| - Node.js: 22.x | ||
| - Package manager: npm (`package-lock.json` is canonical) | ||
| - Web app dev port: 8080 by default | ||
| - Local Supabase port used by existing tooling: 54321 | ||
| - Production web app: `https://debate.pi-stack.org` | ||
| - Frontend: Vite + React + TypeScript | ||
| - Backend source: Supabase Edge Functions and SQL migrations under `supabase/` | ||
|
|
||
| ## Canonical verification | ||
|
|
||
| After `npm ci`, install the browser once on a new machine: | ||
|
|
||
| ```bash | ||
| npx playwright install chromium | ||
| ``` | ||
|
|
||
| On Linux CI or a fresh Linux host where system browser dependencies are missing: | ||
|
|
||
| ```bash | ||
| npx playwright install --with-deps chromium | ||
| ``` | ||
|
|
||
| Then the canonical branch verification command is: | ||
|
|
||
| ```bash | ||
| npm run verify | ||
| ``` | ||
|
|
||
| `npm run verify` must run, in order: | ||
|
|
||
| 1. ESLint | ||
| 2. TypeScript typechecking | ||
| 3. Vitest with the configured coverage thresholds | ||
| 4. Production Vite build | ||
| 5. Playwright deterministic browser journeys | ||
|
|
||
| Useful narrower commands: | ||
|
|
||
| ```bash | ||
| npm run verify:core | ||
| npm run lint | ||
| npm run typecheck | ||
| npm test | ||
| npm run test:coverage | ||
| npm run build | ||
| npm run test:e2e | ||
| npm run test:e2e:ui | ||
| ``` | ||
|
|
||
| Do not report a change as verified if the relevant command did not execute. A GitHub Actions job that failed before obtaining a runner, with no executed steps, is infrastructure failure rather than a failed test result. | ||
|
|
||
| ## Verification ladder | ||
|
|
||
| Use the smallest useful rung while developing, then run the full gate before considering the work complete: | ||
|
|
||
| 1. Static checks for edited TypeScript/React. | ||
| 2. Focused Vitest tests for the changed behavior. | ||
| 3. Full coverage-gated Vitest suite. | ||
| 4. Production build. | ||
| 5. Deterministic Playwright browser journeys. | ||
| 6. Live authenticated staging checks only when explicitly authorized and staging credentials already exist. | ||
| 7. Manual browser inspection only for visual judgment that automated assertions cannot establish. | ||
|
|
||
| Every bug fix should gain a failing regression test first when practical. Never weaken an assertion or coverage threshold merely to make a branch green. | ||
|
|
||
| ## Browser failure evidence | ||
|
|
||
| Playwright retains trace, screenshot, and video for failures. CI uploads `test-results/` and `playwright-report/` as a short-lived artifact when verification fails. | ||
|
|
||
| When describing verification evidence, name the command or CI run. Do not say that a browser flow passed based only on code review. | ||
|
|
||
| ## Authentication and environment model | ||
|
|
||
| Supabase credentials are selected in `src/lib/supabase/env.ts`. | ||
|
|
||
| - Production project ref: `uziwdtkzofptihahfooo` | ||
| - Staging project ref: `brifeyypjauyusmkptkx` | ||
| - Runtime storage key: `debate-supabase-env` | ||
| - Auth storage is environment-specific. | ||
|
|
||
| The normal Playwright suite uses a deliberately fake localStorage session and mocks network behavior where a product journey needs backend output. That suite is safe for pull requests because it receives no privileged credential and does not mutate Supabase. | ||
|
|
||
| A real staging-authenticated suite, when present, is a separate opt-in verification path. It must never run on untrusted pull-request code with staging secrets. | ||
|
|
||
| ## Supabase deployment boundary | ||
|
|
||
| Source files under `supabase/functions/` and `supabase/migrations/` may be reviewed and changed when a task requires it, but source control and deployment are separate operations. | ||
|
|
||
| **Do not perform any of these unless the user explicitly authorizes that exact operation:** | ||
|
|
||
| - `supabase db push` | ||
| - applying a migration to local/staging/production through a connected remote project | ||
| - `supabase functions deploy` | ||
| - `supabase secrets set` | ||
| - linking or relinking the repository to a different remote project for deployment | ||
| - modifying production or staging rows as a substitute for a test fixture | ||
|
|
||
| For the `feature/sparring-verification-retention-loop` work, all Supabase deployment and mutation is explicitly out of scope. Keep migrations/functions un-applied and use deterministic mocks for executable CI. | ||
|
|
||
| The existing `demo-code-login` endpoint is stateful: it reserves a demo-code use and may provision/update a user. Do not invoke it merely to prove a branch works when remote Supabase mutation is not authorized. | ||
|
|
||
| ## Secrets | ||
|
|
||
| Never commit, print, paste into a PR comment, or expose in test artifacts: | ||
|
|
||
| - service-role keys | ||
| - provider API keys | ||
| - Stripe secrets | ||
| - demo-code pepper values | ||
| - plaintext reusable demo access codes | ||
|
|
||
| Public Supabase anon credentials are browser credentials, not server secrets, but still do not invent or rotate them casually. | ||
|
|
||
| ## AI product rules | ||
|
|
||
| DebateLab is a debate-preparation and training product. User-facing AI behavior should preserve these invariants: | ||
|
|
||
| - Do not fabricate evidence, citations, statistics, quotations, or source metadata. | ||
| - Weak research sources are fallback context, not citation-quality evidence. | ||
| - Controls must be honest: a visible toggle must actually change the request or deterministic post-processing behavior. | ||
| - Typed Sparring cannot honestly judge vocal delivery, eye contact, speaking pace, or other audio-only qualities. | ||
| - Prefer transcript-specific feedback over unexplained numeric scores. | ||
| - Active Source Library items are factual grounding context; make that behavior visible rather than pretending a disabled control exists. | ||
|
|
||
| ## Sparring architecture | ||
|
|
||
| Sparring deliberately avoids adding another feature branch to the large `supabase/functions/ai/index.ts` handler. | ||
|
|
||
| - Schema: `src/lib/ai/schemas/sparring.ts` | ||
| - AI orchestration: `src/lib/ai/sparring-client.ts` | ||
| - Local persisted history: `src/lib/stores/sparring-store.ts` | ||
| - UI: `src/components/features/sparring/` and `src/pages/SparringPage.tsx` | ||
| - Export: `src/lib/sparring-export.ts` | ||
|
|
||
| The first version is a four-speech loop: user opening, AI response, user rebuttal, AI response, then a qualitative scorecard. Completed sessions are local-first and must be cleared at auth boundaries so one browser account cannot inherit another account's transcripts. | ||
|
|
||
| ## Roadmap authority | ||
|
|
||
| `plans/README.md` is the roadmap index, but the source tree is authoritative. Before implementing a plan, check whether the described files/behavior already exist. When a plan is substantially implemented, update the index so future agents do not repeatedly rebuild shipped work. | ||
|
|
||
| ## Pull requests and merging | ||
|
|
||
| A draft PR may be used to obtain CI evidence. Do not merge a PR unless the user explicitly requests a merge. Do not enable auto-merge as a substitute for explicit authorization. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,144 @@ | ||
| import { test, expect } from '@playwright/test'; | ||
| import { stubSupabaseSession, setLocale } from './helpers/auth'; | ||
|
|
||
| const scorecard = { | ||
| verdict: 'user-edge', | ||
| summary: 'The user won the central mechanism clash and did more comparative weighing in rebuttal.', | ||
| biggestImprovement: 'Front-load the comparison against narrower alternatives in the opening.', | ||
| strengths: ['The rebuttal directly answered the opponent mechanism.'], | ||
| missedArguments: ['The opening did not preempt the narrower-policy alternative.'], | ||
| categories: [ | ||
| { category: 'argument-strength', rating: 'strong', evidence: 'The user established a clear participation benefit.', advice: 'Keep the causal claim this explicit.' }, | ||
| { category: 'warrant-quality', rating: 'solid', evidence: 'The participation mechanism was plausible but brief.', advice: 'Add one more causal link.' }, | ||
| { category: 'impact-analysis', rating: 'solid', evidence: 'The user explained why access matters but did not compare scale in the opening.', advice: 'Make the affected population comparative.' }, | ||
| { category: 'engagement', rating: 'strong', evidence: 'The rebuttal answered the opponent funding alternative directly.', advice: 'Keep organizing rebuttal around their offense.' }, | ||
| { category: 'rebuttal-efficiency', rating: 'strong', evidence: 'The response spent its time on the decisive alternative-policy clash.', advice: 'Preserve this collapse discipline.' }, | ||
| { category: 'weighing', rating: 'solid', evidence: 'The user compared access, but not probability explicitly.', advice: 'Add a one-sentence probability comparison.' }, | ||
| { category: 'evidence-use', rating: 'needs-work', evidence: 'No source was cited in the typed round.', advice: 'Use evidence only where it resolves a contested factual warrant.' }, | ||
| { category: 'strategy', rating: 'strong', evidence: 'The rebuttal collapsed onto why the alternative did not solve access.', advice: 'Preempt that alternative earlier next time.' }, | ||
| ], | ||
| nextDrill: 'Give a 60-second opening that preempts one narrower counter-policy before the opponent raises it.', | ||
| }; | ||
|
|
||
| function askEnvelope(response: string) { | ||
| return JSON.stringify({ output: { response, followUpQuestions: [], uncertainties: [] } }); | ||
| } | ||
|
|
||
| const corsHeaders = { | ||
| 'access-control-allow-origin': '*', | ||
| 'access-control-allow-headers': 'authorization, x-client-info, apikey, content-type', | ||
| 'content-type': 'application/json', | ||
| }; | ||
|
|
||
| test.beforeEach(async ({ page }) => { | ||
| await setLocale(page, 'en'); | ||
| await stubSupabaseSession(page); | ||
| }); | ||
|
|
||
| test('sparring completes the training loop and hands the round into existing tools', async ({ page }) => { | ||
| let aiCall = 0; | ||
| let discussInput: { message?: string; history?: Array<{ role?: string; content?: string }> } | undefined; | ||
|
|
||
| await page.route('**/functions/v1/ai', async (route) => { | ||
| if (route.request().method() === 'OPTIONS') { | ||
| await route.fulfill({ status: 200, headers: corsHeaders, body: 'ok' }); | ||
| return; | ||
| } | ||
|
|
||
| aiCall += 1; | ||
| if (aiCall === 4) { | ||
| const requestBody = route.request().postDataJSON() as { input?: typeof discussInput }; | ||
| discussInput = requestBody.input; | ||
| await route.fulfill({ | ||
| status: 200, | ||
| headers: corsHeaders, | ||
| body: askEnvelope('Focus the next drill on comparing universal access against targeted alternatives.'), | ||
| }); | ||
| return; | ||
| } | ||
|
|
||
| const response = aiCall === 1 | ||
| ? JSON.stringify({ | ||
| speech: 'A universal free-transit policy is expensive. Targeted discounts can improve access while preserving funding for service quality.', | ||
| pressurePoints: ['You have not shown why targeted support cannot solve the same access problem.'], | ||
| }) | ||
| : aiCall === 2 | ||
| ? JSON.stringify({ | ||
| speech: 'You still need to prove that universal access creates more benefit than investing the same budget in frequency and reliability.', | ||
| pressurePoints: ['The budget tradeoff is still underdeveloped.'], | ||
| }) | ||
| : JSON.stringify(scorecard); | ||
|
|
||
| await route.fulfill({ status: 200, headers: corsHeaders, body: askEnvelope(response) }); | ||
| }); | ||
|
|
||
| await page.goto('/app/sparring'); | ||
| await page.getByLabel('Motion').fill('This House would make public transport free at the point of use.'); | ||
| await page.getByRole('button', { name: 'Start sparring' }).click(); | ||
|
|
||
| const speechBox = page.locator('textarea').first(); | ||
| const opening = 'Free public transport removes a direct price barrier and lets more people participate in work, education, and public life.'; | ||
| await speechBox.fill(opening); | ||
| await page.getByRole('button', { name: 'Send opening' }).click(); | ||
| await expect(page.getByText(/universal free-transit policy is expensive/i)).toBeVisible(); | ||
| await expect(page.getByText(/targeted support/i)).toBeVisible(); | ||
|
|
||
| await speechBox.fill('Targeted discounts preserve eligibility gaps and administrative friction, while universal access removes the barrier for every marginal trip.'); | ||
| await page.getByRole('button', { name: 'Send rebuttal' }).click(); | ||
| await expect(page.getByText(/frequency and reliability/i)).toBeVisible(); | ||
|
|
||
| await page.getByRole('button', { name: 'Finish and get scorecard' }).click(); | ||
| await expect(page.getByText('Your side had the edge')).toBeVisible(); | ||
| await expect(page.getByText(/Front-load the comparison against narrower alternatives/i)).toBeVisible(); | ||
| await expect(page.getByText('Weighing')).toBeVisible(); | ||
| await expect(page.getByRole('button', { name: 'Rematch this motion' })).toBeVisible(); | ||
| expect(aiCall).toBe(3); | ||
|
|
||
| await page.getByRole('button', { name: 'Attack my round' }).click(); | ||
| await expect(page).toHaveURL(/\/app\/attack$/); | ||
| await expect(page.locator('#caseText')).toHaveValue(new RegExp(opening.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); | ||
|
|
||
| await page.goto('/app/sparring'); | ||
| await page.getByRole('button', { name: 'Discuss with Ask' }).click(); | ||
| await expect(page).toHaveURL(/\/app\/ask$/); | ||
| await expect(page.getByText(/Focus the next drill on comparing universal access/i)).toBeVisible(); | ||
|
|
||
| expect(discussInput?.message).toContain('I just finished a DebateLab sparring round'); | ||
| expect(discussInput?.history?.some((entry) => entry.content === discussInput?.message)).toBe(false); | ||
| expect(aiCall).toBe(4); | ||
| }); | ||
|
|
||
| test('sparring can retry a failed opponent response without losing the user speech', async ({ page }) => { | ||
| let aiCall = 0; | ||
| await page.route('**/functions/v1/ai', async (route) => { | ||
| if (route.request().method() === 'OPTIONS') { | ||
| await route.fulfill({ status: 200, headers: corsHeaders, body: 'ok' }); | ||
| return; | ||
| } | ||
|
|
||
| aiCall += 1; | ||
| const response = aiCall === 1 | ||
| ? '' | ||
| : JSON.stringify({ | ||
| speech: 'A narrower subsidy still leaves eligibility friction, but you need to show why that friction is large enough to justify universality.', | ||
| pressurePoints: ['Quantify or comparatively explain the eligibility-friction harm.'], | ||
| }); | ||
| await route.fulfill({ status: 200, headers: corsHeaders, body: askEnvelope(response) }); | ||
| }); | ||
|
|
||
| await page.goto('/app/sparring'); | ||
| await page.getByLabel('Motion').fill('This House would make public transport free at the point of use.'); | ||
| await page.getByRole('button', { name: 'Start sparring' }).click(); | ||
|
|
||
| const opening = 'Universal access removes price and eligibility friction at the same time, increasing participation across marginal trips.'; | ||
| await page.locator('textarea').first().fill(opening); | ||
| await page.getByRole('button', { name: 'Send opening' }).click(); | ||
|
|
||
| await expect(page.getByText(opening)).toBeVisible(); | ||
| await expect(page.getByRole('button', { name: 'Retry opponent' })).toBeVisible(); | ||
| await page.getByRole('button', { name: 'Retry opponent' }).click(); | ||
|
|
||
| await expect(page.getByText(/narrower subsidy still leaves eligibility friction/i)).toBeVisible(); | ||
| await expect(page.getByText(opening)).toHaveCount(1); | ||
| expect(aiCall).toBe(2); | ||
| }); | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win
Dispatch the mocked AI responses on the request body, not on the call ordinal.
aiCallmaps request number 1, 2, 3, and 4 to opponent, opponent, scorecard, and Ask. Any additional request, for example a schema repair retry inrequestSparringScorecard, shifts every later mapping and the failure appears as an unrelated assertion error. Key the mock on a discriminator inroute.request().postDataJSON()instead, and keep the existing strict call-count assertions.page.locator('textarea').first()is also position dependent. A stable accessible name or test id survives layout changes inSparringRound.Also applies to: 79-79
🤖 Prompt for AI Agents