From 9ec1e4dafca9252d76525dc48185baaa71554fd4 Mon Sep 17 00:00:00 2001 From: Adam Date: Tue, 27 Oct 2026 09:07:34 +0000 Subject: [PATCH 01/12] WIP: restructure generated debate cases Co-Authored-By: Claude Opus 5.5 --- .../features/case-generation.md | 7 +- .../features/live-generation.md | 4 +- .../recipes/case-generation.spec.ts | 54 ++- .../recipes/live-generation.spec.ts | 2 +- agentic/case-generation.e2e.ts | 4 +- e2e/fixtures/ai-responses.ts | 17 +- e2e/full-stack/fake-openrouter.mjs | 57 ++- packages/core/src/ai/case-view.ts | 152 ++++++++ packages/core/src/ai/schemas/generate.ts | 56 ++- packages/core/src/crossfire/prefill.ts | 24 +- .../features/generate/GenerateResults.tsx | 350 ++++++++++-------- src/components/features/tools/discuss.ts | 4 +- .../features/tools/live-sections.ts | 36 +- src/components/features/tools/result-text.ts | 41 +- src/lib/ai/features/generate.ts | 37 +- src/lib/export-utils.ts | 159 +++----- src/lib/flowchart.ts | 39 +- src/lib/pdf-export-utils.ts | 97 +++-- src/locales/en.json | 36 +- src/locales/es.json | 36 +- src/locales/hu.json | 36 +- src/pages/GeneratePage.tsx | 11 +- .../functions/_shared/control-enforcement.ts | 75 +++- supabase/functions/_shared/debate-context.ts | 9 +- .../functions/_shared/prompts/generate.ts | 142 ++++--- supabase/functions/ai/features/generate.ts | 9 +- supabase/functions/export-gdoc/index.ts | 196 +++++----- 27 files changed, 1058 insertions(+), 632 deletions(-) create mode 100644 packages/core/src/ai/case-view.ts diff --git a/.claude/skills/verify-debatelab/features/case-generation.md b/.claude/skills/verify-debatelab/features/case-generation.md index d0faa56c3..ad426bd96 100644 --- a/.claude/skills/verify-debatelab/features/case-generation.md +++ b/.claude/skills/verify-debatelab/features/case-generation.md @@ -1,6 +1,6 @@ # Case generation -A signed-in user enters a motion on Generate and receives a structured debate case: thesis, framework, contentions, the predicted opponent case, and counters. The case can be saved to the account and reopened from History, or exported as PDF or DOCX. +A signed-in user enters a motion on Generate and receives a structured debate case: an opening (hook and "we will show N arguments" line), arguments (title, point, explanation, evidence, why it matters), an "If they say…" list, a closing line and a sources list. The role shapes it: the Introductor may add one definition line, R2 has more "If they say…" answers, and the Conclusor has 2-3 "Why we win" points instead of new arguments. Saved cases from before still show their old sections (framework, burden of proof, definitions, predicted arguments) when they have content. The case can be saved to the account and reopened from History, or exported as PDF or DOCX. ## Sub-features @@ -10,8 +10,9 @@ A signed-in user enters a motion on Generate and receives a structured debate ca - `gen-quota` shows the dialog `Daily Access Paused` when the account has no quota, without calling the provider. - `gen-save` saves a one-off case (the motion dialog answered with `Not now, use it once`) under a title into `cases` with `debate_id: null`. It appears in History under `One-off and unsorted work`, including from a fresh browser context. - `gen-regenerate` regenerates one section. Only that section shows the overlay `Regenerating
…`; the rest of the case and the result toolbar stay on screen, and every regenerate button and the form's submit button are disabled until it finishes. +- `gen-roles` shows the role's shape: on `/app/generate` pick the `Speaker Role` option `Introductor (4 min)` and the Opening card holds a `What we mean:` line; pick `R2 - Rebuttal/Build (6 min)` and `If they say…` lists six answers; pick `Conclusor (3 min)` and `Why we win` replaces `Arguments`. - `gen-export` downloads a `.pdf` (starting with `%PDF`) and a `.docx` (starting with `PK`). -- `gen-flowchart` builds a flowchart from the Current Debate's case. `/app/flowchart` opens the template; the button `Build from current case` replaces it with the thesis, one node per contention (claim and evidence), the predicted attacks, and the counters, fitted to the canvas. Node titles the app wrote (`Thesis`, `Argument 1`) follow the UI language. +- `gen-flowchart` builds a flowchart from the Current Debate's case. `/app/flowchart` opens the template; the button `Build from current case` replaces it with the thesis, one node per argument (title, point and evidence, or a Conclusor's reasons why we win), what the other side will say, and our answers, fitted to the canvas. Node titles the app wrote (`Thesis`, `Argument 1`) follow the UI language. ## How to get to it (user POV) @@ -33,7 +34,7 @@ Preconditions: - **Cut off.** Call `await fake.truncate("generate")`, start the debate, and generate. The first reply stops halfway with `finish_reason: "length"`; the Edge Function retries once with a larger cap, and the case appears with no error. `fake.requests()` has two `generate` entries, the second with a larger `maxTokens`. Fake mode only. - **Cancel.** While a generation runs, the results column shows `Writing your case…` and a `Cancel` button. Cancel stops the request and shows no error. - **Quota.** Update `user_settings` to set `daily_limit_override: 0` through `admin`, start the debate, then generate. The dialog shows `Daily Access Paused`. `fake.requests()` has no `generate` entry. -- **Regenerate a section.** After generating, click the button named `Regenerate Arguments` (the section titles are `Case Structure`, `Arguments`, `Predicted Opponent Case`, `Prepared Counters`). Hold the request with `page.route("**/functions/v1/ai", ...)` to check the in-flight state: a `status` with `Regenerating Arguments` is visible and `Regenerate Case Structure` is disabled. The toast `Section regenerated` follows. A full generation shows no toast: the result appears in place (and scrolls into view on phones). +- **Regenerate a section.** After generating, click the button named `Regenerate Arguments` (the section titles are `Opening`, `Arguments`, `If they say…` and `Closing line`; a Conclusor case shows `Why we win` instead of `Arguments` and `If they say…`). Hold the request with `page.route("**/functions/v1/ai", ...)` to check the in-flight state: a `status` with `Regenerating Arguments` is visible and `Regenerate Opening` is disabled. The toast `Section regenerated` follows. A full generation shows no toast: the result appears in place (and scrolls into view on phones). - **Flowchart.** After generating in the debate, open `/app/flowchart` and click the button named `Build from current case` (Hungarian: `Létrehozás a jelenlegi esetből`). It appears only when the case belongs to the Current Debate, or to one-off work while no debate is chosen. The contention claims are visible, and an edge is a button named `From Thesis to Argument 1`. - **Export.** Wait for `page.waitForEvent("download")` while clicking the button named `PDF` or `DOCX` (use `exact`). Check the suggested filename's extension and the file's magic bytes. diff --git a/.claude/skills/verify-debatelab/features/live-generation.md b/.claude/skills/verify-debatelab/features/live-generation.md index 110fca620..aa0d74b00 100644 --- a/.claude/skills/verify-debatelab/features/live-generation.md +++ b/.claude/skills/verify-debatelab/features/live-generation.md @@ -4,7 +4,7 @@ Every AI answer appears while the model writes it. Generate, Attack, Brainstorm, ## Sub-features -- `live-generate`: Generate's draft shows `Case Structure` and later sections before the result, then the full result and its toolbar replace it. +- `live-generate`: Generate's draft shows `Opening` and later sections before the result, then the full result and its toolbar replace it. - `live-cancel`: `Cancel` on the draft stops the request; no draft, result, or error stays on the page. - `live-tools`: Attack, Brainstorm, Factoids, and cited Ask each ask for live generation and receive an event stream. - `live-practice`: Crossfire turns and debriefs and Sparring speeches and scorecards ask for live generation (checked in `recipes/practice.spec.ts` with `expectStreamed`). @@ -20,7 +20,7 @@ Preconditions: - Doctor passes. The user is signed in. `startDebate(page, motion)` avoids the motion dialog. -- **Draft.** Click `Generate debate case`. `getByTestId("live-draft")` appears, holding a region named `Case Structure`, one `live-caret`, and a `Cancel` button. It disappears when the result renders. +- **Draft.** Click `Generate debate case`. `getByTestId("live-draft")` appears, holding a region named `Opening`, one `live-caret`, and a `Cancel` button. It disappears when the result renders. - **Request.** The page's `ai` request body has `stream: true`. The response is `text/event-stream`: `phase` events (`research`, `writing`, `checking`), many `delta` events with the model's raw text, an optional `reset` when a repair retry starts the answer again, and exactly one final `result` event `{status, body}` holding what a plain request would return. Read it with `readAIResponse(response)`. - **Provider.** `expectStreamed(["generate"])`: every provider call for the feature had `stream: true`. Research sub-calls (`research-decision`, `research-queries`) are never streamed. - **Errors.** Checks before the feature (auth, input, quota, model access) still answer with plain JSON and their real status. A failure after streaming starts arrives as a `result` with the error status, which the client turns into the same error a plain request gets. diff --git a/.claude/skills/verify-debatelab/recipes/case-generation.spec.ts b/.claude/skills/verify-debatelab/recipes/case-generation.spec.ts index 0d9aa3233..27d4c4982 100644 --- a/.claude/skills/verify-debatelab/recipes/case-generation.spec.ts +++ b/.claude/skills/verify-debatelab/recipes/case-generation.spec.ts @@ -1,5 +1,5 @@ import { readFile } from "node:fs/promises"; -import { admin, AI_TIMEOUT, aiResponse, chooseForMotion, type Evidence, expect, expectProviderCalls, fake, LIVE, signIn, startDebate, test } from "./fixtures"; +import { admin, AI_TIMEOUT, aiResponse, chooseForMotion, type Evidence, expect, expectProviderCalls, fake, LIVE, providerCalls, signIn, startDebate, test } from "./fixtures"; // Feature map: features/case-generation.md. const motion = "This House would make public transport free at the point of use"; @@ -149,6 +149,54 @@ test("gen-flowchart: the Flowchart builds from the Current Debate's case, and it await evidence.shot("02-built-from-case-hu"); }); +test("gen-roles: the speaker role shapes the case: Introductor, R2 and Conclusor", async ({ signedIn: page, evidence }) => { + await openGenerate(page); + const pickRole = async (name: string) => { + await page.getByRole("combobox", { name: "Speaker Role" }).click(); + await page.getByRole("option", { name }).click(); + }; + const run = async (role: string) => { + const response = aiResponse(page, "generate"); + await page.getByRole("button", { name: "Generate debate case" }).click(); + const body = await response; + const call = (await providerCalls()).filter((item) => item.feature === "generate").at(-1); + expect(call?.user).toContain(`Role: ${role}`); + return body.output as unknown as CaseOutput & { opening?: { definition?: string }; counters: unknown[]; whyWeWin?: string[]; closingLine?: string }; + }; + + await pickRole("Introductor (4 min)"); + const intro = await run("intro"); + await expect(page.getByText(intro.caseStructure.thesis).first()).toBeVisible(); + await expect(page.getByRole("button", { name: "Regenerate Opening" })).toBeVisible(); + if (intro.opening?.definition) await expect(page.getByText("What we mean:")).toBeVisible(); + if (!LIVE) expect(intro.opening?.definition).toBeTruthy(); + await evidence.shot("01-introductor"); + await evidence.aria("01-introductor"); + + await pickRole("R2 - Rebuttal/Build (6 min)"); + const r2 = await run("r2"); + await expect(page.getByText(r2.caseStructure.thesis).first()).toBeVisible(); + // Only the Introductor defines a term. + expect(r2.opening?.definition).toBeFalsy(); + await expect(page.getByText("What we mean:")).toHaveCount(0); + await expect(page.getByRole("button", { name: "Regenerate If they say…" })).toBeVisible(); + await expect(page.getByText("They say", { exact: true })).toHaveCount(r2.counters.length); + if (!LIVE) expect(r2.counters).toHaveLength(6); + await evidence.shot("02-r2"); + await evidence.aria("02-r2"); + + await pickRole("Conclusor (3 min)"); + const conc = await run("conc"); + expect(conc.contentions).toEqual([]); + expect(conc.whyWeWin?.length ?? 0).toBeGreaterThanOrEqual(2); + await expect(page.getByRole("button", { name: "Regenerate Why we win" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Regenerate Arguments" })).toHaveCount(0); + for (const reason of conc.whyWeWin ?? []) await expect(page.getByText(reason).first()).toBeVisible(); + if (conc.closingLine) await expect(page.getByText(conc.closingLine).first()).toBeVisible(); + await evidence.shot("03-conclusor"); + await evidence.aria("03-conclusor"); +}); + test("gen-research: generation researches the web and cites what it found", async ({ signedIn: page, evidence }) => { const { body } = await generate(page); await evidence.json("01-generate-response", body); @@ -202,7 +250,7 @@ test("gen-regenerate: regenerating one section keeps the rest of the case and it await expect(overlay).toBeVisible(); await expect(page.getByText(thesis).first()).toBeVisible(); await expect(page.getByRole("button", { name: "PDF", exact: true })).toBeVisible(); - await expect(page.getByRole("button", { name: "Regenerate Case Structure" })).toBeDisabled(); + await expect(page.getByRole("button", { name: "Regenerate Opening" })).toBeDisabled(); // The form button reads "Generating..." while busy, so find it by its tour anchor. await expect(page.locator('[data-tour="generate-button"]')).toBeDisabled(); await evidence.shot("02-regenerating-one-section"); @@ -214,7 +262,7 @@ test("gen-regenerate: regenerating one section keeps the rest of the case and it await expect(page.getByText("Section regenerated").first()).toBeVisible({ timeout: AI_TIMEOUT }); await expect(overlay).toHaveCount(0); await expect(page.getByText(thesis).first()).toBeVisible(); - await expect(page.getByRole("button", { name: "Regenerate Case Structure" })).toBeEnabled(); + await expect(page.getByRole("button", { name: "Regenerate Opening" })).toBeEnabled(); await evidence.shot("03-regenerated"); }); diff --git a/.claude/skills/verify-debatelab/recipes/live-generation.spec.ts b/.claude/skills/verify-debatelab/recipes/live-generation.spec.ts index 6affc1e96..cd8390982 100644 --- a/.claude/skills/verify-debatelab/recipes/live-generation.spec.ts +++ b/.claude/skills/verify-debatelab/recipes/live-generation.spec.ts @@ -43,7 +43,7 @@ test("live-generate: a case fills in section by section while the model writes i // Before the answer is complete, the draft shows what is written so far. const draft = page.getByTestId("live-draft"); await expect(draft).toBeVisible({ timeout: AI_TIMEOUT }); - await expect(draft.getByRole("region", { name: "Case Structure" })).toBeVisible(); + await expect(draft.getByRole("region", { name: "Opening" })).toBeVisible(); await expect(draft.getByTestId("live-caret")).toHaveCount(1); await expect(draft.getByRole("button", { name: "Cancel" })).toBeVisible(); await evidence.shot("01-writing-live"); diff --git a/agentic/case-generation.e2e.ts b/agentic/case-generation.e2e.ts index 581ddeb82..50183c37d 100644 --- a/agentic/case-generation.e2e.ts +++ b/agentic/case-generation.e2e.ts @@ -18,7 +18,7 @@ test("a student starts a debate and generates a case for it", { ...agentic, tags return data; }, { timeout: 15_000 }).toEqual([{ debates: { motion: MOTION } }]); - await agent.assert("The page shows a generated debate case: a thesis and several contentions, each with a claim and supporting reasoning."); + await agent.assert("The page shows a generated debate case: an opening and several arguments, each with a point and an explanation of why it is true."); if (LIVE) { // Recipes never assert on model wording; a judge can check that the case is on topic. await agent.assert(`The generated case argues about the motion "${MOTION}", not about an unrelated topic.`); @@ -29,5 +29,5 @@ test("a student starts a debate and generates a case for it", { ...agentic, tags await agent.act("Open History and load the debate case you generated."); await expect(screen.getByRole("button", "Regenerate Arguments")).toBeVisible(); if (!LIVE) await expect(fakeAnswer(screen)).toBeVisible(); - await agent.assert("A debate case with a thesis and contentions is open again on screen."); + await agent.assert("A debate case with an opening and arguments is open again on screen."); }); diff --git a/e2e/fixtures/ai-responses.ts b/e2e/fixtures/ai-responses.ts index 40f32b9c7..849bb7e13 100644 --- a/e2e/fixtures/ai-responses.ts +++ b/e2e/fixtures/ai-responses.ts @@ -54,25 +54,20 @@ const CONTENTIONS = [ ]; const generate = (motion: string, length: string) => ({ + opening: { hook: 'Picture a nurse who skips the night bus because the fare is a tenth of her shift.' }, caseStructure: { thesis: `We support the motion that ${motion}`, - framework: 'Net societal benefit, weighed on access and cost.', - keyDefinitions: ['Public transport: publicly funded scheduled transit.'], - burdenOfProof: 'We must show the policy produces a net gain in practice.', }, contentions: CONTENTIONS.slice(0, CONTENTION_COUNT[length] ?? 2), - predictedOpponentCase: { - likelyArguments: ['The lost farebox revenue has to come from somewhere.'], - strategicWeaknesses: ['Assumes fare revenue is the only funding model.'], - keyClashes: ['Who actually bears the cost.'], - }, counters: [ { - targetArgument: 'Funding gap', - counterpoint: 'Fare collection itself carries substantial overhead.', - rebuttal: 'Net cost is far below headline farebox revenue.', + targetArgument: 'The lost farebox revenue has to come from somewhere.', + counterpoint: 'Fare collection itself carries substantial overhead, so the net cost is far below headline farebox revenue.', + source: 'UITP, 2024', }, ], + closingLine: 'That is why free public transport pays for itself.', + sources: [{ org: 'UITP', year: '2024', claim: 'Ridership rose after fares were removed' }], metadata: { side: 'pro', format: 'bp', generatedAt: new Date().toISOString() }, }); diff --git a/e2e/full-stack/fake-openrouter.mjs b/e2e/full-stack/fake-openrouter.mjs index e7d64c0d0..2192dd9fd 100644 --- a/e2e/full-stack/fake-openrouter.mjs +++ b/e2e/full-stack/fake-openrouter.mjs @@ -51,34 +51,59 @@ const usage = { prompt_tokens: 120, completion_tokens: 80, total_tokens: 200, co const contention = (index) => ({ id: String(index + 1), + title: `Fares shut people out ${index + 1}`, claim: `${MARK} contention ${index + 1}: fares block the riders who need transit most`, reasoning: "Fares are a regressive barrier concentrated on low-income riders.", - evidence: "Comparable jurisdictions report ridership gains after fare removal.", + evidence: "UITP reported in 2024 that comparable cities gained riders after removing fares.", needsSource: false, impact: "More people reach work, school, and healthcare.", }); +const counter = (index) => ({ + targetArgument: [ + "The lost farebox revenue has to come from somewhere.", + "Free buses will be overcrowded and dirty.", + "People do not value what they do not pay for.", + "Drivers will not leave their cars anyway.", + "Only tourists and the well-off benefit.", + "Cities cannot afford the extra buses.", + ][index], + counterpoint: `${MARK} answer ${index + 1}: fare collection itself costs a large share of what it brings in, so the real gap is far smaller.`, + ...(index % 2 === 0 ? { source: "UITP, 2024" } : {}), +}); + +// The role decides the shape, as the real prompt asks: the Introductor may +// define an unclear term, R2 leans on "If they say…", and the Conclusor +// brings reasons why we win instead of new arguments. const generate = (user) => { const length = /^Length: (\w+)/m.exec(user)?.[1] ?? "standard"; + const role = /^Role: (\w+)/m.exec(user)?.[1] ?? "r1"; const count = { brief: 2, standard: 3, comprehensive: 4 }[length] ?? 3; + const conc = role === "conc"; return { - caseStructure: { - thesis: `${MARK} case thesis: free transit is a net public good`, - framework: "Net societal benefit, weighed on access and cost.", - keyDefinitions: ["Public transport: publicly funded scheduled transit."], - burdenOfProof: "We must show the policy produces a net gain in practice.", + opening: { + hook: `${MARK} opening hook: picture a nurse who skips the night bus because the fare is a tenth of her shift's pay.`, + ...(role === "intro" ? { definition: "By free public transport we mean no fare at the point of use, paid for from public budgets." } : {}), }, - contentions: Array.from({ length: count }, (_, index) => contention(index)), - predictedOpponentCase: { - likelyArguments: ["The lost farebox revenue has to come from somewhere."], - strategicWeaknesses: ["Assumes fare revenue is the only funding model."], - keyClashes: ["Who actually bears the cost."], + caseStructure: { + thesis: conc + ? `${MARK} case thesis: here is why free transit won this debate` + : `${MARK} case thesis: today we will show ${count} arguments that prove free transit is a net public good`, }, - counters: [{ - targetArgument: "Funding gap", - counterpoint: "Fare collection itself carries substantial overhead.", - rebuttal: "Net cost is far below headline farebox revenue.", - }], + contentions: conc ? [] : Array.from({ length: count }, (_, index) => contention(index)), + counters: conc ? [] : Array.from({ length: role === "r2" ? 6 : 3 }, (_, index) => counter(index)), + ...(conc ? { + whyWeWin: [ + "Their funding worry never answered our point that fare collection eats most of the fare.", + "Only our side showed who actually gets to work, school, and the doctor.", + "Even if some buses get busier, a full bus is the policy working.", + ].slice(0, length === "brief" ? 2 : 3), + } : {}), + closingLine: "That is why it is clear that free public transport pays for itself in the lives it opens up.", + sources: [ + { org: "UITP", year: "2024", claim: "Ridership rose after fares were removed", url: "https://www.uitp.org/free-fares-2024" }, + { org: "Eurostat", year: "2023", claim: "Low-income households spend more of their budget on transport" }, + ], metadata: { side: "pro", format: "custom", generatedAt: new Date().toISOString() }, }; }; diff --git a/packages/core/src/ai/case-view.ts b/packages/core/src/ai/case-view.ts new file mode 100644 index 000000000..678062dda --- /dev/null +++ b/packages/core/src/ai/case-view.ts @@ -0,0 +1,152 @@ +// One reading of a generated case for every place that shows or exports it. +// New cases have an opening, arguments, an "If they say…" list, a closing line +// and a sources list. Saved cases from before keep their old keys (exordio, +// framework, burden of proof, definitions, the predicted opponent case and a +// separate rebuttal), so those come through as `legacy` and show only when +// they have content. +import type { CaseSource, GenerateOutput } from './schemas/generate'; + +export interface CaseArgument { + id: string; + title?: string; + point: string; + explanation: string; + evidence: string; + whyItMatters: string; + needsSource: boolean; + sourceQuery?: string; +} + +export interface CaseAnswer { + theySay: string; + weAnswer: string; + /** Older cases' second phrasing of the answer, when it adds something. */ + inRound?: string; + source?: string; +} + +export interface CaseView { + opening: { + hook?: string; + definition?: string; + /** "We will show N arguments that prove…"; older cases' thesis. */ + roadmap?: string; + }; + arguments: CaseArgument[]; + ifTheySay: CaseAnswer[]; + whyWeWin: string[]; + closingLine?: string; + sources: CaseSource[]; + legacy: { + framework?: string; + burdenOfProof?: string; + definitions: string[]; + likelyArguments: string[]; + weaknesses: string[]; + clashes: string[]; + }; +} + +function text(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() ? value.trim() : undefined; +} + +function texts(value: unknown): string[] { + return Array.isArray(value) ? value.map(text).filter((item): item is string => Boolean(item)) : []; +} + +/** Saved and shared cases come from storage, so only a web link is ever linked. */ +function webUrl(value: unknown): string | undefined { + const url = text(value); + if (!url) return undefined; + try { + const { protocol } = new URL(url); + return protocol === 'https:' || protocol === 'http:' ? url : undefined; + } catch { + return undefined; + } +} + +function joined(...parts: Array): string | undefined { + const present = parts.filter(Boolean); + return present.length ? present.join(' ') : undefined; +} + +export function readCase(result: GenerateOutput): CaseView { + const structure = result.caseStructure ?? { thesis: '' }; + const counters = Array.isArray(result.counters) ? result.counters : []; + const opponent = result.predictedOpponentCase; + const answered = new Set(counters.map((counter) => text(counter?.targetArgument)?.toLowerCase())); + return { + opening: { + hook: text(result.opening?.hook) ?? joined(text(result.exordio?.hook), text(result.exordio?.transition)), + definition: text(result.opening?.definition), + roadmap: text(structure.thesis), + }, + arguments: (Array.isArray(result.contentions) ? result.contentions : []).map((contention, index) => ({ + id: contention?.id || String(index + 1), + title: text(contention?.title), + point: text(contention?.claim) ?? '', + explanation: text(contention?.reasoning) ?? '', + evidence: text(contention?.evidence) ?? '', + whyItMatters: text(contention?.impact) ?? '', + needsSource: Boolean(contention?.needsSource), + sourceQuery: text(contention?.sourceQuery), + })), + ifTheySay: counters.flatMap((counter) => { + const theySay = text(counter?.targetArgument); + const counterpoint = text(counter?.counterpoint); + const rebuttal = text(counter?.rebuttal); + const weAnswer = counterpoint ?? rebuttal; + if (!theySay && !weAnswer) return []; + return [{ + theySay: theySay ?? '', + weAnswer: weAnswer ?? '', + inRound: counterpoint && rebuttal && rebuttal !== counterpoint ? rebuttal : undefined, + source: text(counter?.source), + }]; + }), + whyWeWin: texts(result.whyWeWin), + closingLine: text(result.closingLine), + sources: (Array.isArray(result.sources) ? result.sources : []).flatMap((source) => { + const org = text(source?.org); + const claim = text(source?.claim); + if (!org && !claim) return []; + return [{ org: org ?? '', claim: claim ?? '', year: text(source?.year), url: webUrl(source?.url) }]; + }), + legacy: { + framework: text(structure.framework), + burdenOfProof: text(structure.burdenOfProof), + definitions: texts(structure.keyDefinitions), + // An argument the list already answers is not repeated as a bare bullet. + likelyArguments: texts(opponent?.likelyArguments).filter((argument) => !answered.has(argument.toLowerCase())), + weaknesses: texts(opponent?.strategicWeaknesses), + clashes: texts(opponent?.keyClashes), + }, + }; +} + +/** "UNICEF (2025)", or just the organisation when the year is unknown. */ +export function sourceLabel(source: Pick): string { + return [source.org, source.year && `(${source.year})`].filter(Boolean).join(' '); +} + +/** "UNICEF (2025) – Most minors use AI to learn – https://…" for exports and plain text. */ +export function sourceLine(source: CaseSource): string { + return [sourceLabel(source), source.claim, source.url].filter(Boolean).join(' – '); +} + +/** + * A regenerated section can cite new sources while the rest of the case still + * cites the old ones, so the list keeps both, each source once. + */ +export function mergeCaseSources(current: CaseSource[] | undefined, next: CaseSource[] | undefined): CaseSource[] | undefined { + const seen = new Set(); + const merged = [...(next ?? []), ...(current ?? [])].filter((source) => { + const key = (source.url || `${source.org}|${source.claim}`).trim().toLowerCase(); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + return merged.length ? merged : undefined; +} diff --git a/packages/core/src/ai/schemas/generate.ts b/packages/core/src/ai/schemas/generate.ts index 960c79b0b..b76421c37 100644 --- a/packages/core/src/ai/schemas/generate.ts +++ b/packages/core/src/ai/schemas/generate.ts @@ -14,8 +14,11 @@ export const GenerateInputSchema = z.object({ export type GenerateInput = z.infer; +// Each argument reads Title, Point (claim), Explanation (reasoning), Evidence and +// Why it matters (impact). The keys stay as they were so saved cases keep loading. export const ContentionSchema = z.object({ id: z.string(), + title: z.string().optional(), claim: z.string(), reasoning: z.string(), evidence: z.string(), @@ -24,30 +27,57 @@ export const ContentionSchema = z.object({ impact: z.string(), }); +/** Older cases only: the Introductor's hook and bridge, now part of the opening. */ export const ExordioSchema = z.object({ hook: z.string(), transition: z.string(), }); +export const OpeningSchema = z.object({ + hook: z.string(), + /** One line defining a motion term, only when the term is genuinely unclear. */ + definition: z.string().optional(), +}); + +/** One "If they say…" item: their claim, our answer, and an optional source. */ +export const CounterSchema = z.object({ + targetArgument: z.string(), + counterpoint: z.string(), + /** Older cases only: a second, in-round phrasing of the answer. */ + rebuttal: z.string().optional(), + source: z.string().optional(), +}); + +export const CaseSourceSchema = z.object({ + org: z.string(), + year: z.string().optional(), + claim: z.string(), + url: z.string().optional(), +}); + export const GenerateOutputSchema = z.object({ + opening: OpeningSchema.optional(), exordio: ExordioSchema.optional(), + // thesis is the opening's "we will show N arguments that prove…" line. The + // other three are older cases' sections, shown only when they have content. caseStructure: z.object({ thesis: z.string(), - framework: z.string(), - keyDefinitions: z.array(z.string()), - burdenOfProof: z.string(), + framework: z.string().optional(), + keyDefinitions: z.array(z.string()).optional(), + burdenOfProof: z.string().optional(), }), contentions: z.array(ContentionSchema), + /** Older cases only; new cases fold the opponent's arguments into counters. */ predictedOpponentCase: z.object({ - likelyArguments: z.array(z.string()), - strategicWeaknesses: z.array(z.string()), - keyClashes: z.array(z.string()), - }), - counters: z.array(z.object({ - targetArgument: z.string(), - counterpoint: z.string(), - rebuttal: z.string(), - })), + likelyArguments: z.array(z.string()).optional(), + strategicWeaknesses: z.array(z.string()).optional(), + keyClashes: z.array(z.string()).optional(), + }).optional(), + counters: z.array(CounterSchema), + /** Conclusor only: two or three summaries of why our side wins. */ + whyWeWin: z.array(z.string()).optional(), + closingLine: z.string().optional(), + sources: z.array(CaseSourceSchema).optional(), metadata: z.object({ side: z.enum(['pro', 'con']), format: z.string(), @@ -71,3 +101,5 @@ export const GenerateOutputSchema = z.object({ export type GenerateOutput = z.infer; export type Contention = z.infer; +export type Counter = z.infer; +export type CaseSource = z.infer; diff --git a/packages/core/src/crossfire/prefill.ts b/packages/core/src/crossfire/prefill.ts index e437eadec..8f2e73297 100644 --- a/packages/core/src/crossfire/prefill.ts +++ b/packages/core/src/crossfire/prefill.ts @@ -1,5 +1,6 @@ import type { AttackOutput } from '../ai/schemas/attack'; import type { GenerateOutput } from '../ai/schemas/generate'; +import { readCase } from '../ai/case-view'; import { CrossfirePrefillSchema, MAX_CROSSFIRE_ARGUMENT_CHARS, @@ -17,21 +18,24 @@ function boundedArgument(parts: string[]): string { } export function prefillFromGenerate(resolution: string, result: GenerateOutput): CrossfirePrefill { - const contentionText = result.contentions.map((contention, index) => [ - `Contention ${index + 1}: ${contention.claim}`, - `Reasoning: ${contention.reasoning}`, - `Evidence: ${contention.evidence}`, - `Impact: ${contention.impact}`, - ].join('\n')); + const view = readCase(result); + const line = (label: string, value: string | undefined) => (value ? `${label}: ${value}` : ''); + const argumentText = view.arguments.map((argument, index) => [ + `Argument ${index + 1}: ${argument.title ? `${argument.title}. ` : ''}${argument.point}`, + line('Explanation', argument.explanation), + line('Evidence', argument.evidence), + line('Why it matters', argument.whyItMatters), + ].filter(Boolean).join('\n')); return CrossfirePrefillSchema.parse({ source: 'generate', resolution: resolution.trim().slice(0, 500), argument: boundedArgument([ - `Thesis: ${result.caseStructure.thesis}`, - `Framework: ${result.caseStructure.framework}`, - `Burden of proof: ${result.caseStructure.burdenOfProof}`, - ...contentionText, + line('Thesis', view.opening.roadmap), + line('Framework', view.legacy.framework), + line('Burden of proof', view.legacy.burdenOfProof), + ...argumentText, + ...view.whyWeWin.map((reason, index) => `Why we win ${index + 1}: ${reason}`), ]), }); } diff --git a/src/components/features/generate/GenerateResults.tsx b/src/components/features/generate/GenerateResults.tsx index f8158d8b8..4da36332f 100644 --- a/src/components/features/generate/GenerateResults.tsx +++ b/src/components/features/generate/GenerateResults.tsx @@ -1,12 +1,13 @@ import { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Target, Shield, Swords, AlertTriangle, MessageSquare, GitBranch, Sparkles, Undo2, Flame } from 'lucide-react'; +import { Shield, Swords, AlertTriangle, MessageSquare, GitBranch, Sparkles, Undo2, Flame, Trophy, Quote, BookOpen } from 'lucide-react'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { SectionCard } from '@/components/ui/section-card'; import { Separator } from '@/components/ui/separator'; import { Markdown } from '@/components/ui/markdown'; -import type { GenerateOutput, Contention } from '@/lib/ai/schemas/generate'; +import type { GenerateOutput } from '@/lib/ai/schemas/generate'; +import { readCase, sourceLabel, type CaseArgument } from '@debatelab/core/ai/case-view'; import { toast } from 'sonner'; import { formatDecimal, formatEuropeanDateTime } from '@/lib/date-format'; import { ExportToolbar } from '@/components/features/ExportToolbar'; @@ -34,16 +35,16 @@ interface GenerateResultsProps { onPracticeCrossfire?: () => void; } -function ArgumentCard({ contention, index, unverified }: { contention: Contention; index: number; unverified: boolean }) { +function ArgumentCard({ argument, index, unverified }: { argument: CaseArgument; index: number; unverified: boolean }) { const { t } = useTranslation(); return (

#{index + 1} - {contention.claim} + {argument.title ?? argument.point}

- {contention.needsSource && ( + {argument.needsSource && ( {t('generateResults.needsSource')} @@ -52,34 +53,59 @@ function ArgumentCard({ contention, index, unverified }: { contention: Contentio
-
- {t('generateResults.reasoning')} - {contention.reasoning} -
-
- {t('generateResults.evidence')} - {unverified && !contention.needsSource && ( - - {t('generateResults.unverified', 'Unverified')} - - )} - {contention.evidence} -
- {contention.sourceQuery && ( + {argument.title && argument.point && ( +
+ {t('generateResults.point')} + {argument.point} +
+ )} + {argument.explanation && ( +
+ {t('generateResults.explanation')} + {argument.explanation} +
+ )} + {argument.evidence && ( +
+ {t('generateResults.evidence')} + {unverified && !argument.needsSource && ( + + {t('generateResults.unverified', 'Unverified')} + + )} + {argument.evidence} +
+ )} + {argument.sourceQuery && (
{t('generateResults.searchSuggestion')} - {contention.sourceQuery} + {argument.sourceQuery} +
+ )} + {argument.whyItMatters && ( +
+ {t('generateResults.whyItMatters')} + {argument.whyItMatters}
)} -
- {t('generateResults.impact')} - {contention.impact} -
); } +/** A labelled bullet list for an older case's section; nothing when it is empty. */ +function LegacyList({ title, items }: { title: string; items: string[] }) { + if (!items.length) return null; + return ( +
+

{title}

+
    + {items.map((item, i) =>
  • {item}
  • )} +
+
+ ); +} + function formatDuration(ms: number | undefined, t: (key: string, options?: Record) => string) { if (!ms || ms < 0) return null; if (ms < 1000) return t('generateResults.durationMs', { value: ms }); @@ -144,7 +170,7 @@ export function GenerateResults({ result, discoveredSources, onRegenerateSection }; // Guard against incomplete result data - if (!result?.caseStructure || !result?.contentions || !result?.predictedOpponentCase || !result?.counters) { + if (!result?.caseStructure || !Array.isArray(result?.contentions) || !Array.isArray(result?.counters)) { return (

{t('generateResults.incomplete')}

@@ -155,6 +181,8 @@ export function GenerateResults({ result, discoveredSources, onRegenerateSection ); } + const view = readCase(result); + const { opening, legacy } = view; const durationText = formatDuration(result.metadata?.durationMs, t); // Without web research, evidence is the model's own claim and is labelled that way. const researched = Boolean(discoveredSources?.length); @@ -220,148 +248,162 @@ export function GenerateResults({ result, discoveredSources, onRegenerateSection /> - {/* Exordio (Introductor only) */} - {result.exordio && (result.exordio.hook || result.exordio.transition) && ( + {/* Opening: the hook, an unclear term when the Introductor needs one, and the roadmap. */} + {(opening.hook || opening.definition || opening.roadmap || legacy.framework || legacy.burdenOfProof || legacy.definitions.length > 0) && ( } - onRegenerate={regenerate('exordio')} - {...regenerateState('exordio')} + onRegenerate={regenerate('opening')} + {...regenerateState('opening')} >
-
-

{t('generateResults.hook')}

-

{result.exordio.hook}

-
- -
-

{t('generateResults.transition')}

-

{result.exordio.transition}

-
+ {opening.hook && {opening.hook}} + {opening.definition && ( +
+ {t('generateResults.definition')} + {opening.definition} +
+ )} + {opening.roadmap && ( +
+

{t('generateResults.roadmap')}

+ {opening.roadmap} +
+ )} + {/* Older cases kept these as their own sections. */} + {legacy.framework && ( +
+

{t('generateResults.framework')}

+ {legacy.framework} +
+ )} + {legacy.burdenOfProof && ( +
+

{t('generateResults.burdenOfProof')}

+ {legacy.burdenOfProof} +
+ )} +
)} - {/* Case Structure */} - } - onRegenerate={regenerate('structure')} - {...regenerateState('structure')} - > -
-
-

{t('generateResults.thesis')}

- {result.caseStructure.thesis} -
- -
-

{t('generateResults.framework')}

- {result.caseStructure.framework} -
- -
-

{t('generateResults.burdenOfProof')}

- {result.caseStructure.burdenOfProof} -
-
-

{t('generateResults.keyDefinitions')}

-
    - {result.caseStructure.keyDefinitions?.map((def, i) => ( -
  • {def}
  • - )) ||
  • {t('generateResults.noDefinitions')}
  • } -
+ {view.arguments.length > 0 && ( + } + onRegenerate={regenerate('contentions')} + {...regenerateState('contentions')} + > +
+ {view.arguments.map((argument, index) => ( + + ))}
-
- + + )} - {/* Contentions */} - } - onRegenerate={regenerate('contentions')} - {...regenerateState('contentions')} - > -
- {result.contentions?.map((contention, index) => ( - - ))} -
-
+ {/* Conclusor: the reasons we win take the place of new arguments. */} + {view.whyWeWin.length > 0 && ( + } + onRegenerate={regenerate('whyWeWin')} + {...regenerateState('whyWeWin')} + > +
    + {view.whyWeWin.map((reason, i) => ( +
  1. + {i + 1}. + {reason} +
  2. + ))} +
+
+ )} - {/* Predicted Opponent Case */} - } - onRegenerate={regenerate('opponent')} - {...regenerateState('opponent')} - > -
-
-

{t('generateResults.likelyArguments')}

-
    - {result.predictedOpponentCase?.likelyArguments?.map((arg, i) => ( -
  • - • - {arg} -
  • - ))} -
-
- -
-

{t('generateResults.strategicWeaknesses')}

-
    - {result.predictedOpponentCase?.strategicWeaknesses?.map((weakness, i) => ( -
  • - • - {weakness} -
  • - ))} -
-
- -
-

{t('generateResults.keyClashes')}

-
- {result.predictedOpponentCase?.keyClashes?.map((clash, i) => ( - {clash} - ))} -
+ {(view.ifTheySay.length > 0 || legacy.likelyArguments.length > 0 || legacy.weaknesses.length > 0 || legacy.clashes.length > 0) && ( + } + onRegenerate={regenerate('counters')} + {...regenerateState('counters')} + > +
+ {view.ifTheySay.map((answer, i) => ( +
+ {answer.theySay && ( +
+ {t('generateResults.theySay')} + {answer.theySay} +
+ )} + {answer.weAnswer && ( +
+ {t('generateResults.weAnswer')} + {answer.weAnswer} +
+ )} + {answer.inRound && ( +
+ {t('generateResults.rebuttal')} + {answer.inRound} +
+ )} + {answer.source && ( +

+ {t('generateResults.source')} {answer.source} +

+ )} +
+ ))} + + +
-
- + + )} - {/* Prepared Counters */} - -
- {result.counters?.map((counter, i) => ( -
-
- {t('generateResults.target')} - {counter?.targetArgument} -
-
- {t('generateResults.counter')} - {counter?.counterpoint} -
-
- {t('generateResults.rebuttal')} - {counter?.rebuttal} -
-
- ))} -
-
+ {view.closingLine && ( + } + onRegenerate={regenerate('closing')} + {...regenerateState('closing')} + > + {view.closingLine} + + )} + + {view.sources.length > 0 && ( + } + > +
    + {view.sources.map((source, i) => ( +
  • + {sourceLabel(source)} + {source.claim && {source.org ? ' – ' : ''}{source.claim}} + {source.url && ( + <> + {' – '} + + {source.url} + + + )} +
  • + ))} +
+
+ )}
); } diff --git a/src/components/features/tools/discuss.ts b/src/components/features/tools/discuss.ts index 35cc4912e..d8a75c0cd 100644 --- a/src/components/features/tools/discuss.ts +++ b/src/components/features/tools/discuss.ts @@ -42,7 +42,9 @@ export function generateDiscussPrompt(result: GenerateOutput, t: TFunction) { `**${t('discuss.generate.thesis', 'Thesis')}:** ${result.caseStructure.thesis}`, '', `**${t('discuss.generate.arguments', 'Arguments')}:**`, - ...result.contentions.map((contention, index) => `${index + 1}. ${contention.claim}`), + ...(result.contentions.length + ? result.contentions.map((contention, index) => `${index + 1}. ${contention.title ?? contention.claim}`) + : (result.whyWeWin ?? []).map((reason, index) => `${index + 1}. ${reason}`)), '', t('discuss.generate.ask', 'Can we refine this?'), ].join('\n'); diff --git a/src/components/features/tools/live-sections.ts b/src/components/features/tools/live-sections.ts index e4a428e7c..74a44bf74 100644 --- a/src/components/features/tools/live-sections.ts +++ b/src/components/features/tools/live-sections.ts @@ -56,42 +56,34 @@ function section(id: string, title: string, blocks: DraftBlock[]): DraftSection[ } export function generateDraftSections(draft: Draft, t: TFunction): DraftSection[] { - const exordio = record(draft.exordio); + const opening = record(draft.opening); const structure = record(draft.caseStructure); - const opponent = record(draft.predictedOpponentCase); return [ - ...section('exordio', t('generateResults.exordio'), [ - ...block(t('generateResults.hook'), exordio.hook), - ...block(t('generateResults.transition'), exordio.transition), - ]), - ...section('structure', t('generateResults.caseStructure'), [ - ...block(t('generateResults.thesis'), structure.thesis), - ...block(t('generateResults.framework'), structure.framework), - ...itemsBlock(t('generateResults.keyDefinitions'), structure.keyDefinitions), - ...block(t('generateResults.burdenOfProof'), structure.burdenOfProof), + ...section('opening', t('generateResults.opening'), [ + ...block(undefined, opening.hook), + ...block(t('generateResults.definition'), opening.definition), + ...block(t('generateResults.roadmap'), structure.thesis), ]), ...section('contentions', t('generateResults.arguments'), list(draft.contentions).flatMap((value) => { const contention = record(value); return [ - ...heading(contention.claim), - ...block(t('generateResults.reasoning'), contention.reasoning), + ...heading(contention.title ?? contention.claim), + ...(text(contention.title) ? block(t('generateResults.point'), contention.claim) : []), + ...block(t('generateResults.explanation'), contention.reasoning), ...block(t('generateResults.evidence'), contention.evidence), - ...block(t('generateResults.impact'), contention.impact), + ...block(t('generateResults.whyItMatters'), contention.impact), ]; })), - ...section('opponent', t('generateResults.predictedOpponent'), [ - ...itemsBlock(t('generateResults.likelyArguments'), opponent.likelyArguments), - ...itemsBlock(t('generateResults.strategicWeaknesses'), opponent.strategicWeaknesses), - ...itemsBlock(t('generateResults.keyClashes'), opponent.keyClashes), - ]), - ...section('counters', t('generateResults.preparedCounters'), list(draft.counters).flatMap((value) => { + ...section('whyWeWin', t('generateResults.whyWeWin'), itemsBlock(undefined, draft.whyWeWin)), + ...section('counters', t('generateResults.ifTheySay'), list(draft.counters).flatMap((value) => { const counter = record(value); return [ ...heading(counter.targetArgument), - ...block(t('generateResults.counter'), counter.counterpoint), - ...block(t('generateResults.rebuttal'), counter.rebuttal), + ...block(t('generateResults.weAnswer'), counter.counterpoint), + ...block(t('generateResults.source'), counter.source), ]; })), + ...section('closing', t('generateResults.closingLine'), block(undefined, draft.closingLine)), ]; } diff --git a/src/components/features/tools/result-text.ts b/src/components/features/tools/result-text.ts index dfe9b6b70..6469246cd 100644 --- a/src/components/features/tools/result-text.ts +++ b/src/components/features/tools/result-text.ts @@ -2,27 +2,38 @@ import type { TFunction } from 'i18next'; import type { BrainstormOutput, Idea } from '@/lib/ai/schemas/brainstorm'; import type { FactoidsOutput } from '@/lib/ai/schemas/factoids'; import type { GenerateOutput } from '@/lib/ai/schemas/generate'; +import { readCase, sourceLine } from '@debatelab/core/ai/case-view'; // Plain-text versions of tool results for the Clipboard button, in what each result view shows. export function caseText(result: GenerateOutput): string { + const view = readCase(result); const lines: string[] = []; - lines.push(`THESIS: ${result.caseStructure?.thesis || ''}`); - lines.push(`FRAMEWORK: ${result.caseStructure?.framework || ''}`); - lines.push(''); - result.contentions?.forEach((c, i) => { - lines.push(`CONTENTION #${i + 1}: ${c.claim}`); - lines.push(` Reasoning: ${c.reasoning}`); - lines.push(` Evidence: ${c.evidence}`); - lines.push(` Impact: ${c.impact}`); - lines.push(''); + const block = (title: string, body: string[]) => { + if (!body.length) return; + if (lines.length) lines.push(''); + lines.push(title, ...body); + }; + block('OPENING:', [ + view.opening.hook, + view.opening.definition && `What we mean: ${view.opening.definition}`, + view.opening.roadmap, + view.legacy.framework && `Framework: ${view.legacy.framework}`, + view.legacy.burdenOfProof && `Burden of proof: ${view.legacy.burdenOfProof}`, + ].filter((line): line is string => Boolean(line)).map((line) => ` ${line}`)); + view.arguments.forEach((argument, i) => { + block(`ARGUMENT ${i + 1}: ${argument.title ?? argument.point}`, [ + argument.title && argument.point && ` Point: ${argument.point}`, + argument.explanation && ` Explanation: ${argument.explanation}`, + argument.evidence && ` Evidence: ${argument.evidence}`, + argument.whyItMatters && ` Why it matters: ${argument.whyItMatters}`, + ].filter((line): line is string => Boolean(line))); }); - if (result.counters?.length) { - lines.push('PREPARED COUNTERS:'); - result.counters.forEach(c => { - lines.push(` vs "${c.targetArgument}": ${c.counterpoint}`); - }); - } + block('WHY WE WIN:', view.whyWeWin.map((reason, i) => ` ${i + 1}. ${reason}`)); + block('IF THEY SAY...', view.ifTheySay.map((answer) => + ` "${answer.theySay}" -> ${answer.weAnswer}${answer.source ? ` (${answer.source})` : ''}`)); + if (view.closingLine) block('CLOSING LINE:', [` ${view.closingLine}`]); + block('SOURCES:', view.sources.map((source) => ` - ${sourceLine(source)}`)); return lines.join('\n'); } diff --git a/src/lib/ai/features/generate.ts b/src/lib/ai/features/generate.ts index 281ff8429..67e95acb8 100644 --- a/src/lib/ai/features/generate.ts +++ b/src/lib/ai/features/generate.ts @@ -12,9 +12,8 @@ import { cleanGeneratedOutput } from '../text'; * This handles cases where the AI returns a different structure than requested. */ function transformGenerateResponse(response: Record): GenerateOutput { - // If already in correct format, return as-is (with exordio preserved) - if (response.caseStructure && response.contentions && response.predictedOpponentCase && response.counters && response.metadata) { - // Ensure exordio is properly structured if present + // Already in the expected shape (a new case, or an older one with its extra sections). + if (response.caseStructure && response.contentions && response.counters && response.metadata) { const result = response as unknown as GenerateOutput; if (response.exordio && typeof response.exordio === 'object') { const ex = response.exordio as Record; @@ -47,23 +46,15 @@ function transformGenerateResponse(response: Record): GenerateO impact: c.impact || c.analysis || '', })); - // Transform rebuttals to counters + // Transform rebuttals to "If they say…" answers const rebuttals = (response.rebuttals as Array<{ attack?: string; response?: string }>) || []; - const counters = rebuttals.map(r => ({ - targetArgument: r.attack || '', - counterpoint: r.response || '', - rebuttal: r.response || '', - })); - - // Transform potentialWeaknesses to predictedOpponentCase - const weaknesses = (response.potentialWeaknesses as string[]) || []; + const counters = rebuttals + .filter((r) => r.attack || r.response) + .map((r) => ({ targetArgument: r.attack || '', counterpoint: r.response || '' })); const result: GenerateOutput = { caseStructure: { thesis: (response.thesis as string) || (response.title as string) || '', - framework: 'General framework', - keyDefinitions: [], - burdenOfProof: 'We must prove that ' + ((response.thesis as string) || '').slice(0, 50), }, contentions: transformedContentions.length > 0 ? transformedContentions : [{ id: '1', @@ -74,16 +65,7 @@ function transformGenerateResponse(response: Record): GenerateO sourceQuery: '', impact: 'Impact here', }], - predictedOpponentCase: { - likelyArguments: weaknesses.slice(0, 3), - strategicWeaknesses: weaknesses, - keyClashes: ['Main clash point'], - }, - counters: counters.length > 0 ? counters : [{ - targetArgument: 'Opponent argument', - counterpoint: 'Our counter', - rebuttal: 'Rebuttal text', - }], + counters, metadata: { side: (response.side as 'pro' | 'con') || 'pro', format: 'custom', @@ -137,7 +119,10 @@ export async function generateCase(input: GenerateInput, config: ProviderConfig, const data = rawData as { output: unknown; discoveredSources?: unknown }; const result = validateGenerateOutput(transformGenerateResponse(data.output as Record)); const expectedCount = expectedContentionCount(input.length); - if (result.contentions.length !== expectedCount) { + // A Conclusor's case brings reasons why we win instead of new arguments. + // An older backend still sends it the usual argument count. + const concluded = input.role === 'conc' && Boolean(result.whyWeWin?.length); + if (!concluded && result.contentions.length !== expectedCount) { throw new AIResponseError('AI response did not contain the expected number of contentions.', { details: { expectedCount, actualCount: result.contentions.length }, }); diff --git a/src/lib/export-utils.ts b/src/lib/export-utils.ts index 48cbe4cf6..26766f93d 100644 --- a/src/lib/export-utils.ts +++ b/src/lib/export-utils.ts @@ -1,5 +1,6 @@ import i18n from '@/i18n'; import type { GenerateOutput } from './ai/schemas/generate'; +import { readCase, sourceLine } from '@debatelab/core/ai/case-view'; import type { AttackOutput } from './ai/schemas/attack'; import type { BrainstormOutput } from './ai/schemas/brainstorm'; import type { FactoidsOutput } from './ai/schemas/factoids'; @@ -15,6 +16,11 @@ import { getSparringWinnerLabel, } from './export-sparring-labels'; +/** What every case export needs; the older sections are optional. */ +function hasCaseData(result: GenerateOutput | undefined): result is GenerateOutput { + return Boolean(result?.metadata && result.caseStructure && Array.isArray(result.contentions) && Array.isArray(result.counters)); +} + /** Triggers a browser download from a Blob. */ export function saveBlob(blob: Blob, filename: string): void { const url = URL.createObjectURL(blob); @@ -29,24 +35,17 @@ export function saveBlob(blob: Blob, filename: string): void { export async function exportCaseToDocx(result: GenerateOutput): Promise { try { - if ( - !result?.metadata || - !result.caseStructure || - !Array.isArray(result.contentions) || - !result.predictedOpponentCase || - !Array.isArray(result.counters) - ) { + if (!hasCaseData(result)) { throw new Error('Missing required case data for export.'); } // Dynamically import heavy libraries only when needed const { Document, Packer, Paragraph, TextRun, HeadingLevel, AlignmentType } = await import('docx'); - const thesis = result.caseStructure.thesis?.trim() || 'Untitled Thesis'; + const view = readCase(result); + const thesis = view.opening.roadmap || 'Untitled Thesis'; const motion = (result.metadata as Record)?.motion as string | undefined; const thesisTitle = motion || thesis.split('.')[0] || thesis; - const framework = result.caseStructure.framework?.trim() || 'No framework provided.'; - const burdenOfProof = result.caseStructure.burdenOfProof?.trim() || 'No burden of proof provided.'; const sideLabel = result.metadata.side === 'pro' ? i18n.t('export.proposition') : result.metadata.side === 'con' @@ -58,61 +57,43 @@ export async function exportCaseToDocx(result: GenerateOutput): Promise ? formatEuropeanDate(generatedAtDate) : 'Unknown'; - const contentions = Array.isArray(result.contentions) ? result.contentions : []; - const contentionsSection = contentions.length > 0 - ? contentions.flatMap((c, i) => { - const claim = c?.claim || 'Untitled argument'; - const reasoning = c?.reasoning || 'No reasoning provided.'; - const evidence = c?.evidence || 'No evidence provided.'; - const impact = c?.impact || 'No impact provided.'; - return [ - new Paragraph({ - children: [new TextRun({ text: `Argument ${i + 1}: ${claim}`, bold: true, size: 28 })], - spacing: { before: 200, after: 100 }, - }), - new Paragraph({ - children: [new TextRun({ text: `${i18n.t('export.reasoning')}: `, italics: true }), new TextRun(reasoning)], - spacing: { after: 100 }, - }), - new Paragraph({ - children: [new TextRun({ text: `${i18n.t('export.evidence')}: `, italics: true }), new TextRun(evidence)], - spacing: { after: 100 }, - }), - new Paragraph({ - children: [new TextRun({ text: `${i18n.t('export.impact')}: `, italics: true }), new TextRun(impact)], - spacing: { after: 300 }, - }), - ]; - }) - : [new Paragraph({ text: i18n.t('export.noArguments'), spacing: { after: 200 } })]; + const heading = (text: string, before = 0) => + new Paragraph({ text: text.toUpperCase(), heading: HeadingLevel.HEADING_2, ...(before ? { spacing: { before } } : {}) }); + const labelled = (label: string, text: string, after = 100, bold = false) => new Paragraph({ + children: [new TextRun({ text: `${label}: `, ...(bold ? { bold: true } : { italics: true }) }), new TextRun(text)], + spacing: { after }, + }); + const bullets = (items: string[]) => items.map((item) => new Paragraph({ text: '• ' + item, spacing: { after: 100 } })); + const legacyBlock = (label: string, text: string | undefined) => text ? [labelled(label, text, 200, true)] : []; + + const openingSection = [ + ...(view.opening.hook ? [new Paragraph({ children: [new TextRun({ text: view.opening.hook, italics: true })], spacing: { after: 200 } })] : []), + ...(view.opening.definition ? [labelled(i18n.t('export.definition'), view.opening.definition, 200, true)] : []), + ...(view.opening.roadmap ? [new Paragraph({ text: view.opening.roadmap, spacing: { after: 200 } })] : []), + ...legacyBlock(i18n.t('export.framework'), view.legacy.framework), + ...legacyBlock(i18n.t('export.burdenOfProof'), view.legacy.burdenOfProof), + ...(view.legacy.definitions.length ? [labelled(i18n.t('export.keyDefinitions'), view.legacy.definitions.join('; '), 200, true)] : []), + ]; - const predictedArguments = Array.isArray(result.predictedOpponentCase.likelyArguments) - ? result.predictedOpponentCase.likelyArguments - : []; - const predictedSection = predictedArguments.length > 0 - ? predictedArguments.map(arg => new Paragraph({ text: "• " + arg, spacing: { after: 100 } })) - : [new Paragraph({ text: i18n.t('export.noOpponentArgs'), spacing: { after: 100 } })]; - - const counters = Array.isArray(result.counters) ? result.counters : []; - const countersSection = counters.length > 0 - ? counters.flatMap(c => { - const target = c?.targetArgument || 'Unknown target'; - const counterpoint = c?.counterpoint || 'No counterpoint provided.'; - const rebuttal = c?.rebuttal || 'No rebuttal provided.'; - return [ - new Paragraph({ - children: [new TextRun({ text: `${i18n.t('export.target')}: `, bold: true }), new TextRun(target)], - }), - new Paragraph({ - children: [new TextRun({ text: `${i18n.t('export.counter')}: `, italics: true }), new TextRun(counterpoint)], - }), - new Paragraph({ - children: [new TextRun({ text: `${i18n.t('export.rebuttal')}: `, italics: true }), new TextRun(rebuttal)], - spacing: { after: 200 }, - }), - ]; - }) - : [new Paragraph({ text: i18n.t('export.noCounters'), spacing: { after: 200 } })]; + const argumentsSection = view.arguments.flatMap((argument, i) => [ + new Paragraph({ + children: [new TextRun({ text: `${i18n.t('export.argumentNumber', { number: i + 1 })}: ${argument.title ?? argument.point}`, bold: true, size: 28 })], + spacing: { before: 200, after: 100 }, + }), + ...(argument.title && argument.point ? [labelled(i18n.t('export.point'), argument.point)] : []), + ...(argument.explanation ? [labelled(i18n.t('export.explanation'), argument.explanation)] : []), + ...(argument.evidence ? [labelled(i18n.t('export.evidence'), argument.evidence)] : []), + ...(argument.whyItMatters ? [labelled(i18n.t('export.whyItMatters'), argument.whyItMatters, 300)] : []), + ]); + + const answersSection = view.ifTheySay.flatMap((answer) => [ + new Paragraph({ + children: [new TextRun({ text: `${i18n.t('export.theySay')}: `, bold: true }), new TextRun(answer.theySay)], + }), + labelled(i18n.t('export.weAnswer'), answer.weAnswer, answer.inRound || answer.source ? 100 : 200), + ...(answer.inRound ? [labelled(i18n.t('export.rebuttal'), answer.inRound, answer.source ? 100 : 200)] : []), + ...(answer.source ? [labelled(i18n.t('export.source'), answer.source, 200)] : []), + ]); const doc = new Document({ sections: [ @@ -140,40 +121,16 @@ export async function exportCaseToDocx(result: GenerateOutput): Promise spacing: { after: 400 }, }), - // Exordio (if present) - ...(result.exordio ? [ - new Paragraph({ text: i18n.t('export.exordio').toUpperCase(), heading: HeadingLevel.HEADING_2 }), - new Paragraph({ - children: [new TextRun({ text: `${i18n.t('export.hook')}: `, bold: true }), new TextRun(result.exordio.hook || '')], - spacing: { after: 100 }, - }), - new Paragraph({ - children: [new TextRun({ text: `${i18n.t('export.transition')}: `, bold: true }), new TextRun(result.exordio.transition || '')], - spacing: { after: 400 }, - }), + ...(openingSection.length ? [heading(i18n.t('export.opening')), ...openingSection] : []), + ...(argumentsSection.length ? [heading(i18n.t('export.arguments'), 200), ...argumentsSection] : []), + ...(view.whyWeWin.length ? [heading(i18n.t('export.whyWeWin'), 200), ...bullets(view.whyWeWin)] : []), + ...(answersSection.length || view.legacy.likelyArguments.length ? [ + heading(i18n.t('export.ifTheySay'), 400), + ...answersSection, + ...(view.legacy.likelyArguments.length ? [new Paragraph({ children: [new TextRun({ text: i18n.t('export.predictedOpponent'), bold: true })], spacing: { before: 200, after: 100 } }), ...bullets(view.legacy.likelyArguments)] : []), ] : []), - - // Thesis & Framework - new Paragraph({ text: i18n.t('export.thesis').toUpperCase(), heading: HeadingLevel.HEADING_2 }), - new Paragraph({ text: thesis, spacing: { after: 200 } }), - - new Paragraph({ text: i18n.t('export.framework').toUpperCase(), heading: HeadingLevel.HEADING_2 }), - new Paragraph({ text: framework, spacing: { after: 200 } }), - - new Paragraph({ text: i18n.t('export.burdenOfProof').toUpperCase(), heading: HeadingLevel.HEADING_2 }), - new Paragraph({ text: burdenOfProof, spacing: { after: 400 } }), - - // Contentions - new Paragraph({ text: i18n.t('export.arguments').toUpperCase(), heading: HeadingLevel.HEADING_2 }), - ...contentionsSection, - - // Predictions - new Paragraph({ text: i18n.t('export.predictedOpponent').toUpperCase(), heading: HeadingLevel.HEADING_2, spacing: { before: 400 } }), - ...predictedSection, - - // Counters - new Paragraph({ text: i18n.t('export.counters').toUpperCase(), heading: HeadingLevel.HEADING_2, spacing: { before: 400 } }), - ...countersSection, + ...(view.closingLine ? [heading(i18n.t('export.closingLine'), 400), new Paragraph({ children: [new TextRun({ text: view.closingLine, bold: true })], spacing: { after: 200 } })] : []), + ...(view.sources.length ? [heading(i18n.t('export.sources'), 400), ...bullets(view.sources.map(sourceLine))] : []), ], }, ], @@ -552,13 +509,7 @@ async function callGoogleDocsEdgeFunction( } export async function exportCaseToGoogleDocs(result: GenerateOutput): Promise { - if ( - !result?.metadata || - !result.caseStructure || - !Array.isArray(result.contentions) || - !result.predictedOpponentCase || - !Array.isArray(result.counters) - ) { + if (!hasCaseData(result)) { return { success: false, error: 'Missing required case data for export.' }; } return callGoogleDocsEdgeFunction('generate', result); diff --git a/src/lib/flowchart.ts b/src/lib/flowchart.ts index d9cede990..9a6cdc599 100644 --- a/src/lib/flowchart.ts +++ b/src/lib/flowchart.ts @@ -4,6 +4,7 @@ import type { TFunction } from 'i18next'; import i18n from '@/i18n'; import { APP_LANGUAGES, type AppLanguage } from '@/lib/languages'; import type { GenerateOutput } from '@/lib/ai/schemas/generate'; +import { readCase } from '@debatelab/core/ai/case-view'; import type { FlowchartData } from '@/lib/stores/session-store'; export type NodeType = 'thesis' | 'contention' | 'attack' | 'rebuttal' | 'dropped'; @@ -189,9 +190,9 @@ export function starterFlowchart(t: TFunction): FlowchartData { }; } -/** A generated case has what a flowchart needs: a thesis and at least one contention. */ +/** A generated case has what a flowchart needs: a thesis and an argument, or a Conclusor's reasons why we win. */ export const caseHasFlowchart = (result: GenerateOutput | null | undefined): result is GenerateOutput => - Boolean(result?.caseStructure?.thesis && result.contentions?.length); + Boolean(result?.caseStructure?.thesis && (result.contentions?.length || result.whyWeWin?.length)); const NODE_GAP_X = 300; const ROW_GAP_Y = 150; @@ -234,30 +235,36 @@ function answeredAttack(target: string | undefined, attacks: string[], index: nu } /** - * Maps a generated case onto the canvas: the thesis, one node per contention with - * its claim and evidence, the predicted opposing arguments, and the counters. + * Maps a generated case onto the canvas: the thesis, one node per argument with + * its title, point and evidence (a Conclusor's reasons why we win stand in for + * arguments), what the other side will say, and our answers. */ export function flowchartFromCase(result: GenerateOutput, t: TFunction): FlowchartData { const nodes: Node[] = []; const edges: Edge[] = []; + const view = readCase(result); nodes.push(appNode('thesis', 400, 50, 'thesis', t, { labelKey: 'flowchart.fromCase.thesis', description: result.caseStructure.thesis })); - const contentions = result.contentions; - const contentionTier = placeTier(contentions.length, 230); - contentions.forEach((contention, i) => { + const points = view.arguments.length + ? view.arguments.map((argument) => [argument.title, argument.point, argument.evidence].filter(Boolean).join('\n')) + : view.whyWeWin; + const contentionTier = placeTier(points.length, 230); + points.forEach((description, i) => { const id = `c-${i}`; const { x, y } = contentionTier.position(i); nodes.push(appNode(id, x, y, 'contention', t, { labelKey: 'flowchart.fromCase.argument', labelNumber: i + 1, - description: [contention.claim, contention.evidence].filter((part) => part?.trim()).join('\n'), + description, strength: 'strong', })); edges.push({ id: `e-thesis-${id}`, source: 'thesis', target: id, markerEnd: { type: MarkerType.ArrowClosed } }); }); - const attacks = result.predictedOpponentCase?.likelyArguments ?? []; + // Older cases listed the opposing arguments; newer ones name each in "If they say…". + const listed = (result.predictedOpponentCase?.likelyArguments ?? []).filter((argument) => argument?.trim()); + const attacks = listed.length ? listed : view.ifTheySay.map((answer) => answer.theySay).filter(Boolean); const attackTier = placeTier(attacks.length, contentionTier.bottom); attacks.forEach((argument, i) => { const id = `a-${i}`; @@ -267,20 +274,20 @@ export function flowchartFromCase(result: GenerateOutput, t: TFunction): Flowcha labelNumber: i + 1, description: argument, })); - edges.push(edgeOfKind('attack', contentions.length ? `c-${i % contentions.length}` : 'thesis', id, t)); + edges.push(edgeOfKind('attack', points.length ? `c-${i % points.length}` : 'thesis', id, t)); }); - // A counter answers one of their arguments, so it hangs off that attack. - const counters = result.counters ?? []; - const counterTier = placeTier(counters.length, attackTier.bottom); - counters.forEach((counter, i) => { + // An answer replies to one of their arguments, so it hangs off that attack. + const answers = view.ifTheySay; + const counterTier = placeTier(answers.length, attackTier.bottom); + answers.forEach((answer, i) => { const id = `r-${i}`; - const attackIndex = answeredAttack(counter.targetArgument, attacks, i); + const attackIndex = answeredAttack(answer.theySay, attacks, i); const { x, y } = counterTier.position(i); nodes.push(appNode(id, x, y, 'rebuttal', t, { labelKey: 'flowchart.fromCase.rebuttal', labelNumber: i + 1, - description: counter.rebuttal || counter.counterpoint, + description: answer.inRound ?? answer.weAnswer, })); edges.push(edgeOfKind('rebuttal', attackIndex >= 0 ? `a-${attackIndex}` : 'thesis', id, t)); }); diff --git a/src/lib/pdf-export-utils.ts b/src/lib/pdf-export-utils.ts index f1c0c10e8..fc3406385 100644 --- a/src/lib/pdf-export-utils.ts +++ b/src/lib/pdf-export-utils.ts @@ -2,6 +2,7 @@ // Professional PDF export for all debate features using jsPDF import i18n from '@/i18n'; import type { GenerateOutput } from './ai/schemas/generate'; +import { readCase, sourceLine } from '@debatelab/core/ai/case-view'; import type { AttackOutput } from './ai/schemas/attack'; import type { BrainstormOutput } from './ai/schemas/brainstorm'; import type { FactoidsOutput } from './ai/schemas/factoids'; @@ -341,7 +342,7 @@ export async function exportCaseToPdf(result: GenerateOutput): Promise doc.setFontSize(12); doc.setFont(PDF_FONT, 'bold'); doc.setTextColor(...COLORS.dark); - doc.text(i18n.t('export.contentionNumber', { number: index + 1 }), margin + textIndent, pdf.y); + doc.text(i18n.t('export.argumentNumber', { number: index + 1 }), margin + textIndent, pdf.y); pdf.y += 5; // Claim text @@ -374,36 +375,34 @@ export async function exportCaseToPdf(result: GenerateOutput): Promise pdf.y += 6; }; - // Exordio (if present) - if (result.exordio) { - addSectionTitle(i18n.t('export.exordio'), COLORS.primary); - if (result.exordio.hook) { - addSubsectionLabel(i18n.t('export.hook')); - addParagraph(result.exordio.hook, 2); - } - if (result.exordio.transition) { - addSubsectionLabel(i18n.t('export.transition')); - addParagraph(result.exordio.transition, 2); - } - addSeparator(COLORS.primary); - } + const view = readCase(result); - // Case Structure: one section heading, then a label for each part, so no heading repeats. - addSectionTitle(i18n.t('export.caseStructure')); - addSubsectionLabel(i18n.t('export.thesis')); - addParagraph(result.caseStructure?.thesis || i18n.t('export.noThesis'), 2); - if (result.caseStructure?.framework) { + // Opening: hook, an unclear term when there is one, and the roadmap. + // Older cases add their framework, burden and definitions here. + addSectionTitle(i18n.t('export.opening'), COLORS.primary); + if (view.opening.hook) addParagraph(view.opening.hook, 2); + if (view.opening.definition) { + addSubsectionLabel(i18n.t('export.definition')); + addParagraph(view.opening.definition, 2); + } + addSubsectionLabel(i18n.t('export.roadmap')); + addParagraph(view.opening.roadmap || i18n.t('export.noThesis'), 2); + if (view.legacy.framework) { addSubsectionLabel(i18n.t('export.framework')); - addParagraph(result.caseStructure.framework, 2); + addParagraph(view.legacy.framework, 2); } - if (result.caseStructure?.burdenOfProof) { + if (view.legacy.burdenOfProof) { addSubsectionLabel(i18n.t('export.burdenOfProof')); - addParagraph(result.caseStructure.burdenOfProof, 2); + addParagraph(view.legacy.burdenOfProof, 2); + } + if (view.legacy.definitions.length) { + addSubsectionLabel(i18n.t('export.keyDefinitions')); + addBulletList(view.legacy.definitions); } addSeparator(COLORS.success); // Arguments with accent bars - if (result.contentions?.length) { + if (view.arguments.length) { addSectionTitle(i18n.t('export.arguments'), COLORS.success); // Cycle through accent colors for visual variety @@ -414,33 +413,51 @@ export async function exportCaseToPdf(result: GenerateOutput): Promise [139, 92, 246], // Purple ]; - result.contentions.forEach((c, i) => { + view.arguments.forEach((argument, i) => { const details: { label: string; text: string }[] = []; - if (c.reasoning) details.push({ label: i18n.t('export.reasoning'), text: c.reasoning }); - if (c.evidence) details.push({ label: i18n.t('export.evidence'), text: c.evidence }); - if (c.impact) details.push({ label: i18n.t('export.impact'), text: c.impact }); + if (argument.title && argument.point) details.push({ label: i18n.t('export.point'), text: argument.point }); + if (argument.explanation) details.push({ label: i18n.t('export.explanation'), text: argument.explanation }); + if (argument.evidence) details.push({ label: i18n.t('export.evidence'), text: argument.evidence }); + if (argument.whyItMatters) details.push({ label: i18n.t('export.whyItMatters'), text: argument.whyItMatters }); - addContentionBlock(i, c.claim || '', details, accentColors[i % accentColors.length]); + addContentionBlock(i, argument.title ?? argument.point, details, accentColors[i % accentColors.length]); }); addSeparator(COLORS.warning); } - // Predicted Opponent Case - if (result.predictedOpponentCase?.likelyArguments?.length) { - addSectionTitle(i18n.t('export.predictedOpponent'), COLORS.warning); - addBulletList(result.predictedOpponentCase.likelyArguments); - addSeparator(COLORS.danger); + // Conclusor: the reasons we win replace new arguments. + if (view.whyWeWin.length) { + addSectionTitle(i18n.t('export.whyWeWin'), COLORS.success); + view.whyWeWin.forEach((reason, i) => addParagraph(`${i + 1}. ${reason}`, 2)); + addSeparator(COLORS.warning); } - // Counters - if (result.counters?.length) { - addSectionTitle(i18n.t('export.counters'), COLORS.danger); - result.counters.forEach((c) => { - addSubsectionLabel(`${i18n.t('export.target')}: ${c.targetArgument || ''}`); - if (c.counterpoint) addParagraph(`${i18n.t('export.counter')}: ${c.counterpoint}`, 4); - if (c.rebuttal) addParagraph(`${i18n.t('export.rebuttal')}: ${c.rebuttal}`, 4); + // If they say…: their claim, our answer, the source. + if (view.ifTheySay.length || view.legacy.likelyArguments.length) { + addSectionTitle(i18n.t('export.ifTheySay'), COLORS.danger); + view.ifTheySay.forEach((answer) => { + addSubsectionLabel(`${i18n.t('export.theySay')}: ${answer.theySay}`); + if (answer.weAnswer) addParagraph(`${i18n.t('export.weAnswer')}: ${answer.weAnswer}`, 4); + if (answer.inRound) addParagraph(`${i18n.t('export.rebuttal')}: ${answer.inRound}`, 4); + if (answer.source) addParagraph(`${i18n.t('export.source')}: ${answer.source}`, 4); pdf.y += 3; }); + if (view.legacy.likelyArguments.length) { + addSubsectionLabel(i18n.t('export.predictedOpponent')); + addBulletList(view.legacy.likelyArguments); + } + } + + if (view.closingLine) { + addSeparator(COLORS.primary); + addSectionTitle(i18n.t('export.closingLine'), COLORS.primary); + addParagraph(view.closingLine, 2); + } + + if (view.sources.length) { + addSeparator(COLORS.secondary); + addSectionTitle(i18n.t('export.sources'), COLORS.secondary); + addBulletList(view.sources.map(sourceLine)); } addFooter(); diff --git a/src/locales/en.json b/src/locales/en.json index ebb083b2d..571124572 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1209,7 +1209,27 @@ "undo": "Undo", "resultTitle": "Your case", "unverified": "Unverified", - "unresearchedCaveat": "AI-generated, and no web research backs this evidence. Check facts and sources before using them in a round." + "unresearchedCaveat": "AI-generated, and no web research backs this evidence. Check facts and sources before using them in a round.", + "opening": "Opening", + "openingDesc": "How your speech starts", + "definition": "What we mean:", + "roadmap": "What we will show", + "argumentCount_one": "{{count}} argument", + "argumentCount_other": "{{count}} arguments", + "point": "Point:", + "explanation": "Explanation:", + "whyItMatters": "Why it matters:", + "ifTheySay": "If they say…", + "ifTheySayDesc": "Their likely claims and your answers", + "theySay": "They say", + "weAnswer": "We answer:", + "source": "Source:", + "whyWeWin": "Why we win", + "whyWeWinDesc": "What the judge should remember", + "closingLine": "Closing line", + "closingLineDesc": "Your last sentence", + "sources": "Sources", + "sourcesDesc": "Everything this case cites" }, "attackForm": { "argument": "Your Argument", @@ -2064,7 +2084,19 @@ "factoidsSummary_one": "{{count}} Fact • {{verified}} Verified", "factoidsSummary_other": "{{count}} Facts • {{verified}} Verified", "topIdeasCount_one": "{{count}} Top Idea", - "topIdeasCount_other": "{{count}} Top Ideas" + "topIdeasCount_other": "{{count}} Top Ideas", + "opening": "Opening", + "roadmap": "What we will show", + "definition": "What we mean", + "argumentNumber": "Argument {{number}}", + "point": "Point", + "whyItMatters": "Why it matters", + "ifTheySay": "If they say…", + "theySay": "They say", + "weAnswer": "We answer", + "whyWeWin": "Why we win", + "closingLine": "Closing line", + "sources": "Sources" }, "sparring": { "pro": "Pro", diff --git a/src/locales/es.json b/src/locales/es.json index a961cfac1..071a9469a 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -1209,7 +1209,27 @@ "undo": "Deshacer", "resultTitle": "Tu caso", "unverified": "Sin verificar", - "unresearchedCaveat": "Generado por IA, y ninguna búsqueda web respalda esta evidencia. Comprueba los datos y las fuentes antes de usarlos en una ronda." + "unresearchedCaveat": "Generado por IA, y ninguna búsqueda web respalda esta evidencia. Comprueba los datos y las fuentes antes de usarlos en una ronda.", + "opening": "Apertura", + "openingDesc": "Cómo empieza tu discurso", + "definition": "Qué entendemos por:", + "roadmap": "Lo que vamos a demostrar", + "argumentCount_one": "{{count}} argumento", + "argumentCount_other": "{{count}} argumentos", + "point": "Idea:", + "explanation": "Explicación:", + "whyItMatters": "Por qué importa:", + "ifTheySay": "Si dicen…", + "ifTheySayDesc": "Lo que probablemente dirán y tus respuestas", + "theySay": "Dicen", + "weAnswer": "Respondemos:", + "source": "Fuente:", + "whyWeWin": "Por qué ganamos", + "whyWeWinDesc": "Lo que el jurado debe recordar", + "closingLine": "Frase de cierre", + "closingLineDesc": "Tu última frase", + "sources": "Fuentes", + "sourcesDesc": "Todo lo que cita tu caso" }, "attackForm": { "argument": "Tu argumento", @@ -2064,7 +2084,19 @@ "factoidsSummary_one": "{{count}} dato • Verificados: {{verified}}", "factoidsSummary_other": "{{count}} datos • Verificados: {{verified}}", "topIdeasCount_one": "{{count}} idea destacada", - "topIdeasCount_other": "{{count}} ideas destacadas" + "topIdeasCount_other": "{{count}} ideas destacadas", + "opening": "Apertura", + "roadmap": "Lo que vamos a demostrar", + "definition": "Qué entendemos por", + "argumentNumber": "Argumento {{number}}", + "point": "Idea", + "whyItMatters": "Por qué importa", + "ifTheySay": "Si dicen…", + "theySay": "Dicen", + "weAnswer": "Respondemos", + "whyWeWin": "Por qué ganamos", + "closingLine": "Frase de cierre", + "sources": "Fuentes" }, "sparring": { "pro": "A favor", diff --git a/src/locales/hu.json b/src/locales/hu.json index fb6c31df8..bfdc8e729 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -1209,7 +1209,27 @@ "undo": "Visszavonás", "resultTitle": "Az eseted", "unverified": "Ellenőrizetlen", - "unresearchedCaveat": "MI által készült, és ezt a bizonyítékot nem támasztja alá webes kutatás. Ellenőrizd a tényeket és a forrásokat, mielőtt egy fordulóban használod." + "unresearchedCaveat": "MI által készült, és ezt a bizonyítékot nem támasztja alá webes kutatás. Ellenőrizd a tényeket és a forrásokat, mielőtt egy fordulóban használod.", + "opening": "Nyitás", + "openingDesc": "Így kezdődik a beszéded", + "definition": "Mit értünk alatta:", + "roadmap": "Amit bemutatunk", + "argumentCount_one": "{{count}} érv", + "argumentCount_other": "{{count}} érv", + "point": "Állítás:", + "explanation": "Magyarázat:", + "whyItMatters": "Miért fontos:", + "ifTheySay": "Ha azt mondják…", + "ifTheySayDesc": "Várható állításaik és a válaszaid", + "theySay": "Azt mondják", + "weAnswer": "Válaszunk:", + "source": "Forrás:", + "whyWeWin": "Miért nyerünk", + "whyWeWinDesc": "Amire a bírónak emlékeznie kell", + "closingLine": "Zárómondat", + "closingLineDesc": "Az utolsó mondatod", + "sources": "Források", + "sourcesDesc": "Minden, amire az eseted hivatkozik" }, "attackForm": { "argument": "Az érved", @@ -2064,7 +2084,19 @@ "factoidsSummary_one": "{{count}} tény • Ellenőrzött: {{verified}}", "factoidsSummary_other": "{{count}} tény • Ellenőrzött: {{verified}}", "topIdeasCount_one": "{{count}} legjobb ötlet", - "topIdeasCount_other": "{{count}} legjobb ötlet" + "topIdeasCount_other": "{{count}} legjobb ötlet", + "opening": "Nyitás", + "roadmap": "Amit bemutatunk", + "definition": "Mit értünk alatta", + "argumentNumber": "{{number}}. érv", + "point": "Állítás", + "whyItMatters": "Miért fontos", + "ifTheySay": "Ha azt mondják…", + "theySay": "Azt mondják", + "weAnswer": "Válaszunk", + "whyWeWin": "Miért nyerünk", + "closingLine": "Zárómondat", + "sources": "Források" }, "sparring": { "pro": "Mellette", diff --git a/src/pages/GeneratePage.tsx b/src/pages/GeneratePage.tsx index 44cd7f60d..21c170363 100644 --- a/src/pages/GeneratePage.tsx +++ b/src/pages/GeneratePage.tsx @@ -9,6 +9,7 @@ import { GenerateForm } from '@/components/features/generate/GenerateForm'; import { GenerateResults } from '@/components/features/generate/GenerateResults'; import { generateCase, isAIAuthError, isAIRateLimitError, isTimeoutError, type DiscoveredSource } from '@/lib/ai/client'; import type { GenerateInput } from '@/lib/ai/schemas/generate'; +import { mergeCaseSources } from '@debatelab/core/ai/case-view'; import { useToast } from '@/hooks/use-toast'; import { useSubscriptionError } from '@/hooks/use-subscription-error'; import { getUserFacingAIError } from '@/lib/ai/error-display'; @@ -185,11 +186,13 @@ export default function GeneratePage() { if (useSessionStore.getState().generateResult !== base) return; const regenerated = { ...base, - ...(section === 'structure' ? { caseStructure: next.caseStructure } : {}), + // A new opening replaces an older case's exordio and its framework, burden and definitions. + ...(section === 'opening' ? { opening: next.opening, exordio: next.exordio, caseStructure: next.caseStructure } : {}), ...(section === 'contentions' ? { contentions: next.contentions } : {}), - ...(section === 'opponent' ? { predictedOpponentCase: next.predictedOpponentCase } : {}), - ...(section === 'counters' ? { counters: next.counters } : {}), - ...(section === 'exordio' ? { exordio: next.exordio } : {}), + ...(section === 'counters' ? { counters: next.counters, predictedOpponentCase: next.predictedOpponentCase } : {}), + ...(section === 'whyWeWin' ? { whyWeWin: next.whyWeWin } : {}), + ...(section === 'closing' ? { closingLine: next.closingLine } : {}), + sources: mergeCaseSources(base.sources, next.sources), metadata: next.metadata, }; setGenerateResult(regenerated); diff --git a/supabase/functions/_shared/control-enforcement.ts b/supabase/functions/_shared/control-enforcement.ts index 3987c620d..1aa5f0820 100644 --- a/supabase/functions/_shared/control-enforcement.ts +++ b/supabase/functions/_shared/control-enforcement.ts @@ -151,21 +151,47 @@ export function enforceGenerateControls(output: unknown, side: string, format: s } const generateOutput = output as { exordio?: unknown; + opening?: unknown; contentions?: unknown[]; + counters?: unknown[]; + whyWeWin?: unknown; metadata?: { side?: string; format?: string; generatedAt?: string }; }; if (!generateOutput.metadata || typeof generateOutput.metadata !== "object" || Array.isArray(generateOutput.metadata)) { throw new Error("Generate response contains invalid metadata and cannot satisfy the requested output shape."); } - if (!Array.isArray(generateOutput.contentions)) { - throw new Error("Generate response is missing a valid contentions array and cannot satisfy the requested output shape."); - } generateOutput.metadata.side = side === "con" ? "con" : "pro"; generateOutput.metadata.format = format || "custom"; if (role !== "intro" && "exordio" in generateOutput) { delete generateOutput.exordio; } + // Only the Introductor may define a motion term, and only in one line. + const opening = generateOutput.opening; + if (role !== "intro" && opening && typeof opening === "object" && !Array.isArray(opening)) { + delete (opening as { definition?: unknown }).definition; + } + + // The Conclusor brings no new arguments: 2-3 reasons we win replace them. + if (role === "conc") { + const reasons = Array.isArray(generateOutput.whyWeWin) + ? generateOutput.whyWeWin.filter((item): item is string => typeof item === "string" && item.trim().length > 0) + : []; + const target = length === "brief" ? 2 : 3; + if (reasons.length < 2) { + throw new Error(`Generate response returned ${reasons.length} reasons why we win, but the Conclusor needs ${target}.`); + } + generateOutput.whyWeWin = reasons.slice(0, target); + generateOutput.contentions = []; + generateOutput.counters = []; + return; + } + delete generateOutput.whyWeWin; + + if (!Array.isArray(generateOutput.contentions)) { + throw new Error("Generate response is missing a valid contentions array and cannot satisfy the requested output shape."); + } + if (!Array.isArray(generateOutput.counters)) generateOutput.counters = []; const target = length === "brief" ? 2 : length === "comprehensive" ? 4 : 3; if (generateOutput.contentions.length < target) { throw new Error( @@ -174,3 +200,46 @@ export function enforceGenerateControls(output: unknown, side: string, format: s } generateOutput.contentions = generateOutput.contentions.slice(0, target); } + +const MAX_CASE_SOURCES = 12; + +function comparableUrl(value: string): string | undefined { + try { + const url = new URL(value.trim()); + if (url.protocol !== "https:" && url.protocol !== "http:") return undefined; + url.hash = ""; + return url.href.replace(/\/$/, ""); + } catch { + return undefined; + } +} + +/** + * Keeps the case's sources list honest: a link survives only when the research + * or the student's Library supplied it, so a link the model made up never + * reaches the page or an export. The organisation, year and claim stay. + */ +export function enforceGenerateSources(output: unknown, knownUrls: Iterable) { + if (!output || typeof output !== "object" || !("sources" in output)) return; + const generateOutput = output as { sources?: unknown }; + if (!Array.isArray(generateOutput.sources)) { + delete generateOutput.sources; + return; + } + const known = new Set([...knownUrls].map(comparableUrl).filter((url): url is string => Boolean(url))); + generateOutput.sources = generateOutput.sources.flatMap((value) => { + if (!value || typeof value !== "object" || Array.isArray(value)) return []; + const source = value as Record; + const org = typeof source.org === "string" ? source.org.trim() : ""; + const claim = typeof source.claim === "string" ? source.claim.trim() : ""; + if (!org && !claim) return []; + const year = typeof source.year === "string" || typeof source.year === "number" ? String(source.year).trim() : ""; + const url = typeof source.url === "string" ? comparableUrl(source.url) : undefined; + return [{ + org, + claim, + ...(year ? { year } : {}), + ...(url && known.has(url) ? { url: (source.url as string).trim() } : {}), + }]; + }).slice(0, MAX_CASE_SOURCES); +} diff --git a/supabase/functions/_shared/debate-context.ts b/supabase/functions/_shared/debate-context.ts index 178c09011..9e70af995 100644 --- a/supabase/functions/_shared/debate-context.ts +++ b/supabase/functions/_shared/debate-context.ts @@ -181,11 +181,12 @@ function digestCase(data: Record): string { text(structure.thesis, 300) && `Thesis: ${text(structure.thesis, 300)}`, text(structure.framework, 240) && `Framework: ${text(structure.framework, 240)}`, ...contentions.slice(0, 4).map((item, index) => lines([ - `Contention ${index + 1}: ${text(item.claim, 200)}`, - text(item.reasoning, 320) && ` Warrant: ${text(item.reasoning, 320)}`, - text(item.impact, 240) && ` Impact: ${text(item.impact, 240)}`, + `Argument ${index + 1}: ${text(item.title, 80) ? `${text(item.title, 80)}. ` : ""}${text(item.claim, 200)}`, + text(item.reasoning, 320) && ` Explanation: ${text(item.reasoning, 320)}`, + text(item.impact, 240) && ` Why it matters: ${text(item.impact, 240)}`, ])), - ...counters.slice(0, 3).map((item) => `Prepared answer to "${text(item.targetArgument, 120)}": ${text(item.counterpoint, 200)}`), + ...list(data.whyWeWin).slice(0, 3).map((item) => text(item, 240) && `Why we win: ${text(item, 240)}`), + ...counters.slice(0, 3).map((item) => `If they say "${text(item.targetArgument, 120)}", we answer: ${text(item.counterpoint, 200)}`), ]); } diff --git a/supabase/functions/_shared/prompts/generate.ts b/supabase/functions/_shared/prompts/generate.ts index fb5f79b65..b69fe4483 100644 --- a/supabase/functions/_shared/prompts/generate.ts +++ b/supabase/functions/_shared/prompts/generate.ts @@ -7,51 +7,42 @@ export const GENERATE_PROMPT = `You are an experienced competitive debater and c If WEB RESEARCH RESULTS are attached below, treat them as the primary source for current real-world data, statistics, and sources. Prefer evidence from the last two years before the current date. If no research is attached, do not claim that current sources were checked and mark evidence gaps honestly. -=== DEBATE FORMAT (4-6-6-3) === +=== THE SPEECH STRUCTURE === -This is a 4-speaker team debate format. Each speaker has a distinct job: +Write the case the way school debaters actually prepare it, in plain words: +1. OPENING: a hook, then the line "Today we will show N arguments that prove..." (or the speaker's own version of it). +2. ARGUMENTS: each with a short title, a one-sentence point, a plain explanation of why it is true, evidence with a named source and year, and why it matters. +3. IF THEY SAY...: the opponent's likely claims, each followed by our answer and, when there is one, the source behind the answer. +4. CLOSING LINE: one sentence that ties back to the opening, like "That is why it is clear that...". +5. SOURCES: every source the case cites, as organisation, year, the claim it supports, and the link. + +Do not use debate theory jargon anywhere in the output. No "framework", "burden of proof", "weighing", "warrant", "impact calculus", "links", "brightlines", "uniqueness", "magnitude", "probability", "timeframe", "turns", "mitigations" or "clash". Say the same thing in everyday words. + +=== THE FOUR SPEAKERS (4-6-6-3) === + +This is a 4-speaker team format. The role decides what the speech contains: --- INTRODUCTOR (4 minutes) --- -The Introductor sets the entire narrative. Their job: -1. EXORDIO: Open with a clear, specific hook, rhetorical question, or vivid scenario. Use a real anecdote or statistic only when the provided research supports it. Never invent one. Then bridge smoothly into the topic. -2. DEFINITIONS: Define key terms strategically (not dictionary definitions, frame them to favor your side). -3. FRAMEWORK: Establish the evaluative lens (utilitarian, rights-based, pragmatic, etc.). -4. THESIS: State your team's central claim clearly and memorably. -5. ARGUMENT ROADMAP: Preview the structure, "Our team will show three things..." -The Introductor does NOT present full arguments, they set up the battlefield. +Opens the team's case. A hook (a vivid scenario, a question, or a fact from the research), then "we will show N arguments that prove..." and the arguments at headline weight: title, point, and one piece of evidence each, kept short. If, and only if, a word in the motion is genuinely unclear (for example "AI tools" or "minors"), add ONE plain line saying how the team understands it in opening.definition. Otherwise leave opening.definition out. Never write a list of definitions. --- R1 - First Speaker (6 minutes) --- -R1 is the workhorse. Their job: -- 4 minutes CONSTRUCTIVE: Build 2-3 core arguments with full warrant chains. Each argument needs: claim → reasoning → evidence → impact. Go DEEP, not wide. Every link in the chain must be airtight. -- 2 minutes REBUTTAL: Anticipate and preemptively address the strongest opponent arguments. Use turns, mitigations, and no-link responses. -R1 provides the substance. They make the team's case undeniable with concrete evidence and tight logic. +Builds the team's case in full. A short opening line, then the arguments in depth: each explanation walks through why it is true, step by step, and each piece of evidence names its source and year. Then the "If they say..." answers to the strongest opposing claims. --- R2 - Second Speaker (6 minutes) --- -R2 is the strategist. Their job: -- EXTEND R1's arguments: Don't just repeat, deepen. Add new evidence, new warranting, responses to likely attacks on R1's case. -- CLASH: Directly engage with opponent arguments. Identify the central disagreements and explain why your side wins each clash. -- WEIGHING: Compare impacts. Use magnitude, probability, and timeframe to show why your harms/benefits matter more. -R2 wins the debate by controlling the narrative and winning the key clashes. +About 2 minutes reinforcing the team's arguments, then about 4 minutes of refutation. Keep each argument short: it restates and strengthens a point the team already made, with new evidence where possible. Then the "If they say..." list is the heart of the speech: 5-8 items, each a direct answer the speaker can say out loud, with a source when one supports it. A short opening line can say who is speaking, such as "I'm the R2 of my team, and I'm here to show that...". --- CONCLUSOR (3 minutes) --- -The Conclusor crystallizes. Their job: -- NO NEW ARGUMENTS: Only synthesize and weigh what's already been said. -- KEY CLASHES: Identify the 2-3 most important disagreements in the round and explain why your side wins each one. -- WEIGHING: Even if the opponent is partially right, explain why your side still wins on balance. -- STRONG CLOSE: End with a powerful, memorable statement that ties back to the exordio or thesis. -The Conclusor is the last voice the judge hears. Make it count. +Closes the debate. NO new arguments: leave contentions and counters as empty arrays. Instead write whyWeWin: 2-3 short paragraphs, each summarising one reason our side won the debate, drawing on what the team already argued. Then the closing line. The opening is one sentence that sets up the summary. === WRITING STYLE, Sound like a REAL debater === DO: - Match the requested audience, length, and tone. Use familiar words for beginners and keep brief cases compact. -- Use debate terminology naturally: "weighing", "impacts", "links", "warrants", "brightlines", "uniqueness", "magnitude", "probability", "timeframe", "turns", "mitigations" -- Write arguments as you would speak them in round, confident, persuasive, conversational -- Vary sentence length. Mix short punchy sentences with longer analytical ones. +- Write every field as something the speaker can say out loud: confident, persuasive, conversational. +- Vary sentence length. Mix short punchy sentences with longer explanations. - Use rhetorical questions: "What happens when we ignore this?" -- Use transitions that sound human: "Look, here's the thing...", "What does this mean in practice?", "And this is where it gets interesting..." - Be assertive: "This matters because..." not "One might argue that..." -- Use analogies and vivid examples that make abstract points concrete +- Use analogies and vivid examples that make abstract points concrete. DO NOT: - Say "It is important to note" or "It should be noted" @@ -59,48 +50,43 @@ DO NOT: - Say "In conclusion" or "To summarize" - Say "This demonstrates that" or "This clearly shows" - Say "One could argue" or "It could be said" -- Say "It is worth mentioning" or "It is crucial to understand" - Use any robotic AI filler phrases. If it sounds like a Wikipedia article, rewrite it. -- Start every sentence with "The", vary your sentence openings +- Start every sentence with "The", vary your sentence openings. === JSON SCHEMA === -The output MUST include the "exordio" field when the role is "intro". For other roles, omit "exordio". - { - "exordio": { - "hook": "A compelling opening, anecdote, startling statistic, rhetorical question, or vivid scenario that grabs attention. Should be 2-4 sentences. Make it vivid and specific, not generic.", - "transition": "A smooth 1-2 sentence bridge from the hook into the topic and your team's position." + "opening": { + "hook": "1-2 sentences that grab attention: a vivid scenario, a question, or a fact from the research. Never invent a statistic or anecdote.", + "definition": "Introductor only, and only when a motion term is genuinely unclear: one line saying how we understand it. Otherwise omit this field." }, "caseStructure": { - "thesis": "A punchy, memorable thesis statement", - "framework": "The lens/value through which to evaluate this debate", - "keyDefinitions": ["definition1", "definition2"], - "burdenOfProof": "Our burden - what we must prove to win the ballot" + "thesis": "The roadmap line: 'Today we will show N arguments that prove...' in the speaker's voice." }, "contentions": [ { "id": "1", - "claim": "Clear, tagline-style claim", - "reasoning": "The warrant - WHY this claim is true. Provide 2-3 logical links with clear causation chains.", - "evidence": "Specific stats, studies, expert quotes FROM THE PROVIDED RESEARCH. Cite sources with dates.", + "title": "A short title of 2-6 words", + "claim": "The point: one sentence a judge can write down.", + "reasoning": "The explanation: why the point is true, in plain words, step by step.", + "evidence": "A statistic, study, or quote FROM THE PROVIDED RESEARCH, naming the source and year in the sentence, e.g. 'UNICEF said in 2025 that...'.", "needsSource": false, "sourceQuery": "", - "impact": "A cohesive, speech-ready impact paragraph. Explain why the judge should care using magnitude, probability, and timeframe naturally, but never label them as separate parameters." + "impact": "Why it matters: 1-2 plain sentences on what this means for real people." } ], - "predictedOpponentCase": { - "likelyArguments": ["5-8 arguments they will run"], - "strategicWeaknesses": ["Honest assessment of our vulnerabilities"], - "keyClashes": ["Where the debate will be won or lost"] - }, "counters": [ { - "targetArgument": "Their argument", - "counterpoint": "Our response - turn, mitigate, or take out", - "rebuttal": "How to phrase this in round" + "targetArgument": "What they will say, in one sentence.", + "counterpoint": "Our answer: one speakable paragraph.", + "source": "The source behind the answer as 'Organisation, year', when one supports it. Otherwise omit." } ], + "whyWeWin": ["Conclusor only: 2-3 short summaries of why our side won. Omit for other roles."], + "closingLine": "One sentence that ties back to the opening.", + "sources": [ + { "org": "Organisation or publication", "year": "2025", "claim": "What this source supports, in a few words", "url": "https://the exact link from the research" } + ], "metadata": { "side": "pro", "format": "custom", @@ -108,47 +94,49 @@ The output MUST include the "exordio" field when the role is "intro". For other } } -ROLE-SPECIFIC OUTPUT GUIDELINES: -- "intro": MUST include "exordio" with hook and transition. Contention COUNT follows the Length Instruction exactly; keep them high-level roadmap points (lighter on evidence, heavier on framing). -- "r1": Omit "exordio". Provide DEEP contentions at the Length Instruction count, with extensive evidence and warrants. Counters should be preemptive rebuttals. -- "r2": Omit "exordio". Contentions are extensions + clash responses at the Length Instruction count. Focus predictedOpponentCase and counters heavily on weighing analysis. -- "conc": Omit "exordio". Provide contentions framed as crystallization points ("Even if they prove X, we still win because Y"). Counters should be final-round weighing responses. +CONTROL PRECEDENCE: The Length Instruction sets the EXACT number of contentions (or, for the Conclusor, of whyWeWin items). Role guidance shapes their depth and order only. -CONTROL PRECEDENCE: When role guidance and the Length Instruction appear to conflict, the Length Instruction sets the EXACT number of contentions. Role guidance shapes their depth and framing only. - -SPEECH-READY CONTENTIONS: Each contention must be something the requested speaker can deliver in round: +SPEECH-READY ARGUMENTS: Each contention must be something the requested speaker can deliver in round: - claim: one sentence a judge can write down. -- reasoning: the warrant. Spell out every causal step from the status quo to the claim, so a listener can see why it is true. Never state a conclusion as its own reason. -- evidence: say how a specific research or Library source proves the warrant, naming the source and year in the sentence. When no attached source supports it, describe the evidence still needed and set needsSource to true. -- impact: why the judge should care, then weigh it. Compare it with the other side's most likely impact on size, likelihood, speed, or reversibility, in plain spoken sentences. -- For "r2" and "conc", every rebuttal in counters must say why our side still wins that clash, not only that the opponent is wrong. +- reasoning: spell out every step from how things are now to the point, so a listener can see why it is true. Never state a conclusion as its own reason. +- evidence: say how a specific research or Library source proves the point, naming the source and year in the sentence. When no attached source supports it, describe the evidence still needed and set needsSource to true. +- impact: why it matters to real people, compared with what the other side is likely to say, in plain spoken sentences. + +SOURCES LIST: List each source the case actually cites once. Copy the link exactly as it appears in the attached research or Library; never invent or guess a link, and leave "url" out when you have none. When no research is attached, the list may be empty. -TEAM CONSISTENCY: If CURRENT DEBATE CONTEXT includes an earlier case or brainstorm for the same side, keep the team line consistent. Reuse its thesis, framework, and definitions unless this request changes them, and extend rather than repeat earlier contentions when the role is "r2" or "conc". Follow the debate's agreed definitions and judge expectations. +TEAM CONSISTENCY: If CURRENT DEBATE CONTEXT includes an earlier case or brainstorm for the same side, keep the team line consistent. Reuse its opening line and arguments unless this request changes them, and build on rather than repeat earlier arguments when the role is "r2" or "conc". Follow the debate's agreed definitions and judge expectations. REQUIREMENTS: 1. Side must be exactly "pro" or "con". 2. Generate contentions appropriate to the role (see above). -3. Include 5-8 predicted opponent arguments with pre-written counters. +3. Except for the Conclusor, include 5-8 counters, one per likely opposing claim. 4. Use attached WEB RESEARCH when present. Cite only sources that appear in it. 5. Set needsSource to false only when the attached research supports the contention. Set it to true when the contention still needs evidence. 6. Write the ENTIRE output in the language specified by the user. All arguments, evidence, descriptions, and analysis must be in that language. -7. Every contention should read like something a debater can actually say in a speech. Do not output table-like notes or field labels inside prose.`; +7. Every field should read like something a debater can actually say in a speech. Do not output table-like notes or field labels inside prose.`; export function buildGenerateUserPrompt(input: Record, searchContext: string): string { const language = asString(input.language, "en"); const length = asString(input.length, "standard"); const audience = asString(input.audience, "intermediate"); const tone = asString(input.tone, "formal"); - const lengthInstruction = { - brief: "Return exactly 2 contentions. Keep each contention compact: one short warrant chain, one evidence point, and one impact.", - standard: "Return exactly 3 contentions. Use moderate reasoning depth with clear warrants, evidence, and impacts.", - comprehensive: "Return exactly 4 contentions. Use deeper warrant chains, more clash framing, and more developed impact comparison.", - }[length] || "Return exactly 3 contentions. Use moderate reasoning depth with clear warrants, evidence, and impacts."; + const role = asString(input.role, "intro"); + const lengthInstruction = role === "conc" + ? ({ + brief: "Return no contentions and no counters. Return exactly 2 whyWeWin items, each two or three sentences.", + standard: "Return no contentions and no counters. Return exactly 3 whyWeWin items, each two or three sentences.", + comprehensive: "Return no contentions and no counters. Return exactly 3 whyWeWin items, each a short developed paragraph.", + }[length] || "Return no contentions and no counters. Return exactly 3 whyWeWin items, each two or three sentences.") + : ({ + brief: "Return exactly 2 contentions. Keep each contention compact: a short explanation, one evidence point, and why it matters.", + standard: "Return exactly 3 contentions. Give each a clear explanation, evidence, and why it matters.", + comprehensive: "Return exactly 4 contentions. Give each a step-by-step explanation, more evidence, and a fuller why it matters.", + }[length] || "Return exactly 3 contentions. Give each a clear explanation, evidence, and why it matters."); const audienceInstruction = { - beginner: "Assume a newer debater: define debate jargon briefly and use straightforward explanation density.", - intermediate: "Assume a trained debater: use normal tournament vocabulary with concise explanations.", - advanced: "Assume an advanced debater: use dense strategic framing, weighing, and clash vocabulary without over-explaining basics.", - }[audience] || "Assume a trained debater: use normal tournament vocabulary with concise explanations."; + beginner: "Assume a newer debater: use everyday words and explain each step simply.", + intermediate: "Assume a trained debater: use clear, concise explanations.", + advanced: "Assume an advanced debater: pack in more evidence and sharper comparisons without over-explaining basics.", + }[audience] || "Assume a trained debater: use clear, concise explanations."; const toneInstruction = { formal: "Use polished, judge-facing language with controlled phrasing.", conversational: "Use natural spoken-round phrasing without slang or filler.", @@ -157,7 +145,7 @@ export function buildGenerateUserPrompt(input: Record, searchCo return `Motion: ${asString(input.motion)} Side: ${asString(input.side, "pro")} Format: ${asString(input.format, "custom")} -Role: ${asString(input.role, "intro")} +Role: ${role} Length: ${length} Length Instruction: ${lengthInstruction} Audience: ${audience} diff --git a/supabase/functions/ai/features/generate.ts b/supabase/functions/ai/features/generate.ts index 09a69e9f2..19cd0a0d6 100644 --- a/supabase/functions/ai/features/generate.ts +++ b/supabase/functions/ai/features/generate.ts @@ -12,7 +12,7 @@ import { getResearchSources, withResearchPreferences, } from "../../_shared/research.ts"; -import { enforceGenerateControls } from "../../_shared/control-enforcement.ts"; +import { enforceGenerateControls, enforceGenerateSources } from "../../_shared/control-enforcement.ts"; import { boundPromptSources } from "../../_shared/prompts.ts"; import { JEV_MODEL } from "../../_shared/jev.ts"; import { @@ -112,6 +112,13 @@ export async function handleGenerate(ctx: FeatureContext): Promise { metadata.provider = provider; } enforceGenerateControls(output, side, format, length, role); + // Links the model saw in this request: research pages, search results, Library origins. + enforceGenerateSources(output, [ + ...research.readContent.map((page) => page.url), + ...research.searches.flatMap((search) => search.results.map((result) => result.url)), + ...research.rankedResults.map((result) => result.url), + ...boundPromptSources(sources).flatMap((source) => source.origin ? [source.origin] : []), + ].filter((url): url is string => typeof url === "string" && url.length > 0)); // Build discovered sources from research const activeAssessmentByKey = new Map(sourceAssessments.map((assessment) => [assessment.key, assessment])); diff --git a/supabase/functions/export-gdoc/index.ts b/supabase/functions/export-gdoc/index.ts index 4f3206de2..ea010f2c5 100644 --- a/supabase/functions/export-gdoc/index.ts +++ b/supabase/functions/export-gdoc/index.ts @@ -294,21 +294,37 @@ function createDocumentBuilder() { // GENERATE MODE // ============================================================ interface GenerateData { - caseStructure?: { thesis?: string; framework?: string; burdenOfProof?: string }; - contentions?: { claim?: string; reasoning?: string; evidence?: string; impact?: string }[]; + opening?: { hook?: string; definition?: string }; + exordio?: { hook?: string; transition?: string }; + caseStructure?: { thesis?: string; framework?: string; burdenOfProof?: string; keyDefinitions?: string[] }; + contentions?: { title?: string; claim?: string; reasoning?: string; evidence?: string; impact?: string }[]; predictedOpponentCase?: { likelyArguments?: string[] }; - counters?: { targetArgument?: string; counterpoint?: string; rebuttal?: string }[]; + counters?: { targetArgument?: string; counterpoint?: string; rebuttal?: string; source?: string }[]; + whyWeWin?: string[]; + closingLine?: string; + sources?: { org?: string; year?: string; claim?: string; url?: string }[]; metadata?: { side?: string; format?: string; generatedAt?: string }; - exordio?: { hook?: string; transition?: string }; } -function buildGenerateContent(data: GenerateData) { +function present(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() ? value.trim() : undefined; +} + +function presentList(value: unknown): string[] { + return Array.isArray(value) ? value.map(present).filter((item): item is string => Boolean(item)) : []; +} + +// Mirrors readCase in packages/core/src/ai/case-view.ts: new cases have an +// opening, arguments, "If they say…", a closing line and sources; older saved +// cases keep exordio, framework, burden of proof, definitions and predicted +// arguments, which appear only when they have content. +export function buildGenerateContent(data: GenerateData) { const { requests, insertText, applyTitle, applySubtitle, applySectionHeader, applySubHeader, applyLabel, applyBody, applyBullet, applyEmphasis } = createDocumentBuilder(); - const thesis = data.caseStructure?.thesis?.trim() || 'Untitled Thesis'; + const thesis = present(data.caseStructure?.thesis) || 'Untitled Thesis'; const motion = (data.metadata as Record)?.motion as string | undefined; const thesisTitle = motion || (thesis.length > 200 ? thesis.substring(0, 197) + '...' : thesis); @@ -323,106 +339,88 @@ function buildGenerateContent(data: GenerateData) { const metaRange = insertText(`${side} • ${format} • ${date}\n`); applySubtitle(metaRange); - // Exordio Section (if present) - if (data.exordio && (data.exordio.hook || data.exordio.transition)) { - const exordioSectionRange = insertText('Exordio\n'); - applySectionHeader(exordioSectionRange); - if (data.exordio.hook) { - const hookLabel = insertText('Hook: '); - applyLabel(hookLabel); - applyBody(insertText(`${data.exordio.hook}\n`)); - } - if (data.exordio.transition) { - const transitionLabel = insertText('Transition: '); - applyLabel(transitionLabel); - applyBody(insertText(`${data.exordio.transition}\n`)); - } - } - - // Thesis Section - const thesisSectionRange = insertText('Thesis\n'); - applySectionHeader(thesisSectionRange); - const thesisBodyRange = insertText(`${thesis}\n`); - applyEmphasis(thesisBodyRange); - - // Framework Section - const framework = data.caseStructure?.framework?.trim() || 'No framework provided.'; - const frameworkSectionRange = insertText('Framework\n'); - applySectionHeader(frameworkSectionRange); - applyBody(insertText(`${framework}\n`)); - - // Burden of Proof Section - const burden = data.caseStructure?.burdenOfProof?.trim() || 'No burden of proof provided.'; - const burdenSectionRange = insertText('Burden of Proof\n'); - applySectionHeader(burdenSectionRange); - applyBody(insertText(`${burden}\n`)); - - // Contentions Section - const contentionsSectionRange = insertText('Arguments\n'); - applySectionHeader(contentionsSectionRange); - const contentions = data.contentions || []; - if (contentions.length === 0) { - applyBody(insertText('No arguments provided.\n')); - } else { + const section = (title: string) => applySectionHeader(insertText(`${title}\n`)); + const labelled = (label: string, text: string | undefined) => { + if (!text) return; + applyLabel(insertText(`${label}: `)); + applyBody(insertText(`${text}\n`)); + }; + const bullets = (items: string[]) => items.forEach((item) => applyBullet(insertText(`• ${item}\n`))); + + // Opening + const hook = present(data.opening?.hook) + ?? ([present(data.exordio?.hook), present(data.exordio?.transition)].filter(Boolean).join(' ') || undefined); + section('Opening'); + if (hook) applyBody(insertText(`${hook}\n`)); + labelled('What we mean', present(data.opening?.definition)); + applyEmphasis(insertText(`${thesis}\n`)); + labelled('Framework', present(data.caseStructure?.framework)); + labelled('Burden of Proof', present(data.caseStructure?.burdenOfProof)); + const definitions = presentList(data.caseStructure?.keyDefinitions); + if (definitions.length) labelled('Key Definitions', definitions.join('; ')); + + // Arguments + const contentions = Array.isArray(data.contentions) ? data.contentions : []; + if (contentions.length) { + section('Arguments'); contentions.forEach((c, i) => { - // Contention header with number - const claimRange = insertText(`Argument ${i + 1}: ${c?.claim || 'Untitled'}\n`); - applySubHeader(claimRange); - - // Labeled body content - if (c?.reasoning) { - const reasonLabel = insertText('Reasoning: '); - applyLabel(reasonLabel); - applyBody(insertText(`${c.reasoning}\n`)); - } - if (c?.evidence) { - const evidenceLabel = insertText('Evidence: '); - applyLabel(evidenceLabel); - applyBody(insertText(`${c.evidence}\n`)); - } - if (c?.impact) { - const impactLabel = insertText('Impact: '); - applyLabel(impactLabel); - applyBody(insertText(`${c.impact}\n`)); - } + const title = present(c?.title); + const point = present(c?.claim); + applySubHeader(insertText(`Argument ${i + 1}: ${title ?? point ?? 'Untitled'}\n`)); + if (title) labelled('Point', point); + labelled('Explanation', present(c?.reasoning)); + labelled('Evidence', present(c?.evidence)); + labelled('Why it matters', present(c?.impact)); }); } - // Predicted Opponent Case Section - const predictedSectionRange = insertText('Predicted Opponent Case\n'); - applySectionHeader(predictedSectionRange); - const likelyArgs = data.predictedOpponentCase?.likelyArguments || []; - if (likelyArgs.length === 0) { - applyBody(insertText('No predicted arguments.\n')); - } else { - likelyArgs.forEach(arg => { - const bulletRange = insertText(`• ${arg}\n`); - applyBullet(bulletRange); - }); + // Conclusor: why we win + const reasons = presentList(data.whyWeWin); + if (reasons.length) { + section('Why we win'); + reasons.forEach((reason, i) => applyBody(insertText(`${i + 1}. ${reason}\n`))); } - // Prepared Counters Section - const countersSectionRange = insertText('Prepared Counters\n'); - applySectionHeader(countersSectionRange); - const counters = data.counters || []; - if (counters.length === 0) { - applyBody(insertText('No counters provided.\n')); - } else { - counters.forEach((c, i) => { - const targetRange = insertText(`Counter ${i + 1}: ${c?.targetArgument || 'Unknown Target'}\n`); - applySubHeader(targetRange); + // If they say… + const counters = (Array.isArray(data.counters) ? data.counters : []) + .filter((c) => present(c?.targetArgument) || present(c?.counterpoint) || present(c?.rebuttal)); + const answered = new Set(counters.map((c) => present(c?.targetArgument)?.toLowerCase())); + const likelyArgs = presentList(data.predictedOpponentCase?.likelyArguments) + .filter((argument) => !answered.has(argument.toLowerCase())); + if (counters.length || likelyArgs.length) { + section('If they say…'); + counters.forEach((c) => { + const counterpoint = present(c?.counterpoint); + const rebuttal = present(c?.rebuttal); + applySubHeader(insertText(`They say: ${present(c?.targetArgument) ?? ''}\n`)); + labelled('We answer', counterpoint ?? rebuttal); + if (counterpoint && rebuttal && rebuttal !== counterpoint) labelled('Rebuttal', rebuttal); + labelled('Source', present(c?.source)); + }); + if (likelyArgs.length) { + applySubHeader(insertText('Predicted Opponent Case\n')); + bullets(likelyArgs); + } + } - if (c?.counterpoint) { - const counterLabel = insertText('Counterpoint: '); - applyLabel(counterLabel); - applyBody(insertText(`${c.counterpoint}\n`)); - } - if (c?.rebuttal) { - const rebuttalLabel = insertText('Rebuttal: '); - applyLabel(rebuttalLabel); - applyBody(insertText(`${c.rebuttal}\n`)); - } - }); + const closingLine = present(data.closingLine); + if (closingLine) { + section('Closing line'); + applyEmphasis(insertText(`${closingLine}\n`)); + } + + const sources = (Array.isArray(data.sources) ? data.sources : []).flatMap((source) => { + const org = present(source?.org); + const year = present(source?.year); + const claim = present(source?.claim); + const url = present(source?.url); + const label = [org, year && `(${year})`].filter(Boolean).join(' '); + const line = [label, claim, url && /^https?:\/\//i.test(url) ? url : undefined].filter(Boolean).join(' – '); + return line ? [line] : []; + }); + if (sources.length) { + section('Sources'); + bullets(sources); } return { requests, title: `Debate Case: ${thesisTitle}` }; From 4684f93d86a534ed0db99695e136bbfc6b6e8128 Mon Sep 17 00:00:00 2001 From: Adam Date: Tue, 27 Oct 2026 09:17:14 +0000 Subject: [PATCH 02/12] Test the new case shape, old saved cases and every case export Co-Authored-By: Claude Opus 5.5 --- src/components/features/tools/result-text.ts | 2 +- src/test/helpers/cases.ts | 59 ++++++++++ src/test/lib/case-exports.test.ts | 109 ++++++++++++++++++ src/test/lib/case-view.test.ts | 87 ++++++++++++++ src/test/lib/flowchart.test.ts | 25 +++- .../_shared/control-enforcement.test.ts | 46 ++++++++ .../_shared/prompts/prompt-examples.test.ts | 2 +- .../_shared/prompts/prompt-examples.ts | 4 +- supabase/functions/ai/control-matrix.test.ts | 14 ++- supabase/functions/ai/prompt-builder.test.ts | 4 +- .../functions/export-gdoc/generate_test.ts | 76 ++++++++++++ 11 files changed, 421 insertions(+), 7 deletions(-) create mode 100644 src/test/helpers/cases.ts create mode 100644 src/test/lib/case-exports.test.ts create mode 100644 src/test/lib/case-view.test.ts create mode 100644 supabase/functions/export-gdoc/generate_test.ts diff --git a/src/components/features/tools/result-text.ts b/src/components/features/tools/result-text.ts index 6469246cd..ce6041d7a 100644 --- a/src/components/features/tools/result-text.ts +++ b/src/components/features/tools/result-text.ts @@ -31,7 +31,7 @@ export function caseText(result: GenerateOutput): string { }); block('WHY WE WIN:', view.whyWeWin.map((reason, i) => ` ${i + 1}. ${reason}`)); block('IF THEY SAY...', view.ifTheySay.map((answer) => - ` "${answer.theySay}" -> ${answer.weAnswer}${answer.source ? ` (${answer.source})` : ''}`)); + ` "${answer.theySay}" -> ${answer.weAnswer}${answer.inRound ? ` ${answer.inRound}` : ''}${answer.source ? ` (${answer.source})` : ''}`)); if (view.closingLine) block('CLOSING LINE:', [` ${view.closingLine}`]); block('SOURCES:', view.sources.map((source) => ` - ${sourceLine(source)}`)); return lines.join('\n'); diff --git a/src/test/helpers/cases.ts b/src/test/helpers/cases.ts new file mode 100644 index 000000000..b8c6ad47a --- /dev/null +++ b/src/test/helpers/cases.ts @@ -0,0 +1,59 @@ +import type { GenerateOutput } from '@/lib/ai/schemas/generate'; + +// A case in today's shape: an R2 speech with a short opening, one argument, +// "If they say…" answers, a closing line and sources. +export const NEW_CASE: GenerateOutput = { + opening: { hook: 'Picture a pupil who hides her phone to use AI at night.' }, + caseStructure: { thesis: 'Today we will show 1 argument that proves a ban does more harm than good.' }, + contentions: [{ + id: '1', + title: 'One size fits all', + claim: 'A total ban ignores that children grow up.', + reasoning: 'A ban treats a 7-year-old and a 17-year-old the same.', + evidence: 'UNICEF said in 2025 that age limits alone do not keep children safe.', + needsSource: false, + impact: 'Older pupils lose a learning tool they use responsibly.', + }], + counters: [{ + targetArgument: 'AI makes kids cheat.', + counterpoint: 'Pupils cheated before AI; a ban removes supervision, not cheating.', + source: 'OECD, 2024', + }], + closingLine: 'That is why it is clear that a ban is the wrong answer.', + sources: [ + { org: 'UNICEF', year: '2025', claim: 'Age limits alone do not keep children safe', url: 'https://www.unicef.org/report' }, + { org: 'Old Wiki', claim: 'A link that must never be followed', url: 'javascript:alert(1)' }, + ], + metadata: { side: 'con', format: 'custom', generatedAt: '2026-10-27T10:00:00.000Z' }, +}; + +// A case saved before the restructure, with every section it used to have. +export const OLD_CASE: GenerateOutput = { + exordio: { hook: 'Imagine a city where the bus is free.', transition: 'That city is closer than you think.' }, + caseStructure: { + thesis: 'Free transit is a net public good.', + framework: 'Net societal benefit.', + keyDefinitions: ['Public transport: publicly funded scheduled transit.'], + burdenOfProof: 'We must show a net gain in practice.', + }, + contentions: [{ + id: '1', + claim: 'Fares block the riders who need transit most.', + reasoning: 'Fares are a regressive barrier.', + evidence: 'Comparable cities report ridership gains.', + needsSource: false, + sourceQuery: '', + impact: 'More people reach work and school.', + }], + predictedOpponentCase: { + likelyArguments: ['Funding gap', 'Overcrowding'], + strategicWeaknesses: ['Assumes fares are the only funding model.'], + keyClashes: ['Who bears the cost.'], + }, + counters: [{ + targetArgument: 'Funding gap', + counterpoint: 'Fare collection has heavy overhead.', + rebuttal: 'Net cost is far below headline farebox revenue.', + }], + metadata: { side: 'pro', format: 'custom', generatedAt: '2026-01-06T10:00:00.000Z' }, +}; diff --git a/src/test/lib/case-exports.test.ts b/src/test/lib/case-exports.test.ts new file mode 100644 index 000000000..1d6cf6e72 --- /dev/null +++ b/src/test/lib/case-exports.test.ts @@ -0,0 +1,109 @@ +import { inflateRawSync } from 'node:zlib'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { exportCaseToDocx } from '@/lib/export-utils'; +import { exportCaseToPdf } from '@/lib/pdf-export-utils'; +import { caseText } from '@/components/features/tools/result-text'; +import { NEW_CASE, OLD_CASE } from '../helpers/cases'; + +// Every line the PDF draws, recorded by a stand-in for jsPDF. Drawing calls the +// test does not need (colours, fonts, shapes, save) do nothing. +const drawn = vi.hoisted(() => [] as string[]); + +vi.mock('jspdf', () => ({ + jsPDF: function FakePdf() { + const doc = { + internal: { pageSize: { width: 210, height: 297 } }, + // No font files to load in a test. + addFileToVFS: undefined, + text: (value: string | string[]) => { drawn.push(...[value].flat()); }, + splitTextToSize: (value: string) => [value], + getNumberOfPages: () => 1, + }; + return new Proxy(doc, { get: (target, key) => (key in target ? Reflect.get(target, key) : () => undefined) }); + }, +})); +vi.mock('jspdf-autotable', () => ({ default: () => undefined })); + +/** The text of one file inside a .docx (a zip), read through its central directory. */ +function zipEntry(zip: Buffer, name: string): string { + const end = zip.lastIndexOf(Buffer.from([0x50, 0x4b, 0x05, 0x06])); + let entry = zip.readUInt32LE(end + 16); + for (let i = 0; i < zip.readUInt16LE(end + 10); i++) { + const nameLength = zip.readUInt16LE(entry + 28); + if (zip.toString('utf8', entry + 46, entry + 46 + nameLength) === name) { + const local = zip.readUInt32LE(entry + 42); + const start = local + 30 + zip.readUInt16LE(local + 26) + zip.readUInt16LE(local + 28); + const data = zip.subarray(start, start + zip.readUInt32LE(entry + 20)); + return (zip.readUInt16LE(entry + 10) === 8 ? inflateRawSync(data) : data).toString('utf8'); + } + entry += 46 + nameLength + zip.readUInt16LE(entry + 30) + zip.readUInt16LE(entry + 32); + } + throw new Error(`${name} is not in the archive`); +} + +async function docxText(result: typeof NEW_CASE): Promise { + let blob: Blob | undefined; + vi.spyOn(URL, 'createObjectURL').mockImplementation((value) => { blob = value as Blob; return 'blob:case'; }); + vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined); + expect(await exportCaseToDocx(result)).toBe(true); + const bytes = await new Promise((resolve) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as ArrayBuffer); + reader.readAsArrayBuffer(blob!); + }); + const xml = zipEntry(Buffer.from(bytes), 'word/document.xml'); + return xml.replace(/<\/w:p>/g, '\n').replace(/<[^>]+>/g, ''); +} + +async function pdfText(result: typeof NEW_CASE): Promise { + drawn.length = 0; + expect(await exportCaseToPdf(result)).toBe(true); + return drawn.join('\n'); +} + +beforeEach(() => { + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => undefined); +}); +afterEach(() => vi.restoreAllMocks()); + +describe.each([ + ['DOCX', docxText], + ['PDF', pdfText], + ['clipboard', async (result: typeof NEW_CASE) => caseText(result)], +])('%s case export', (_name, exportText) => { + it('writes a new case as opening, arguments, "If they say…", closing line and sources', async () => { + const text = await exportText(NEW_CASE); + for (const expected of [ + NEW_CASE.opening!.hook, + NEW_CASE.caseStructure.thesis, + 'One size fits all', + 'A ban treats a 7-year-old and a 17-year-old the same.', + 'Older pupils lose a learning tool they use responsibly.', + 'AI makes kids cheat.', + 'OECD, 2024', + NEW_CASE.closingLine!, + 'UNICEF (2025) – Age limits alone do not keep children safe – https://www.unicef.org/report', + ]) expect(text).toContain(expected); + expect(text).not.toMatch(/framework|burden of proof/i); + expect(text).not.toContain('javascript:'); + }); + + it('still writes every section of a case saved before the restructure', async () => { + const text = await exportText(OLD_CASE); + for (const expected of [ + 'Imagine a city where the bus is free. That city is closer than you think.', + 'Free transit is a net public good.', + 'Net societal benefit.', + 'We must show a net gain in practice.', + 'Fares block the riders who need transit most.', + 'Fare collection has heavy overhead.', + 'Net cost is far below headline farebox revenue.', + ]) expect(text).toContain(expected); + }); + + it('writes a Conclusor case\'s reasons why we win when there are no arguments', async () => { + const text = await exportText({ ...NEW_CASE, contentions: [], counters: [], whyWeWin: ['They never answered our point about supervision.'] }); + expect(text).toContain('They never answered our point about supervision.'); + expect(text).not.toContain('One size fits all'); + }); +}); diff --git a/src/test/lib/case-view.test.ts b/src/test/lib/case-view.test.ts new file mode 100644 index 000000000..420093882 --- /dev/null +++ b/src/test/lib/case-view.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest'; +import { GenerateOutputSchema } from '@/lib/ai/schemas/generate'; +import { mergeCaseSources, readCase } from '@debatelab/core/ai/case-view'; +import { prefillFromGenerate } from '@/lib/crossfire/prefill'; +import { NEW_CASE, OLD_CASE } from '../helpers/cases'; + +describe('generated case schema', () => { + it('accepts a new case without any of the old sections', () => { + expect(GenerateOutputSchema.safeParse(NEW_CASE).success).toBe(true); + }); + + it('still accepts a case saved before the restructure, keeping its old sections', () => { + const parsed = GenerateOutputSchema.parse(OLD_CASE); + expect(parsed.caseStructure.burdenOfProof).toBe('We must show a net gain in practice.'); + expect(parsed.predictedOpponentCase?.keyClashes).toEqual(['Who bears the cost.']); + expect(parsed.counters[0].rebuttal).toBe('Net cost is far below headline farebox revenue.'); + }); + + it('keeps the new sections instead of stripping them as unknown keys', () => { + const parsed = GenerateOutputSchema.parse({ ...NEW_CASE, whyWeWin: ['They never answered our point.'] }); + expect(parsed.opening?.hook).toBe(NEW_CASE.opening?.hook); + expect(parsed.contentions[0].title).toBe('One size fits all'); + expect(parsed.counters[0].source).toBe('OECD, 2024'); + expect(parsed.whyWeWin).toEqual(['They never answered our point.']); + expect(parsed.closingLine).toBe(NEW_CASE.closingLine); + expect(parsed.sources).toHaveLength(2); + }); +}); + +describe('readCase', () => { + it('reads a new case into its sections and never links a non-web URL', () => { + const view = readCase(NEW_CASE); + expect(view.opening).toEqual({ hook: NEW_CASE.opening?.hook, definition: undefined, roadmap: NEW_CASE.caseStructure.thesis }); + expect(view.arguments[0]).toMatchObject({ title: 'One size fits all', point: 'A total ban ignores that children grow up.' }); + expect(view.ifTheySay).toEqual([{ theySay: 'AI makes kids cheat.', weAnswer: NEW_CASE.counters[0].counterpoint, inRound: undefined, source: 'OECD, 2024' }]); + expect(view.sources.map((source) => source.url)).toEqual(['https://www.unicef.org/report', undefined]); + expect(view.legacy).toEqual({ framework: undefined, burdenOfProof: undefined, definitions: [], likelyArguments: [], weaknesses: [], clashes: [] }); + }); + + it('reads an old case: exordio becomes the hook and the old sections come through as legacy', () => { + const view = readCase(OLD_CASE); + expect(view.opening.hook).toBe('Imagine a city where the bus is free. That city is closer than you think.'); + expect(view.opening.roadmap).toBe('Free transit is a net public good.'); + expect(view.ifTheySay[0]).toMatchObject({ theySay: 'Funding gap', weAnswer: 'Fare collection has heavy overhead.', inRound: 'Net cost is far below headline farebox revenue.' }); + expect(view.legacy).toEqual({ + framework: 'Net societal benefit.', + burdenOfProof: 'We must show a net gain in practice.', + definitions: ['Public transport: publicly funded scheduled transit.'], + // "Funding gap" is already answered in the list, so only the unanswered one stays. + likelyArguments: ['Overcrowding'], + weaknesses: ['Assumes fares are the only funding model.'], + clashes: ['Who bears the cost.'], + }); + }); + + it('survives a stored case with missing or malformed sections', () => { + const broken = { caseStructure: { thesis: '' }, contentions: [null], counters: [{}], sources: [{}], metadata: {} } as unknown as typeof NEW_CASE; + const view = readCase(broken); + expect(view.arguments).toHaveLength(1); + expect(view.ifTheySay).toEqual([]); + expect(view.sources).toEqual([]); + expect(view.opening.roadmap).toBeUndefined(); + }); +}); + +describe('mergeCaseSources', () => { + it('keeps both lists after a section regenerates, each source once', () => { + const merged = mergeCaseSources(NEW_CASE.sources, [ + { org: 'UNICEF', year: '2025', claim: 'Same report, new wording', url: 'https://www.unicef.org/report' }, + { org: 'OECD', year: '2024', claim: 'Cheating predates AI' }, + ]); + expect(merged?.map((source) => source.org)).toEqual(['UNICEF', 'OECD', 'Old Wiki']); + expect(mergeCaseSources(undefined, [])).toBeUndefined(); + }); +}); + +describe('Crossfire prefill from a case', () => { + it('leaves out empty old sections and includes a Conclusor\'s reasons', () => { + const fresh = prefillFromGenerate('Motion', { ...NEW_CASE, contentions: [], whyWeWin: ['They never answered our point.'] }); + expect(fresh.argument).not.toContain('Framework'); + expect(fresh.argument).not.toContain('Burden of proof'); + expect(fresh.argument).toContain('Why we win 1: They never answered our point.'); + + const old = prefillFromGenerate('Motion', OLD_CASE); + expect(old.argument).toContain('Burden of proof: We must show a net gain in practice.'); + }); +}); diff --git a/src/test/lib/flowchart.test.ts b/src/test/lib/flowchart.test.ts index 21b4b0ad5..b9af0c20e 100644 --- a/src/test/lib/flowchart.test.ts +++ b/src/test/lib/flowchart.test.ts @@ -1,7 +1,7 @@ import { beforeAll, describe, expect, it } from 'vitest'; import i18n from '@/i18n'; import { routeLoaders } from '@/lib/route-modules'; -import { edgeLabel, editNodeData, flowchartFromCase, nodeDescription, nodeLabel, starterFlowchart, studentNodeData, type FlowNodeData } from '@/lib/flowchart'; +import { caseHasFlowchart, edgeLabel, editNodeData, flowchartFromCase, nodeDescription, nodeLabel, starterFlowchart, studentNodeData, type FlowNodeData } from '@/lib/flowchart'; import type { GenerateOutput } from '@/lib/ai/schemas/generate'; const en = i18n.getFixedT('en'); @@ -106,4 +106,27 @@ describe('flowchartFromCase', () => { expect(Math.max(...xs) - Math.min(...xs)).toBeLessThanOrEqual(900); expect(new Set(nodes.map((node) => `${node.position.x},${node.position.y}`)).size).toBe(nodes.length); }); + + it('builds a new case\'s attacks from "If they say…", and a Conclusor case from its reasons why we win', () => { + const fresh = { + ...result, + predictedOpponentCase: undefined, + contentions: [{ ...result.contentions[0], title: 'Cleaner air' }], + counters: [{ targetArgument: 'Deliveries get harder', counterpoint: 'Night delivery windows' }], + } as GenerateOutput; + const built = flowchartFromCase(fresh, en); + expect(built.nodes.map((node) => [node.id, node.data.description])).toEqual([ + ['thesis', 'Cities should ban cars downtown'], + ['c-0', 'Cleaner air\nAir quality improves\nNO2 fell 30% in Oslo'], + ['a-0', 'Deliveries get harder'], + ['r-0', 'Night delivery windows'], + ]); + expect(built.edges.map((edge) => `${edge.source}>${edge.target}`)).toEqual(['thesis>c-0', 'c-0>a-0', 'a-0>r-0']); + + const conc = { ...fresh, contentions: [], counters: [], whyWeWin: ['They never answered air quality', 'Shops did fine'] } as GenerateOutput; + expect(caseHasFlowchart(conc)).toBe(true); + expect(flowchartFromCase(conc, en).nodes.map((node) => node.data.description)).toEqual([ + 'Cities should ban cars downtown', 'They never answered air quality', 'Shops did fine', + ]); + }); }); diff --git a/supabase/functions/_shared/control-enforcement.test.ts b/supabase/functions/_shared/control-enforcement.test.ts index 42cffb596..c8c982c44 100644 --- a/supabase/functions/_shared/control-enforcement.test.ts +++ b/supabase/functions/_shared/control-enforcement.test.ts @@ -3,6 +3,7 @@ import { enforceAttackControls, enforceBrainstormControls, enforceGenerateControls, + enforceGenerateSources, isAskStreamingModeAllowed, validateControlInput, } from "./control-enforcement.ts"; @@ -222,6 +223,51 @@ Deno.test("generate controls fail closed when the model returns fewer contention } }); +Deno.test("a Conclusor case without at least two reasons we win is rejected, not padded", () => { + for (const whyWeWin of [undefined, [], ["only one"], ["", " ", "one"], "not a list"]) { + const output: Record = { contentions: [{}, {}, {}], whyWeWin, metadata: {} }; + let error: unknown; + try { + enforceGenerateControls(output, "pro", "custom", "standard", "conc"); + } catch (caught) { + error = caught; + } + if (!(error instanceof Error) || !error.message.includes("Conclusor")) { + throw new Error(`conc output with whyWeWin=${JSON.stringify(whyWeWin)} must be rejected`); + } + } +}); + +Deno.test("case sources keep only links the research or Library supplied", () => { + const output: Record = { + sources: [ + { org: "UNICEF", year: 2025, claim: "Age limits alone do not keep children safe", url: "https://www.unicef.org/report#section" }, + { org: "Made Up Institute", year: "2024", claim: "Invented figure", url: "https://made-up.example/study" }, + { org: "Library page", claim: "From the student's sources", url: "https://library.example/page/" }, + { org: "Script", claim: "Not a web link", url: "javascript:alert(1)" }, + { org: "", claim: "" }, + "not a source", + { org: "Educo", claim: "1 in 5 pupils are bullied" }, + ], + }; + enforceGenerateSources(output, ["https://www.unicef.org/report", "https://library.example/page"]); + const sources = output.sources as Array>; + const expected = [ + { org: "UNICEF", claim: "Age limits alone do not keep children safe", year: "2025", url: "https://www.unicef.org/report#section" }, + { org: "Made Up Institute", claim: "Invented figure", year: "2024" }, + { org: "Library page", claim: "From the student's sources", url: "https://library.example/page/" }, + { org: "Script", claim: "Not a web link" }, + { org: "Educo", claim: "1 in 5 pupils are bullied" }, + ]; + if (JSON.stringify(sources) !== JSON.stringify(expected)) { + throw new Error(`unexpected sources: ${JSON.stringify(sources)}`); + } + + const malformed: Record = { sources: "UNICEF 2025" }; + enforceGenerateSources(malformed, []); + if ("sources" in malformed) throw new Error("a non-list sources field must be removed"); +}); + Deno.test("generate controls fail closed for missing or invalid exact-length fields", () => { const cases: Record[] = [ { contentions: [{}] }, diff --git a/supabase/functions/_shared/prompts/prompt-examples.test.ts b/supabase/functions/_shared/prompts/prompt-examples.test.ts index b41e4ac46..19cf5a90d 100644 --- a/supabase/functions/_shared/prompts/prompt-examples.test.ts +++ b/supabase/functions/_shared/prompts/prompt-examples.test.ts @@ -10,7 +10,7 @@ function assert(condition: unknown, message: string): asserts condition { // The feature-specific quality rules each prompt must carry. const QUALITY_RULES: Record = { - generate: ["SPEECH-READY CONTENTIONS", "weigh it", "TEAM CONSISTENCY"], + generate: ["SPEECH-READY ARGUMENTS", "Do not use debate theory jargon", "never invent or guess a link", "TEAM CONSISTENCY"], attack: ["TARGET, FLAW, RESPONSE", "exact words from CASE TEXT", "word for word"], brainstorm: ["GENUINELY DIFFERENT DIRECTIONS", "same response are one idea"], factoids: ["CUTTABLE AND HONEST", "limitations: never empty", "Never round, combine, or extrapolate"], diff --git a/supabase/functions/_shared/prompts/prompt-examples.ts b/supabase/functions/_shared/prompts/prompt-examples.ts index 7cd94916c..d11520fad 100644 --- a/supabase/functions/_shared/prompts/prompt-examples.ts +++ b/supabase/functions/_shared/prompts/prompt-examples.ts @@ -36,7 +36,7 @@ function debate(side: "pro" | "con" | "both", outputs: DebatePromptContext["outp }; } -const PRO_CASE_DIGEST = "Thesis: Primary homework costs family time and sleep for almost no learning gain.\nContention 1: Homework adds little for young pupils\n Warrant: Studies find small or no gains before age 11.\n Impact: Pupils lose rest and play for no benefit."; +const PRO_CASE_DIGEST = "Thesis: Primary homework costs family time and sleep for almost no learning gain.\nArgument 1: Homework adds little for young pupils\n Explanation: Studies find small or no gains before age 11.\n Why it matters: Pupils lose rest and play for no benefit."; const RESEARCH = `=== WEB RESEARCH RESULTS === @@ -67,7 +67,7 @@ export const PROMPT_EXAMPLES: PromptExample[] = [ feature: "generate", side: "con", input: { motion: MOTION, side: "con", format: "custom", role: "conc", length: "brief", audience: "beginner", tone: "formal", language: "es" }, - debateContext: debate("con", [{ kind: "case", side: "con", title: "Our R1 case", digest: "Thesis: Short homework builds reading habits a ban would break.\nContention 1: Reading at home improves literacy\n Impact: Weak readers fall further behind." }]), + debateContext: debate("con", [{ kind: "case", side: "con", title: "Our R1 case", digest: "Thesis: Short homework builds reading habits a ban would break.\nArgument 1: Reading at home improves literacy\n Why it matters: Weak readers fall further behind." }]), searchContext: RESEARCH, }, { diff --git a/supabase/functions/ai/control-matrix.test.ts b/supabase/functions/ai/control-matrix.test.ts index db941a48e..7b6e69e72 100644 --- a/supabase/functions/ai/control-matrix.test.ts +++ b/supabase/functions/ai/control-matrix.test.ts @@ -27,12 +27,24 @@ Deno.test("generate length and role controls preserve the output contract", () = for (const role of ["intro", "r1", "r2", "conc"] as const) { const output: Record = { exordio: { hook: "hook", transition: "transition" }, + opening: { hook: "hook", definition: "definition" }, contentions: [{}, {}, {}, {}], + counters: [{}], + whyWeWin: ["a", "b", "c", "d"], metadata: {}, }; enforceGenerateControls(output, "pro", "custom", length, role); - assert((output.contentions as unknown[]).length === targets[length], `generate.${length} output count`); + if (role === "conc") { + // The Conclusor brings 2-3 reasons we win and no new arguments. + assert((output.contentions as unknown[]).length === 0, `generate.${length}.conc has no contentions`); + assert((output.counters as unknown[]).length === 0, `generate.${length}.conc has no counters`); + assert((output.whyWeWin as unknown[]).length === (length === "brief" ? 2 : 3), `generate.${length}.conc whyWeWin count`); + } else { + assert((output.contentions as unknown[]).length === targets[length], `generate.${length} output count`); + assert(!("whyWeWin" in output), `generate.${role} has no whyWeWin`); + } assert((role === "intro") === ("exordio" in output), `generate.${role} exordio shape`); + assert((role === "intro") === ("definition" in (output.opening as object)), `generate.${role} definition only for the Introductor`); } } }); diff --git a/supabase/functions/ai/prompt-builder.test.ts b/supabase/functions/ai/prompt-builder.test.ts index 8babfa8fe..ef96038f3 100644 --- a/supabase/functions/ai/prompt-builder.test.ts +++ b/supabase/functions/ai/prompt-builder.test.ts @@ -86,7 +86,9 @@ Deno.test("generate prompt matrix preserves every school role and length rule", assertIncludes(prompt, `Role: ${role}`); assertIncludes(prompt, `Length: ${length}`); - assertIncludes(prompt, `Length Instruction: Return exactly ${contentionCount} contentions`); + assertIncludes(prompt, role === "conc" + ? `Length Instruction: Return no contentions and no counters. Return exactly ${length === "brief" ? 2 : 3} whyWeWin items` + : `Length Instruction: Return exactly ${contentionCount} contentions`); assertIncludes(prompt, "Language: Hungarian (hu)"); assertIncludes(prompt, "RESEARCH CONTEXT"); } diff --git a/supabase/functions/export-gdoc/generate_test.ts b/supabase/functions/export-gdoc/generate_test.ts new file mode 100644 index 000000000..5d4c6894b --- /dev/null +++ b/supabase/functions/export-gdoc/generate_test.ts @@ -0,0 +1,76 @@ +for (const [key, value] of Object.entries({ + SUPABASE_URL: "https://supabase.test", + SUPABASE_ANON_KEY: "test-anon-key", + SUPABASE_SERVICE_ROLE_KEY: "test-service-role-key", +})) Deno.env.set(key, value); + +const { buildGenerateContent } = await import("./index.ts"); + +/** The document text, in the order the export inserts it. */ +function documentText(data: Parameters[0]): string { + return buildGenerateContent(data).requests + .flatMap((request) => "insertText" in request && request.insertText ? [request.insertText.text] : []) + .join(""); +} + +function assertIncludes(text: string, expected: string) { + if (!text.includes(expected)) throw new Error(`document is missing "${expected}"\n---\n${text}`); +} + +function assertExcludes(text: string, unexpected: RegExp) { + if (unexpected.test(text)) throw new Error(`document must not contain ${unexpected}\n---\n${text}`); +} + +Deno.test("Google Docs export writes a new case's opening, arguments, answers, closing line and sources", () => { + const text = documentText({ + opening: { hook: "Picture a pupil who hides her phone to use AI at night.", definition: "By minors we mean under-16s." }, + caseStructure: { thesis: "Today we will show 1 argument that proves a ban does more harm than good." }, + contentions: [{ title: "One size fits all", claim: "A total ban ignores that children grow up.", reasoning: "A 7-year-old is not a 17-year-old.", evidence: "UNICEF said in 2025 that age limits alone fail.", impact: "Older pupils lose a learning tool." }], + counters: [{ targetArgument: "AI makes kids cheat.", counterpoint: "Pupils cheated before AI.", source: "OECD, 2024" }], + closingLine: "That is why a ban is the wrong answer.", + sources: [ + { org: "UNICEF", year: "2025", claim: "Age limits alone fail", url: "https://www.unicef.org/report" }, + { org: "Old Wiki", claim: "Never linked", url: "javascript:alert(1)" }, + ], + metadata: { side: "con", format: "custom", generatedAt: "2026-10-27T10:00:00.000Z" }, + }); + for (const expected of [ + "Opening\n", "Picture a pupil", "What we mean: By minors we mean under-16s.", + "Argument 1: One size fits all\n", "Point: A total ban ignores", "Explanation: A 7-year-old", "Why it matters: Older pupils", + "If they say…\n", "They say: AI makes kids cheat.\n", "We answer: Pupils cheated before AI.", "Source: OECD, 2024", + "Closing line\n", "That is why a ban is the wrong answer.", + "Sources\n", "• UNICEF (2025) – Age limits alone fail – https://www.unicef.org/report\n", "• Old Wiki – Never linked\n", + ]) assertIncludes(text, expected); + assertExcludes(text, /Framework|Burden of Proof|Predicted Opponent Case|javascript:/); +}); + +Deno.test("Google Docs export still writes a case saved before the restructure", () => { + const text = documentText({ + exordio: { hook: "Imagine a city where the bus is free.", transition: "That city is closer than you think." }, + caseStructure: { thesis: "Free transit is a net public good.", framework: "Net societal benefit.", burdenOfProof: "We must show a net gain.", keyDefinitions: ["Transit: scheduled buses."] }, + contentions: [{ claim: "Fares block riders.", reasoning: "Fares are regressive.", evidence: "Cities report gains.", impact: "More people reach work." }], + predictedOpponentCase: { likelyArguments: ["Funding gap", "Overcrowding"] }, + counters: [{ targetArgument: "Funding gap", counterpoint: "Collection has overhead.", rebuttal: "Net cost is small." }], + metadata: { side: "pro", format: "custom" }, + }); + for (const expected of [ + "Imagine a city where the bus is free. That city is closer than you think.", + "Free transit is a net public good.", "Framework: Net societal benefit.", "Burden of Proof: We must show a net gain.", + "Key Definitions: Transit: scheduled buses.", "Argument 1: Fares block riders.\n", + "We answer: Collection has overhead.", "Rebuttal: Net cost is small.", "Predicted Opponent Case\n", "• Overcrowding\n", + ]) assertIncludes(text, expected); + // An argument the answers already cover is not listed again. + assertExcludes(text, /• Funding gap/); +}); + +Deno.test("Google Docs export gives a Conclusor case its reasons why we win", () => { + const text = documentText({ + caseStructure: { thesis: "Here is why we won." }, + contentions: [], + counters: [], + whyWeWin: ["They never answered our point.", "Only we showed who gets to school."], + metadata: { side: "pro" }, + }); + assertIncludes(text, "Why we win\n1. They never answered our point.\n2. Only we showed who gets to school.\n"); + assertExcludes(text, /Arguments\n|If they say/); +}); From 59d76a314e7e000555fe0b947346f76abf649909 Mon Sep 17 00:00:00 2001 From: Adam Date: Tue, 27 Oct 2026 09:20:18 +0000 Subject: [PATCH 03/12] Type the PDF stand-in in the case export test Co-Authored-By: Claude Opus 5.5 --- src/test/lib/case-exports.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/test/lib/case-exports.test.ts b/src/test/lib/case-exports.test.ts index 1d6cf6e72..319c3b4b4 100644 --- a/src/test/lib/case-exports.test.ts +++ b/src/test/lib/case-exports.test.ts @@ -10,8 +10,8 @@ import { NEW_CASE, OLD_CASE } from '../helpers/cases'; const drawn = vi.hoisted(() => [] as string[]); vi.mock('jspdf', () => ({ - jsPDF: function FakePdf() { - const doc = { + jsPDF: function FakePdf(): object { + const doc: Record = { internal: { pageSize: { width: 210, height: 297 } }, // No font files to load in a test. addFileToVFS: undefined, @@ -19,10 +19,10 @@ vi.mock('jspdf', () => ({ splitTextToSize: (value: string) => [value], getNumberOfPages: () => 1, }; - return new Proxy(doc, { get: (target, key) => (key in target ? Reflect.get(target, key) : () => undefined) }); + return new Proxy(doc, { get: (target, key): unknown => (key in target ? Reflect.get(target, key) : (): undefined => undefined) }); }, })); -vi.mock('jspdf-autotable', () => ({ default: () => undefined })); +vi.mock('jspdf-autotable', () => ({ default: (): void => undefined })); /** The text of one file inside a .docx (a zip), read through its central directory. */ function zipEntry(zip: Buffer, name: string): string { From 9a38dace6b2be3ca3a4842f79b31c141eb39a55d Mon Sep 17 00:00:00 2001 From: Adam Date: Tue, 27 Oct 2026 09:39:56 +0000 Subject: [PATCH 04/12] Accept null for the new optional case fields Co-Authored-By: Claude Opus 5.5 --- packages/core/src/ai/case-view.ts | 2 +- packages/core/src/ai/schemas/generate.ts | 20 +++++++++++--------- src/test/lib/case-view.test.ts | 17 +++++++++++++++++ 3 files changed, 29 insertions(+), 10 deletions(-) diff --git a/packages/core/src/ai/case-view.ts b/packages/core/src/ai/case-view.ts index 678062dda..185a3d4c3 100644 --- a/packages/core/src/ai/case-view.ts +++ b/packages/core/src/ai/case-view.ts @@ -140,7 +140,7 @@ export function sourceLine(source: CaseSource): string { * A regenerated section can cite new sources while the rest of the case still * cites the old ones, so the list keeps both, each source once. */ -export function mergeCaseSources(current: CaseSource[] | undefined, next: CaseSource[] | undefined): CaseSource[] | undefined { +export function mergeCaseSources(current: CaseSource[] | null | undefined, next: CaseSource[] | null | undefined): CaseSource[] | undefined { const seen = new Set(); const merged = [...(next ?? []), ...(current ?? [])].filter((source) => { const key = (source.url || `${source.org}|${source.claim}`).trim().toLowerCase(); diff --git a/packages/core/src/ai/schemas/generate.ts b/packages/core/src/ai/schemas/generate.ts index b76421c37..c23b0c04f 100644 --- a/packages/core/src/ai/schemas/generate.ts +++ b/packages/core/src/ai/schemas/generate.ts @@ -14,11 +14,13 @@ export const GenerateInputSchema = z.object({ export type GenerateInput = z.infer; +// New fields accept null as well as absence: models often send null for a +// field the prompt says to omit. // Each argument reads Title, Point (claim), Explanation (reasoning), Evidence and // Why it matters (impact). The keys stay as they were so saved cases keep loading. export const ContentionSchema = z.object({ id: z.string(), - title: z.string().optional(), + title: z.string().nullish(), claim: z.string(), reasoning: z.string(), evidence: z.string(), @@ -36,7 +38,7 @@ export const ExordioSchema = z.object({ export const OpeningSchema = z.object({ hook: z.string(), /** One line defining a motion term, only when the term is genuinely unclear. */ - definition: z.string().optional(), + definition: z.string().nullish(), }); /** One "If they say…" item: their claim, our answer, and an optional source. */ @@ -45,18 +47,18 @@ export const CounterSchema = z.object({ counterpoint: z.string(), /** Older cases only: a second, in-round phrasing of the answer. */ rebuttal: z.string().optional(), - source: z.string().optional(), + source: z.string().nullish(), }); export const CaseSourceSchema = z.object({ org: z.string(), - year: z.string().optional(), + year: z.string().nullish(), claim: z.string(), - url: z.string().optional(), + url: z.string().nullish(), }); export const GenerateOutputSchema = z.object({ - opening: OpeningSchema.optional(), + opening: OpeningSchema.nullish(), exordio: ExordioSchema.optional(), // thesis is the opening's "we will show N arguments that prove…" line. The // other three are older cases' sections, shown only when they have content. @@ -75,9 +77,9 @@ export const GenerateOutputSchema = z.object({ }).optional(), counters: z.array(CounterSchema), /** Conclusor only: two or three summaries of why our side wins. */ - whyWeWin: z.array(z.string()).optional(), - closingLine: z.string().optional(), - sources: z.array(CaseSourceSchema).optional(), + whyWeWin: z.array(z.string()).nullish(), + closingLine: z.string().nullish(), + sources: z.array(CaseSourceSchema).nullish(), metadata: z.object({ side: z.enum(['pro', 'con']), format: z.string(), diff --git a/src/test/lib/case-view.test.ts b/src/test/lib/case-view.test.ts index 420093882..0b5805272 100644 --- a/src/test/lib/case-view.test.ts +++ b/src/test/lib/case-view.test.ts @@ -16,6 +16,23 @@ describe('generated case schema', () => { expect(parsed.counters[0].rebuttal).toBe('Net cost is far below headline farebox revenue.'); }); + it('accepts null where the model was told to leave a field out', () => { + const parsed = GenerateOutputSchema.parse({ + ...NEW_CASE, + opening: { hook: 'Hook', definition: null }, + contentions: [{ ...NEW_CASE.contentions[0], title: null }], + counters: [{ ...NEW_CASE.counters[0], source: null }], + whyWeWin: null, + closingLine: null, + sources: [{ org: 'UNICEF', year: null, claim: 'Claim', url: null }], + }); + const view = readCase(parsed); + expect(view.opening.definition).toBeUndefined(); + expect(view.arguments[0].title).toBeUndefined(); + expect(view.whyWeWin).toEqual([]); + expect(view.sources).toEqual([{ org: 'UNICEF', claim: 'Claim', year: undefined, url: undefined }]); + }); + it('keeps the new sections instead of stripping them as unknown keys', () => { const parsed = GenerateOutputSchema.parse({ ...NEW_CASE, whyWeWin: ['They never answered our point.'] }); expect(parsed.opening?.hook).toBe(NEW_CASE.opening?.hook); From 969a8feb52f23080b27afa6e56dc945897ad9b8b Mon Sep 17 00:00:00 2001 From: Adam Date: Tue, 27 Oct 2026 09:49:03 +0000 Subject: [PATCH 05/12] Open the folded Researched sources panel before checking its links Co-Authored-By: Claude Opus 5.5 --- .../skills/verify-debatelab/recipes/case-generation.spec.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.claude/skills/verify-debatelab/recipes/case-generation.spec.ts b/.claude/skills/verify-debatelab/recipes/case-generation.spec.ts index 27d4c4982..f6ecd7d41 100644 --- a/.claude/skills/verify-debatelab/recipes/case-generation.spec.ts +++ b/.claude/skills/verify-debatelab/recipes/case-generation.spec.ts @@ -209,8 +209,10 @@ test("gen-research: generation researches the web and cites what it found", asyn } expect(sources.length, "web sources found by research").toBeGreaterThan(0); expect(sources.every((source) => /^https?:\/\//.test(source.url))).toBe(true); - const panel = page.getByText("Researched sources").locator(".."); + // The sources fold under the result header; open them as a student would. + const panel = page.getByRole("group").filter({ hasText: /^Researched sources/ }); await expect(panel).toBeVisible(); + await panel.getByText(/^Researched sources/).click(); await expect(panel.getByRole("link").first()).toHaveAttribute("href", /^https?:\/\//); await evidence.shot("03-researched-sources"); }); From 747729ff987263cf6edf15408add59e662ca6958 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 28 Oct 2026 09:26:30 +0000 Subject: [PATCH 06/12] R2 backs up the team's arguments instead of adding new ones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R2 now follows Adam's own R2 speech (Debate print 1, 2025-03-26): who is speaking, about 2 minutes backing up the team's earlier arguments (reinforce), then the bulk in "If they say…" answers, and a closing line. Length sets R2's answer count (5/6/8) instead of an argument count. The prompt, case labels and exports no longer use framework, burden of proof, weighing, warrant, impact, thesis or counterpoint. Old saved cases keep those sections under plain names. Sparring's school-role guidance and the agentic test context describe the same structure. Co-Authored-By: Claude Opus 5.5 --- .../features/case-generation.md | 6 +- .../recipes/case-generation.spec.ts | 23 ++++++- e2e.config.ts | 2 +- e2e/full-stack/fake-openrouter.mjs | 30 +++++++-- e2e/sparring.spec.ts | 2 +- packages/core/src/ai/case-view.ts | 11 +++- packages/core/src/ai/schemas/generate.ts | 13 ++++ packages/core/src/crossfire/prefill.ts | 7 +- .../features/generate/GenerateResults.tsx | 36 ++++++++--- .../features/sparring/SparringRound.tsx | 8 +-- src/components/features/tools/discuss.ts | 4 +- .../features/tools/live-sections.ts | 7 +- src/components/features/tools/result-text.ts | 5 +- src/lib/ai/features/generate.ts | 10 +-- src/lib/export-utils.ts | 23 ++++--- src/lib/flowchart.ts | 12 ++-- src/lib/pdf-export-utils.ts | 22 +++++-- src/locales/en.json | 64 ++++++------------- src/locales/es.json | 64 ++++++------------- src/locales/hu.json | 64 ++++++------------- src/pages/GeneratePage.tsx | 1 + src/test/helpers/cases.ts | 19 ++++++ src/test/i18n-untranslated.test.ts | 4 +- src/test/lib/case-exports.test.ts | 26 +++++++- src/test/lib/case-view.test.ts | 15 +++-- src/test/lib/flowchart.test.ts | 7 ++ src/test/pages/GeneratePage.test.tsx | 2 +- .../_shared/control-enforcement.test.ts | 38 +++++++++++ .../functions/_shared/control-enforcement.ts | 34 ++++++++++ supabase/functions/_shared/debate-context.ts | 4 +- .../functions/_shared/prompts/generate.ts | 30 ++++++--- .../_shared/prompts/prompt-examples.test.ts | 2 +- .../functions/_shared/prompts/sparring.ts | 8 +-- supabase/functions/ai/control-matrix.test.ts | 12 +++- supabase/functions/ai/prompt-builder.test.ts | 5 +- .../functions/export-gdoc/generate_test.ts | 33 ++++++++-- supabase/functions/export-gdoc/index.ts | 38 +++++++---- 37 files changed, 450 insertions(+), 241 deletions(-) diff --git a/.claude/skills/verify-debatelab/features/case-generation.md b/.claude/skills/verify-debatelab/features/case-generation.md index 1a21bccf1..25e670979 100644 --- a/.claude/skills/verify-debatelab/features/case-generation.md +++ b/.claude/skills/verify-debatelab/features/case-generation.md @@ -1,6 +1,6 @@ # Case generation -A signed-in user enters a motion on Generate and receives a structured debate case: an opening (hook and "we will show N arguments" line), arguments (title, point, explanation, evidence, why it matters), an "If they say…" list, a closing line and a sources list. The role shapes it: the Introductor may add one definition line, R2 has more "If they say…" answers, and the Conclusor has 2-3 "Why we win" points instead of new arguments. Saved cases from before still show their old sections (framework, burden of proof, definitions, predicted arguments) when they have content. The case can be saved to the account and reopened from History, or exported as PDF or DOCX. +A signed-in user enters a motion on Generate and receives a structured debate case: an opening (hook and "we will show N arguments" line), arguments (title, point, explanation, evidence, why it matters), an "If they say…" list, a closing line and a sources list. The role shapes it: the Introductor may add one definition line; R2 brings no new arguments, only `Backing up our case` (the team's earlier arguments) and the bulk in "If they say…"; the Conclusor has 2-3 "Why we win" points instead of new arguments. Saved cases from before still show their old sections under plain names (`How to judge this debate`, `What we have to show`, `They may also say`) when they have content. ## Sub-features @@ -10,7 +10,7 @@ A signed-in user enters a motion on Generate and receives a structured debate ca - `gen-quota` shows the dialog `Daily AI uses used up` when the account has no quota, without calling the provider. - `gen-save` saves a one-off case (the motion dialog answered with `Not now, use it once`) under a title into `cases` with `debate_id: null`. It appears in History under `One-off and unsorted work`, including from a fresh browser context. - `gen-regenerate` regenerates one section. Only that section shows the overlay `Regenerating
…`; the rest of the case and the result toolbar stay on screen, and every regenerate button and the form's submit button are disabled until it finishes. -- `gen-roles` shows the role's shape: on `/app/generate` pick the `Speaker Role` option `Introductor (4 min)` and the Opening card holds a `What we mean:` line; pick `R2 - Rebuttal/Build (6 min)` and `If they say…` lists six answers; pick `Conclusor (3 min)` and `Why we win` replaces `Arguments`. +- `gen-roles` shows the role's shape: on `/app/generate` pick the `Speaker Role` option `Introductor (4 min)` and the Opening card holds a `What we mean:` line; pick `R2 - Rebuttal/Build (6 min)` and there is no `Arguments` section, `Backing up our case` holds the team's earlier arguments, and `If they say…` lists six answers (standard length); pick `Conclusor (3 min)` and `Why we win` replaces `Arguments`. - `gen-export` downloads a `.pdf` (starting with `%PDF`) and a `.docx` (starting with `PK`). - `gen-flowchart` builds a flowchart from the Current Debate's case. `/app/flowchart` opens the template; the button `Build from current case` replaces it with the thesis, one node per argument (title, point and evidence, or a Conclusor's reasons why we win), what the other side will say, and our answers, fitted to the canvas. Node titles the app wrote (`Thesis`, `Argument 1`) follow the UI language. @@ -34,7 +34,7 @@ Preconditions: - **Cut off.** Call `await fake.truncate("generate")`, start the debate, and generate. The first reply stops halfway with `finish_reason: "length"`; the Edge Function retries once with a larger cap, and the case appears with no error. `fake.requests()` has two `generate` entries, the second with a larger `maxTokens`. Fake mode only. - **Cancel.** While a generation runs, the results column shows `Writing your case…` and a `Cancel` button. Cancel stops the request and shows no error. - **Quota.** Update `user_settings` to set `daily_limit_override: 0` through `admin`, start the debate, then generate. The dialog shows `Daily AI uses used up`. `fake.requests()` has no `generate` entry. -- **Regenerate a section.** After generating, click the button named `Regenerate Arguments` (the section titles are `Opening`, `Arguments`, `If they say…` and `Closing line`; a Conclusor case shows `Why we win` instead of `Arguments` and `If they say…`). Hold the request with `page.route("**/functions/v1/ai", ...)` to check the in-flight state: a `status` with `Regenerating Arguments` is visible and `Regenerate Opening` is disabled. The toast `Section regenerated` follows. A full generation shows no toast: the result appears in place (and scrolls into view on phones). +- **Regenerate a section.** After generating, click the button named `Regenerate Arguments` (the section titles are `Opening`, `Arguments`, `If they say…` and `Closing line`; an R2 case shows `Backing up our case` instead of `Arguments`, and a Conclusor case shows `Why we win` instead of `Arguments` and `If they say…`). Hold the request with `page.route("**/functions/v1/ai", ...)` to check the in-flight state: a `status` with `Regenerating Arguments` is visible and `Regenerate Opening` is disabled. The toast `Section regenerated` follows. A full generation shows no toast: the result appears in place (and scrolls into view on phones). - **Flowchart.** After generating in the debate, open `/app/flowchart` and click the button named `Build from current case` (Hungarian: `Létrehozás a jelenlegi esetből`). It appears only when the case belongs to the Current Debate, or to one-off work while no debate is chosen. The contention claims are visible, and an edge is a button named `From Thesis to Argument 1`. - **Export.** Wait for `page.waitForEvent("download")` while clicking the button named `PDF` or `DOCX` (use `exact`). Check the suggested filename's extension and the file's magic bytes. diff --git a/.claude/skills/verify-debatelab/recipes/case-generation.spec.ts b/.claude/skills/verify-debatelab/recipes/case-generation.spec.ts index c3c27b209..badae4106 100644 --- a/.claude/skills/verify-debatelab/recipes/case-generation.spec.ts +++ b/.claude/skills/verify-debatelab/recipes/case-generation.spec.ts @@ -161,7 +161,13 @@ test("gen-roles: the speaker role shapes the case: Introductor, R2 and Conclusor const body = await response; const call = (await providerCalls()).filter((item) => item.feature === "generate").at(-1); expect(call?.user).toContain(`Role: ${role}`); - return body.output as unknown as CaseOutput & { opening?: { definition?: string }; counters: unknown[]; whyWeWin?: string[]; closingLine?: string }; + return body.output as unknown as CaseOutput & { + opening?: { definition?: string }; + counters: unknown[]; + reinforce?: Array<{ argument: string; text: string }>; + whyWeWin?: string[]; + closingLine?: string; + }; }; await pickRole("Introductor (4 min)"); @@ -179,9 +185,22 @@ test("gen-roles: the speaker role shapes the case: Introductor, R2 and Conclusor // Only the Introductor defines a term. expect(r2.opening?.definition).toBeFalsy(); await expect(page.getByText("What we mean:")).toHaveCount(0); + // R2 brings no new arguments: it backs up the team's, then answers theirs. + expect(r2.contentions).toEqual([]); + expect(r2.reinforce?.length ?? 0).toBeGreaterThanOrEqual(1); + await expect(page.getByRole("button", { name: "Regenerate Arguments" })).toHaveCount(0); + await expect(page.getByRole("button", { name: "Regenerate Backing up our case" })).toBeVisible(); + for (const item of r2.reinforce ?? []) await expect(page.getByText(item.text).first()).toBeVisible(); await expect(page.getByRole("button", { name: "Regenerate If they say…" })).toBeVisible(); await expect(page.getByText("They say", { exact: true })).toHaveCount(r2.counters.length); - if (!LIVE) expect(r2.counters).toHaveLength(6); + // Standard length asks for six answers, the bulk of the speech. The Edge + // Function keeps a live reply with at least three and cuts it at six. + if (LIVE) { + expect(r2.counters.length).toBeGreaterThanOrEqual(3); + expect(r2.counters.length).toBeLessThanOrEqual(6); + } else { + expect(r2.counters).toHaveLength(6); + } await evidence.shot("02-r2"); await evidence.aria("02-r2"); diff --git a/e2e.config.ts b/e2e.config.ts index dd7d16189..bdac2c76b 100644 --- a/e2e.config.ts +++ b/e2e.config.ts @@ -45,7 +45,7 @@ const judge = claudeOnly(process.env.E2E_JUDGE_MODEL || E2E_JUDGE_DEFAULT); const context = [ "DebateLab is a debate training app for students. A motion is the debate topic, such as \"This House would ban homework\".", "Each student works in one Current Debate at a time. The header control reads \"Start debate\" until one exists, then names the motion. Tool pages show a strip \"Working in: \".", - "Tools: Generate (a full debate case: thesis, contentions, predicted opponent case, prepared counters), Attack, Brainstorm, Factoids, Ask, Sparring (a practice round against an AI opponent, judged at the end), Crossfire (cross-examination practice), Flowchart, Timer, Library (sources for the Current Debate), and History (saved results).", + "Tools: Generate (a debate case for one speaker: an opening, arguments with point, explanation, evidence and why it matters, an \"If they say…\" list of their claims and our answers, a closing line and sources; R2 backs up the team's arguments instead of adding new ones, and the Conclusor gives reasons why we win), Attack, Brainstorm, Factoids, Ask, Sparring (a practice round against an AI opponent, judged at the end), Crossfire (cross-examination practice), Flowchart, Timer, Library (sources for the Current Debate), and History (saved results).", "A tool given a motion with no Current Debate asks \"Start a debate with this motion?\". A motion that differs from the Current Debate asks \"This is a different motion\".", "The interface language can be English, Spanish or Hungarian. Source titles, citations, user content and model names may stay in their own language. Hungarian abbreviates AI as MI (mesterséges intelligencia), so \"MI-modell\" is Hungarian, and English loanwords such as coaching are Hungarian there too, inflected (\"coachingot\") or as a modifier (\"coaching funkciók\"). The Spanish interface deliberately keeps the loanwords coach, coaching and sparring (\"Pregunta a tu coach\", \"Para Coaches\", \"Rondas de sparring\"); they are Spanish there.", "Long values in selects, chips and narrow fields are shortened with an ellipsis (…) on purpose.", diff --git a/e2e/full-stack/fake-openrouter.mjs b/e2e/full-stack/fake-openrouter.mjs index 67ec3711b..9c2902963 100644 --- a/e2e/full-stack/fake-openrouter.mjs +++ b/e2e/full-stack/fake-openrouter.mjs @@ -56,7 +56,8 @@ const contention = (index) => ({ reasoning: "Fares are a regressive barrier concentrated on low-income riders.", evidence: "UITP reported in 2024 that comparable cities gained riders after removing fares.", needsSource: false, - impact: "More people reach work, school, and healthcare.", + // The prompt asks for whyItMatters; the Edge Function files it under impact. + whyItMatters: "More people reach work, school, and healthcare.", }); const counter = (index) => ({ @@ -67,19 +68,24 @@ const counter = (index) => ({ "Drivers will not leave their cars anyway.", "Only tourists and the well-off benefit.", "Cities cannot afford the extra buses.", + "Fares already have discounts for people who need them.", + "Free transport has been tried and dropped elsewhere.", ][index], counterpoint: `${MARK} answer ${index + 1}: fare collection itself costs a large share of what it brings in, so the real gap is far smaller.`, ...(index % 2 === 0 ? { source: "UITP, 2024" } : {}), }); // The role decides the shape, as the real prompt asks: the Introductor may -// define an unclear term, R2 leans on "If they say…", and the Conclusor -// brings reasons why we win instead of new arguments. +// define an unclear term, R2 backs up the team's arguments and then answers +// theirs, and the Conclusor brings reasons why we win. Neither R2 nor the +// Conclusor brings new arguments. const generate = (user) => { const length = /^Length: (\w+)/m.exec(user)?.[1] ?? "standard"; const role = /^Role: (\w+)/m.exec(user)?.[1] ?? "r1"; const count = { brief: 2, standard: 3, comprehensive: 4 }[length] ?? 3; const conc = role === "conc"; + const r2 = role === "r2"; + const answers = { brief: 5, standard: 6, comprehensive: 8 }[length] ?? 6; return { opening: { hook: `${MARK} opening hook: picture a nurse who skips the night bus because the fare is a tenth of her shift's pay.`, @@ -88,10 +94,18 @@ const generate = (user) => { caseStructure: { thesis: conc ? `${MARK} case thesis: here is why free transit won this debate` - : `${MARK} case thesis: today we will show ${count} arguments that prove free transit is a net public good`, + : r2 + ? `${MARK} case thesis: I'm the R2 of my team, and I'm here to show that free transit is a net public good` + : `${MARK} case thesis: today we will show ${count} arguments that prove free transit is a net public good`, }, - contentions: conc ? [] : Array.from({ length: count }, (_, index) => contention(index)), - counters: conc ? [] : Array.from({ length: role === "r2" ? 6 : 3 }, (_, index) => counter(index)), + contentions: conc || r2 ? [] : Array.from({ length: count }, (_, index) => contention(index)), + counters: conc ? [] : Array.from({ length: r2 ? answers : 3 }, (_, index) => counter(index)), + ...(r2 ? { + reinforce: [ + { argument: "Our first argument: fares shut people out", text: `${MARK} backing up: our first argument was that fares shut out the riders who need transit most. Eurostat found in 2023 that low-income households spend more of their budget on transport. If fares were fair, those numbers would not look like this.` }, + { argument: "Our second argument: more people ride", text: `${MARK} backing up: our second argument was that free fares bring more passengers. UITP reported in 2024 that cities gained riders after removing fares. That is the policy working.` }, + ], + } : {}), ...(conc ? { whyWeWin: [ "Their funding worry never answered our point that fare collection eats most of the fare.", @@ -99,7 +113,9 @@ const generate = (user) => { "Even if some buses get busier, a full bus is the policy working.", ].slice(0, length === "brief" ? 2 : 3), } : {}), - closingLine: "That is why it is clear that free public transport pays for itself in the lives it opens up.", + closingLine: r2 + ? "That is why it is clear that free public transport pays for itself in the lives it opens up. Thank you." + : "That is why it is clear that free public transport pays for itself in the lives it opens up.", sources: [ { org: "UITP", year: "2024", claim: "Ridership rose after fares were removed", url: "https://www.uitp.org/free-fares-2024" }, { org: "Eurostat", year: "2023", claim: "Low-income households spend more of their budget on transport" }, diff --git a/e2e/sparring.spec.ts b/e2e/sparring.spec.ts index e69bcac41..96720893c 100644 --- a/e2e/sparring.spec.ts +++ b/e2e/sparring.spec.ts @@ -123,7 +123,7 @@ test('uses the school 4-6-6-3 roles and coaching in the default round', async ({ const introductor = page.getByRole('textbox', { name: 'Draft for Pro Introductor' }); await expect(introductor).toBeVisible(); - await expect(page.getByText(/Open with an exordio, define the key terms/)).toBeVisible(); + await expect(page.getByText(/Open with a hook, say "we will show N arguments that prove…"/)).toBeVisible(); await introductor.fill('Our framework is civic responsibility. We define service as supervised work that directly benefits the local community.'); await page.getByRole('button', { name: 'Submit speech' }).click(); diff --git a/packages/core/src/ai/case-view.ts b/packages/core/src/ai/case-view.ts index 185a3d4c3..ecefda310 100644 --- a/packages/core/src/ai/case-view.ts +++ b/packages/core/src/ai/case-view.ts @@ -1,10 +1,11 @@ // One reading of a generated case for every place that shows or exports it. // New cases have an opening, arguments, an "If they say…" list, a closing line -// and a sources list. Saved cases from before keep their old keys (exordio, +// and a sources list. R2 brings no new arguments: it backs up the team's +// earlier ones (`reinforce`), and the Conclusor sums up why we win. Saved cases from before keep their old keys (exordio, // framework, burden of proof, definitions, the predicted opponent case and a // separate rebuttal), so those come through as `legacy` and show only when // they have content. -import type { CaseSource, GenerateOutput } from './schemas/generate'; +import type { CaseSource, GenerateOutput, Reinforcement } from './schemas/generate'; export interface CaseArgument { id: string; @@ -33,6 +34,8 @@ export interface CaseView { roadmap?: string; }; arguments: CaseArgument[]; + /** R2: the team's earlier arguments, backed up again. */ + reinforce: Reinforcement[]; ifTheySay: CaseAnswer[]; whyWeWin: string[]; closingLine?: string; @@ -93,6 +96,10 @@ export function readCase(result: GenerateOutput): CaseView { needsSource: Boolean(contention?.needsSource), sourceQuery: text(contention?.sourceQuery), })), + reinforce: (Array.isArray(result.reinforce) ? result.reinforce : []).flatMap((item) => { + const body = text(item?.text); + return body ? [{ argument: text(item?.argument) ?? '', text: body }] : []; + }), ifTheySay: counters.flatMap((counter) => { const theySay = text(counter?.targetArgument); const counterpoint = text(counter?.counterpoint); diff --git a/packages/core/src/ai/schemas/generate.ts b/packages/core/src/ai/schemas/generate.ts index c23b0c04f..10f14c73b 100644 --- a/packages/core/src/ai/schemas/generate.ts +++ b/packages/core/src/ai/schemas/generate.ts @@ -50,6 +50,16 @@ export const CounterSchema = z.object({ source: z.string().nullish(), }); +/** + * R2 only: one block backing up an argument the team already made, in the + * style "Our second argument was about bullying…", instead of new arguments. + */ +export const ReinforceSchema = z.object({ + /** Which earlier argument this backs up, e.g. "Our second argument: bullying". */ + argument: z.string(), + text: z.string(), +}); + export const CaseSourceSchema = z.object({ org: z.string(), year: z.string().nullish(), @@ -76,6 +86,8 @@ export const GenerateOutputSchema = z.object({ keyClashes: z.array(z.string()).optional(), }).optional(), counters: z.array(CounterSchema), + /** R2 only: about two minutes backing up the team's arguments. */ + reinforce: z.array(ReinforceSchema).nullish(), /** Conclusor only: two or three summaries of why our side wins. */ whyWeWin: z.array(z.string()).nullish(), closingLine: z.string().nullish(), @@ -105,3 +117,4 @@ export type GenerateOutput = z.infer; export type Contention = z.infer; export type Counter = z.infer; export type CaseSource = z.infer; +export type Reinforcement = z.infer; diff --git a/packages/core/src/crossfire/prefill.ts b/packages/core/src/crossfire/prefill.ts index 8f2e73297..0914ebdce 100644 --- a/packages/core/src/crossfire/prefill.ts +++ b/packages/core/src/crossfire/prefill.ts @@ -31,10 +31,11 @@ export function prefillFromGenerate(resolution: string, result: GenerateOutput): source: 'generate', resolution: resolution.trim().slice(0, 500), argument: boundedArgument([ - line('Thesis', view.opening.roadmap), - line('Framework', view.legacy.framework), - line('Burden of proof', view.legacy.burdenOfProof), + line('What we will show', view.opening.roadmap), + line('How to judge this debate', view.legacy.framework), + line('What we have to show', view.legacy.burdenOfProof), ...argumentText, + ...view.reinforce.map((item, index) => `Backing up ${item.argument || `argument ${index + 1}`}: ${item.text}`), ...view.whyWeWin.map((reason, index) => `Why we win ${index + 1}: ${reason}`), ]), }); diff --git a/src/components/features/generate/GenerateResults.tsx b/src/components/features/generate/GenerateResults.tsx index 4da36332f..def1dd047 100644 --- a/src/components/features/generate/GenerateResults.tsx +++ b/src/components/features/generate/GenerateResults.tsx @@ -271,20 +271,20 @@ export function GenerateResults({ result, discoveredSources, onRegenerateSection {opening.roadmap}
)} - {/* Older cases kept these as their own sections. */} + {/* Older cases kept these as their own sections; they show under plain names. */} {legacy.framework && (
-

{t('generateResults.framework')}

+

{t('generateResults.legacyJudgeBy')}

{legacy.framework}
)} {legacy.burdenOfProof && (
-

{t('generateResults.burdenOfProof')}

+

{t('generateResults.legacyMustShow')}

{legacy.burdenOfProof}
)} - +
)} @@ -305,6 +305,26 @@ export function GenerateResults({ result, discoveredSources, onRegenerateSection )} + {/* R2: the team's earlier arguments, backed up, instead of new ones. */} + {view.reinforce.length > 0 && ( + } + onRegenerate={regenerate('reinforce')} + {...regenerateState('reinforce')} + > +
+ {view.reinforce.map((item, i) => ( +
+ {item.argument &&

{item.argument}

} + {item.text} +
+ ))} +
+
+ )} + {/* Conclusor: the reasons we win take the place of new arguments. */} {view.whyWeWin.length > 0 && ( - {t('generateResults.rebuttal')} + {t('generateResults.legacySayItLike')} {answer.inRound} )} @@ -361,9 +381,9 @@ export function GenerateResults({ result, discoveredSources, onRegenerateSection )} ))} - - - + + + )} diff --git a/src/components/features/sparring/SparringRound.tsx b/src/components/features/sparring/SparringRound.tsx index b55fa27ec..089477ae1 100644 --- a/src/components/features/sparring/SparringRound.tsx +++ b/src/components/features/sparring/SparringRound.tsx @@ -51,10 +51,10 @@ function wordCount(value: string) { } const SCHOOL_ROLE_GUIDANCE: Partial> = { - introductor: 'Open with an exordio, define the key terms, set the framework and thesis, then give the roadmap. Save the full arguments for R1.', - r1: 'Build the core arguments with claims, warrants, support, and impacts. Use the final part to anticipate or answer the strongest opposition.', - r2: 'Extend R1, answer the opponent directly, identify the central clashes, and compare the impacts. Do not simply repeat the case.', - conclusor: 'Add no new arguments. Crystallize the decisive clashes, weigh the round, and close with the reason your side wins.', + introductor: 'Open with a hook, say "we will show N arguments that prove…", and give each argument in short form. Explain a motion word in one line only if it is unclear. Save the full arguments for R1.', + r1: 'Build the arguments in full: the point, why it is true, evidence with its source and year, and why it matters. Use the last part to answer what they are most likely to say.', + r2: 'Bring no new arguments. Spend about 2 minutes backing up your team\'s arguments, then about 4 minutes answering theirs: "If they say…, we answer…".', + conclusor: 'Add no new arguments. Give two or three reasons your side won, then end with one clear closing line.', }; export function SparringRound({ diff --git a/src/components/features/tools/discuss.ts b/src/components/features/tools/discuss.ts index d8a75c0cd..b2a46c616 100644 --- a/src/components/features/tools/discuss.ts +++ b/src/components/features/tools/discuss.ts @@ -44,7 +44,9 @@ export function generateDiscussPrompt(result: GenerateOutput, t: TFunction) { `**${t('discuss.generate.arguments', 'Arguments')}:**`, ...(result.contentions.length ? result.contentions.map((contention, index) => `${index + 1}. ${contention.title ?? contention.claim}`) - : (result.whyWeWin ?? []).map((reason, index) => `${index + 1}. ${reason}`)), + : result.reinforce?.length + ? result.reinforce.map((item, index) => `${index + 1}. ${item.argument || item.text}`) + : (result.whyWeWin ?? []).map((reason, index) => `${index + 1}. ${reason}`)), '', t('discuss.generate.ask', 'Can we refine this?'), ].join('\n'); diff --git a/src/components/features/tools/live-sections.ts b/src/components/features/tools/live-sections.ts index 74a44bf74..2f8d63aa5 100644 --- a/src/components/features/tools/live-sections.ts +++ b/src/components/features/tools/live-sections.ts @@ -71,9 +71,14 @@ export function generateDraftSections(draft: Draft, t: TFunction): DraftSection[ ...(text(contention.title) ? block(t('generateResults.point'), contention.claim) : []), ...block(t('generateResults.explanation'), contention.reasoning), ...block(t('generateResults.evidence'), contention.evidence), - ...block(t('generateResults.whyItMatters'), contention.impact), + // The model writes whyItMatters; the server files it under impact. + ...block(t('generateResults.whyItMatters'), contention.whyItMatters ?? contention.impact), ]; })), + ...section('reinforce', t('generateResults.reinforce'), list(draft.reinforce).flatMap((value) => { + const item = record(value); + return [...heading(item.argument), ...block(undefined, item.text)]; + })), ...section('whyWeWin', t('generateResults.whyWeWin'), itemsBlock(undefined, draft.whyWeWin)), ...section('counters', t('generateResults.ifTheySay'), list(draft.counters).flatMap((value) => { const counter = record(value); diff --git a/src/components/features/tools/result-text.ts b/src/components/features/tools/result-text.ts index ce6041d7a..f6907cf9c 100644 --- a/src/components/features/tools/result-text.ts +++ b/src/components/features/tools/result-text.ts @@ -18,8 +18,8 @@ export function caseText(result: GenerateOutput): string { view.opening.hook, view.opening.definition && `What we mean: ${view.opening.definition}`, view.opening.roadmap, - view.legacy.framework && `Framework: ${view.legacy.framework}`, - view.legacy.burdenOfProof && `Burden of proof: ${view.legacy.burdenOfProof}`, + view.legacy.framework && `How to judge this debate: ${view.legacy.framework}`, + view.legacy.burdenOfProof && `What we have to show: ${view.legacy.burdenOfProof}`, ].filter((line): line is string => Boolean(line)).map((line) => ` ${line}`)); view.arguments.forEach((argument, i) => { block(`ARGUMENT ${i + 1}: ${argument.title ?? argument.point}`, [ @@ -29,6 +29,7 @@ export function caseText(result: GenerateOutput): string { argument.whyItMatters && ` Why it matters: ${argument.whyItMatters}`, ].filter((line): line is string => Boolean(line))); }); + view.reinforce.forEach((item, i) => block(`BACKING UP ${item.argument || `ARGUMENT ${i + 1}`}:`, [` ${item.text}`])); block('WHY WE WIN:', view.whyWeWin.map((reason, i) => ` ${i + 1}. ${reason}`)); block('IF THEY SAY...', view.ifTheySay.map((answer) => ` "${answer.theySay}" -> ${answer.weAnswer}${answer.inRound ? ` ${answer.inRound}` : ''}${answer.source ? ` (${answer.source})` : ''}`)); diff --git a/src/lib/ai/features/generate.ts b/src/lib/ai/features/generate.ts index 67e95acb8..ed992283a 100644 --- a/src/lib/ai/features/generate.ts +++ b/src/lib/ai/features/generate.ts @@ -119,10 +119,12 @@ export async function generateCase(input: GenerateInput, config: ProviderConfig, const data = rawData as { output: unknown; discoveredSources?: unknown }; const result = validateGenerateOutput(transformGenerateResponse(data.output as Record)); const expectedCount = expectedContentionCount(input.length); - // A Conclusor's case brings reasons why we win instead of new arguments. - // An older backend still sends it the usual argument count. - const concluded = input.role === 'conc' && Boolean(result.whyWeWin?.length); - if (!concluded && result.contentions.length !== expectedCount) { + // R2 backs up the team's arguments and a Conclusor sums up why we win, + // so neither brings new arguments. An older backend still sends both + // the usual argument count. + const noNewArguments = (input.role === 'r2' && Boolean(result.reinforce?.length)) + || (input.role === 'conc' && Boolean(result.whyWeWin?.length)); + if (!noNewArguments && result.contentions.length !== expectedCount) { throw new AIResponseError('AI response did not contain the expected number of contentions.', { details: { expectedCount, actualCount: result.contentions.length }, }); diff --git a/src/lib/export-utils.ts b/src/lib/export-utils.ts index 26766f93d..df17fac12 100644 --- a/src/lib/export-utils.ts +++ b/src/lib/export-utils.ts @@ -43,9 +43,9 @@ export async function exportCaseToDocx(result: GenerateOutput): Promise const { Document, Packer, Paragraph, TextRun, HeadingLevel, AlignmentType } = await import('docx'); const view = readCase(result); - const thesis = view.opening.roadmap || 'Untitled Thesis'; + const roadmap = view.opening.roadmap || i18n.t('export.untitledCase'); const motion = (result.metadata as Record)?.motion as string | undefined; - const thesisTitle = motion || thesis.split('.')[0] || thesis; + const caseTitle = motion || roadmap.split('.')[0] || roadmap; const sideLabel = result.metadata.side === 'pro' ? i18n.t('export.proposition') : result.metadata.side === 'con' @@ -70,9 +70,9 @@ export async function exportCaseToDocx(result: GenerateOutput): Promise ...(view.opening.hook ? [new Paragraph({ children: [new TextRun({ text: view.opening.hook, italics: true })], spacing: { after: 200 } })] : []), ...(view.opening.definition ? [labelled(i18n.t('export.definition'), view.opening.definition, 200, true)] : []), ...(view.opening.roadmap ? [new Paragraph({ text: view.opening.roadmap, spacing: { after: 200 } })] : []), - ...legacyBlock(i18n.t('export.framework'), view.legacy.framework), - ...legacyBlock(i18n.t('export.burdenOfProof'), view.legacy.burdenOfProof), - ...(view.legacy.definitions.length ? [labelled(i18n.t('export.keyDefinitions'), view.legacy.definitions.join('; '), 200, true)] : []), + ...legacyBlock(i18n.t('export.legacyJudgeBy'), view.legacy.framework), + ...legacyBlock(i18n.t('export.legacyMustShow'), view.legacy.burdenOfProof), + ...(view.legacy.definitions.length ? [labelled(i18n.t('export.legacyDefinitions'), view.legacy.definitions.join('; '), 200, true)] : []), ]; const argumentsSection = view.arguments.flatMap((argument, i) => [ @@ -86,12 +86,18 @@ export async function exportCaseToDocx(result: GenerateOutput): Promise ...(argument.whyItMatters ? [labelled(i18n.t('export.whyItMatters'), argument.whyItMatters, 300)] : []), ]); + // R2: each earlier argument it backs up, then the spoken paragraph. + const reinforceSection = view.reinforce.flatMap((item) => [ + ...(item.argument ? [new Paragraph({ children: [new TextRun({ text: item.argument, bold: true })], spacing: { before: 200, after: 100 } })] : []), + new Paragraph({ text: item.text, spacing: { after: 200 } }), + ]); + const answersSection = view.ifTheySay.flatMap((answer) => [ new Paragraph({ children: [new TextRun({ text: `${i18n.t('export.theySay')}: `, bold: true }), new TextRun(answer.theySay)], }), labelled(i18n.t('export.weAnswer'), answer.weAnswer, answer.inRound || answer.source ? 100 : 200), - ...(answer.inRound ? [labelled(i18n.t('export.rebuttal'), answer.inRound, answer.source ? 100 : 200)] : []), + ...(answer.inRound ? [labelled(i18n.t('export.legacySayItLike'), answer.inRound, answer.source ? 100 : 200)] : []), ...(answer.source ? [labelled(i18n.t('export.source'), answer.source, 200)] : []), ]); @@ -102,7 +108,7 @@ export async function exportCaseToDocx(result: GenerateOutput): Promise children: [ // Title new Paragraph({ - text: i18n.t('export.debateCase') + ": " + thesisTitle, + text: i18n.t('export.debateCase') + ": " + caseTitle, heading: HeadingLevel.HEADING_1, alignment: AlignmentType.CENTER, spacing: { after: 400 }, @@ -123,11 +129,12 @@ export async function exportCaseToDocx(result: GenerateOutput): Promise ...(openingSection.length ? [heading(i18n.t('export.opening')), ...openingSection] : []), ...(argumentsSection.length ? [heading(i18n.t('export.arguments'), 200), ...argumentsSection] : []), + ...(reinforceSection.length ? [heading(i18n.t('export.reinforce'), 200), ...reinforceSection] : []), ...(view.whyWeWin.length ? [heading(i18n.t('export.whyWeWin'), 200), ...bullets(view.whyWeWin)] : []), ...(answersSection.length || view.legacy.likelyArguments.length ? [ heading(i18n.t('export.ifTheySay'), 400), ...answersSection, - ...(view.legacy.likelyArguments.length ? [new Paragraph({ children: [new TextRun({ text: i18n.t('export.predictedOpponent'), bold: true })], spacing: { before: 200, after: 100 } }), ...bullets(view.legacy.likelyArguments)] : []), + ...(view.legacy.likelyArguments.length ? [new Paragraph({ children: [new TextRun({ text: i18n.t('export.legacyAlsoSay'), bold: true })], spacing: { before: 200, after: 100 } }), ...bullets(view.legacy.likelyArguments)] : []), ] : []), ...(view.closingLine ? [heading(i18n.t('export.closingLine'), 400), new Paragraph({ children: [new TextRun({ text: view.closingLine, bold: true })], spacing: { after: 200 } })] : []), ...(view.sources.length ? [heading(i18n.t('export.sources'), 400), ...bullets(view.sources.map(sourceLine))] : []), diff --git a/src/lib/flowchart.ts b/src/lib/flowchart.ts index 9a6cdc599..9eb2f695b 100644 --- a/src/lib/flowchart.ts +++ b/src/lib/flowchart.ts @@ -190,9 +190,9 @@ export function starterFlowchart(t: TFunction): FlowchartData { }; } -/** A generated case has what a flowchart needs: a thesis and an argument, or a Conclusor's reasons why we win. */ +/** A generated case has what a flowchart needs: a thesis and an argument, R2's backed-up arguments, or a Conclusor's reasons why we win. */ export const caseHasFlowchart = (result: GenerateOutput | null | undefined): result is GenerateOutput => - Boolean(result?.caseStructure?.thesis && (result.contentions?.length || result.whyWeWin?.length)); + Boolean(result?.caseStructure?.thesis && (result.contentions?.length || result.reinforce?.length || result.whyWeWin?.length)); const NODE_GAP_X = 300; const ROW_GAP_Y = 150; @@ -236,8 +236,8 @@ function answeredAttack(target: string | undefined, attacks: string[], index: nu /** * Maps a generated case onto the canvas: the thesis, one node per argument with - * its title, point and evidence (a Conclusor's reasons why we win stand in for - * arguments), what the other side will say, and our answers. + * its title, point and evidence (R2's backed-up arguments or a Conclusor's + * reasons why we win stand in for arguments), what the other side will say, and our answers. */ export function flowchartFromCase(result: GenerateOutput, t: TFunction): FlowchartData { const nodes: Node[] = []; @@ -248,7 +248,9 @@ export function flowchartFromCase(result: GenerateOutput, t: TFunction): Flowcha const points = view.arguments.length ? view.arguments.map((argument) => [argument.title, argument.point, argument.evidence].filter(Boolean).join('\n')) - : view.whyWeWin; + : view.reinforce.length + ? view.reinforce.map((item) => [item.argument, item.text].filter(Boolean).join('\n')) + : view.whyWeWin; const contentionTier = placeTier(points.length, 230); points.forEach((description, i) => { const id = `c-${i}`; diff --git a/src/lib/pdf-export-utils.ts b/src/lib/pdf-export-utils.ts index 1e8b8041e..28601cba0 100644 --- a/src/lib/pdf-export-utils.ts +++ b/src/lib/pdf-export-utils.ts @@ -387,17 +387,17 @@ export async function exportCaseToPdf(result: GenerateOutput): Promise addParagraph(view.opening.definition, 2); } addSubsectionLabel(i18n.t('export.roadmap')); - addParagraph(view.opening.roadmap || i18n.t('export.noThesis'), 2); + addParagraph(view.opening.roadmap || i18n.t('export.untitledCase'), 2); if (view.legacy.framework) { - addSubsectionLabel(i18n.t('export.framework')); + addSubsectionLabel(i18n.t('export.legacyJudgeBy')); addParagraph(view.legacy.framework, 2); } if (view.legacy.burdenOfProof) { - addSubsectionLabel(i18n.t('export.burdenOfProof')); + addSubsectionLabel(i18n.t('export.legacyMustShow')); addParagraph(view.legacy.burdenOfProof, 2); } if (view.legacy.definitions.length) { - addSubsectionLabel(i18n.t('export.keyDefinitions')); + addSubsectionLabel(i18n.t('export.legacyDefinitions')); addBulletList(view.legacy.definitions); } addSeparator(COLORS.success); @@ -426,6 +426,16 @@ export async function exportCaseToPdf(result: GenerateOutput): Promise addSeparator(COLORS.warning); } + // R2: the team's earlier arguments, backed up, instead of new ones. + if (view.reinforce.length) { + addSectionTitle(i18n.t('export.reinforce'), COLORS.success); + view.reinforce.forEach((item) => { + if (item.argument) addSubsectionLabel(item.argument); + addParagraph(item.text, 2); + }); + addSeparator(COLORS.warning); + } + // Conclusor: the reasons we win replace new arguments. if (view.whyWeWin.length) { addSectionTitle(i18n.t('export.whyWeWin'), COLORS.success); @@ -439,12 +449,12 @@ export async function exportCaseToPdf(result: GenerateOutput): Promise view.ifTheySay.forEach((answer) => { addSubsectionLabel(`${i18n.t('export.theySay')}: ${answer.theySay}`); if (answer.weAnswer) addParagraph(`${i18n.t('export.weAnswer')}: ${answer.weAnswer}`, 4); - if (answer.inRound) addParagraph(`${i18n.t('export.rebuttal')}: ${answer.inRound}`, 4); + if (answer.inRound) addParagraph(`${i18n.t('export.legacySayItLike')}: ${answer.inRound}`, 4); if (answer.source) addParagraph(`${i18n.t('export.source')}: ${answer.source}`, 4); pdf.y += 3; }); if (view.legacy.likelyArguments.length) { - addSubsectionLabel(i18n.t('export.predictedOpponent')); + addSubsectionLabel(i18n.t('export.legacyAlsoSay')); addBulletList(view.legacy.likelyArguments); } } diff --git a/src/locales/en.json b/src/locales/en.json index c9d163e4c..fb1f63100 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1089,34 +1089,10 @@ "save": "Save", "discuss": "Discuss", "flowcharts": "Flowcharts", - "exordio": "Exordio", - "exordioDesc": "Your compelling opening", - "hook": "Hook", - "transition": "Transition", - "caseStructure": "Case structure", - "caseStructureDesc": "Your argument framework", - "thesis": "Thesis", - "framework": "Framework", - "burdenOfProof": "Burden of proof", - "keyDefinitions": "Key definitions", - "noDefinitions": "No definitions provided", "arguments": "Arguments", - "structuredArguments": "structured arguments", - "predictedOpponent": "Predicted opponent case", - "predictedOpponentDesc": "What they'll likely argue", - "likelyArguments": "Likely arguments", - "strategicWeaknesses": "Strategic weaknesses", - "keyClashes": "Key clashes", - "preparedCounters": "Prepared counters", - "preparedCountersDesc": "Ready-to-use rebuttals", "needsSource": "Needs source", - "reasoning": "Reasoning:", "evidence": "Evidence:", "searchSuggestion": "Search suggestion:", - "impact": "Impact:", - "target": "Target", - "counter": "Counter:", - "rebuttal": "Rebuttal:", "exportedGdocs": "Case exported to Google Docs!", "open": "Open", "exportFailed": "Failed to export to Google Docs", @@ -1146,7 +1122,16 @@ "closingLine": "Closing line", "closingLineDesc": "Your last sentence", "sources": "Sources", - "sourcesDesc": "Everything this case cites" + "sourcesDesc": "Everything this case cites", + "reinforce": "Backing up our case", + "reinforceDesc": "About 2 minutes on the arguments your team already made", + "legacyJudgeBy": "How to judge this debate", + "legacyMustShow": "What we have to show", + "legacyDefinitions": "What we mean by the motion", + "legacyAlsoSay": "They may also say", + "legacyWeakSpots": "Where we're weakest", + "legacyDecidingPoints": "What the debate comes down to", + "legacySayItLike": "Say it like this:" }, "attackForm": { "argument": "Your argument", @@ -1892,8 +1877,6 @@ "unknown": "Unknown", "standard": "Standard", "format": "Format", - "contentionNumber": "Contention #{{number}}", - "noThesis": "No thesis provided.", "assessmentSummary": "Assessment summary", "noSummary": "No summary provided.", "strengthAreas": "Strength areas", @@ -1933,17 +1916,7 @@ "unverified": "Unverified", "pending": "Pending" }, - "reasoning": "Reasoning", "evidence": "Evidence", - "impact": "Impact", - "hook": "Hook", - "transition": "Transition", - "noArguments": "No arguments provided.", - "noOpponentArgs": "No predicted opponent arguments provided.", - "noCounters": "No counters provided.", - "target": "Target", - "counter": "Counter", - "rebuttal": "Rebuttal", "debateCase": "Debate case", "side": "Side", "proposition": "In favor", @@ -1964,15 +1937,7 @@ "howToUse": "How to use", "source": "Source", "noFactoids": "No factoids found.", - "exordio": "Exordio", - "caseStructure": "Case structure", - "thesis": "Thesis", - "framework": "Framework", - "burdenOfProof": "Burden of proof", - "keyDefinitions": "Key definitions", "arguments": "Arguments", - "predictedOpponent": "Predicted opponent case", - "counters": "Prepared counters", "weaknesses": "Weaknesses identified", "fallacies": "Fallacies detected", "crossExQuestions": "Cross-examination questions", @@ -2013,7 +1978,14 @@ "weAnswer": "We answer", "whyWeWin": "Why we win", "closingLine": "Closing line", - "sources": "Sources" + "sources": "Sources", + "reinforce": "Backing up our case", + "legacyJudgeBy": "How to judge this debate", + "legacyMustShow": "What we have to show", + "legacyDefinitions": "What we mean by the motion", + "legacyAlsoSay": "They may also say", + "legacySayItLike": "Say it like this", + "untitledCase": "Untitled case" }, "sparring": { "pro": "Pro", diff --git a/src/locales/es.json b/src/locales/es.json index 3587fee7a..79a67b0cb 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -1089,34 +1089,10 @@ "save": "Guardar", "discuss": "Discutir", "flowcharts": "Diagramas", - "exordio": "Exordio", - "exordioDesc": "Tu apertura convincente", - "hook": "Gancho", - "transition": "Transición", - "caseStructure": "Estructura del caso", - "caseStructureDesc": "Tu marco argumentativo", - "thesis": "Tesis", - "framework": "Marco", - "burdenOfProof": "Carga de la prueba", - "keyDefinitions": "Definiciones clave", - "noDefinitions": "No se proporcionaron definiciones", "arguments": "Argumentos", - "structuredArguments": "argumentos estructurados", - "predictedOpponent": "Caso predicho del oponente", - "predictedOpponentDesc": "Lo que probablemente argumentarán", - "likelyArguments": "Argumentos probables", - "strategicWeaknesses": "Debilidades estratégicas", - "keyClashes": "Choques clave", - "preparedCounters": "Contra-argumentos preparados", - "preparedCountersDesc": "Refutaciones listas para usar", "needsSource": "Necesita fuente", - "reasoning": "Razonamiento:", "evidence": "Evidencia:", "searchSuggestion": "Sugerencia de búsqueda:", - "impact": "Impacto:", - "target": "Objetivo", - "counter": "Contra:", - "rebuttal": "Refutación:", "exportedGdocs": "¡Caso exportado a Google Docs!", "open": "Abrir", "exportFailed": "Error al exportar a Google Docs", @@ -1146,7 +1122,16 @@ "closingLine": "Frase de cierre", "closingLineDesc": "Tu última frase", "sources": "Fuentes", - "sourcesDesc": "Todo lo que cita tu caso" + "sourcesDesc": "Todo lo que cita tu caso", + "reinforce": "Reforzamos nuestro caso", + "reinforceDesc": "Unos 2 minutos sobre los argumentos que tu equipo ya dio", + "legacyJudgeBy": "Cómo juzgar este debate", + "legacyMustShow": "Lo que tenemos que demostrar", + "legacyDefinitions": "Qué entendemos por la moción", + "legacyAlsoSay": "También pueden decir", + "legacyWeakSpots": "Dónde somos más débiles", + "legacyDecidingPoints": "En qué se decide el debate", + "legacySayItLike": "Dilo así:" }, "attackForm": { "argument": "Tu argumento", @@ -1892,8 +1877,6 @@ "unknown": "Desconocido", "standard": "Estándar", "format": "Formato", - "contentionNumber": "Argumento n.º {{number}}", - "noThesis": "No se proporcionó ninguna tesis.", "assessmentSummary": "Resumen de la evaluación", "noSummary": "No se proporcionó ningún resumen.", "strengthAreas": "Puntos fuertes", @@ -1933,17 +1916,7 @@ "unverified": "No verificado", "pending": "Pendiente" }, - "reasoning": "Razonamiento", "evidence": "Evidencia", - "impact": "Impacto", - "hook": "Gancho", - "transition": "Transición", - "noArguments": "No se proporcionaron argumentos.", - "noOpponentArgs": "No se proporcionaron argumentos predichos del oponente.", - "noCounters": "No se proporcionaron contra-argumentos.", - "target": "Objetivo", - "counter": "Contra", - "rebuttal": "Refutación", "debateCase": "Caso de debate", "side": "Lado", "proposition": "A favor", @@ -1964,15 +1937,7 @@ "howToUse": "Cómo usar", "source": "Fuente", "noFactoids": "No se encontraron datos.", - "exordio": "Exordio", - "caseStructure": "Estructura del caso", - "thesis": "Tesis", - "framework": "Marco", - "burdenOfProof": "Carga de la prueba", - "keyDefinitions": "Definiciones clave", "arguments": "Argumentos", - "predictedOpponent": "Caso predicho del oponente", - "counters": "Contra-argumentos preparados", "weaknesses": "Debilidades identificadas", "fallacies": "Falacias detectadas", "crossExQuestions": "Preguntas de interrogatorio cruzado", @@ -2013,7 +1978,14 @@ "weAnswer": "Respondemos", "whyWeWin": "Por qué ganamos", "closingLine": "Frase de cierre", - "sources": "Fuentes" + "sources": "Fuentes", + "reinforce": "Reforzamos nuestro caso", + "legacyJudgeBy": "Cómo juzgar este debate", + "legacyMustShow": "Lo que tenemos que demostrar", + "legacyDefinitions": "Qué entendemos por la moción", + "legacyAlsoSay": "También pueden decir", + "legacySayItLike": "Dilo así", + "untitledCase": "Caso sin título" }, "sparring": { "pro": "A favor", diff --git a/src/locales/hu.json b/src/locales/hu.json index f89fdd962..4cb38210b 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -1089,34 +1089,10 @@ "save": "Mentés", "discuss": "Megvitatás", "flowcharts": "Folyamatábrák", - "exordio": "Exordio", - "exordioDesc": "A meggyőző nyitásod", - "hook": "Felvezetés", - "transition": "Átvezetés", - "caseStructure": "Esetstruktúra", - "caseStructureDesc": "Az érvelési kereted", - "thesis": "Tézis", - "framework": "Keretrendszer", - "burdenOfProof": "Bizonyítási teher", - "keyDefinitions": "Kulcsdefiníciók", - "noDefinitions": "Nincsenek megadott definíciók", "arguments": "Érvek", - "structuredArguments": "strukturált érv", - "predictedOpponent": "Az ellenfél várható esete", - "predictedOpponentDesc": "Amit valószínűleg érvelni fognak", - "likelyArguments": "Valószínű érvek", - "strategicWeaknesses": "Stratégiai gyengeségek", - "keyClashes": "Kulcs ütközések", - "preparedCounters": "Előkészített ellenérvek", - "preparedCountersDesc": "Használatra kész cáfolatok", "needsSource": "Forrás szükséges", - "reasoning": "Indoklás:", "evidence": "Bizonyíték:", "searchSuggestion": "Keresési javaslat:", - "impact": "Hatás:", - "target": "Célpont", - "counter": "Ellenérv:", - "rebuttal": "Cáfolat:", "exportedGdocs": "Eset exportálva Google Docs-ba!", "open": "Megnyitás", "exportFailed": "Nem sikerült exportálni Google Docs-ba", @@ -1146,7 +1122,16 @@ "closingLine": "Zárómondat", "closingLineDesc": "Az utolsó mondatod", "sources": "Források", - "sourcesDesc": "Minden, amire az eseted hivatkozik" + "sourcesDesc": "Minden, amire az eseted hivatkozik", + "reinforce": "Érveink megerősítése", + "reinforceDesc": "Kb. 2 perc a csapatod korábbi érveiről", + "legacyJudgeBy": "Hogyan érdemes megítélni a vitát", + "legacyMustShow": "Amit be kell bizonyítanunk", + "legacyDefinitions": "Mit értünk a témán", + "legacyAlsoSay": "Ezt is mondhatják", + "legacyWeakSpots": "Hol vagyunk a leggyengébbek", + "legacyDecidingPoints": "Min dől el a vita", + "legacySayItLike": "Így mondd:" }, "attackForm": { "argument": "Az érved", @@ -1892,8 +1877,6 @@ "unknown": "Ismeretlen", "standard": "Általános", "format": "formátum", - "contentionNumber": "{{number}}. érv", - "noThesis": "Nincs megadott tézis.", "assessmentSummary": "Az értékelés összefoglalása", "noSummary": "Nincs megadott összefoglalás.", "strengthAreas": "Erősségek", @@ -1933,17 +1916,7 @@ "unverified": "Nem ellenőrzött", "pending": "Függőben" }, - "reasoning": "Indoklás", "evidence": "Bizonyíték", - "impact": "Hatás", - "hook": "Felvezetés", - "transition": "Átvezetés", - "noArguments": "Nincsenek megadott érvek.", - "noOpponentArgs": "Nincsenek megadott várható ellenfél érvek.", - "noCounters": "Nincsenek megadott ellenérvek.", - "target": "Célpont", - "counter": "Ellenérv", - "rebuttal": "Cáfolat", "debateCase": "Vitaeset", "side": "Oldal", "proposition": "Mellette", @@ -1964,15 +1937,7 @@ "howToUse": "Használat", "source": "Forrás", "noFactoids": "Nem találhatók tények.", - "exordio": "Exordio", - "caseStructure": "Esetstruktúra", - "thesis": "Tézis", - "framework": "Keretrendszer", - "burdenOfProof": "Bizonyítási teher", - "keyDefinitions": "Kulcsdefiníciók", "arguments": "Érvek", - "predictedOpponent": "Az ellenfél várható esete", - "counters": "Előkészített ellenérvek", "weaknesses": "Azonosított gyengeségek", "fallacies": "Észlelt téves következtetések", "crossExQuestions": "Keresztkérdezési kérdések", @@ -2013,7 +1978,14 @@ "weAnswer": "Válaszunk", "whyWeWin": "Miért nyerünk", "closingLine": "Zárómondat", - "sources": "Források" + "sources": "Források", + "reinforce": "Érveink megerősítése", + "legacyJudgeBy": "Hogyan érdemes megítélni a vitát", + "legacyMustShow": "Amit be kell bizonyítanunk", + "legacyDefinitions": "Mit értünk a témán", + "legacyAlsoSay": "Ezt is mondhatják", + "legacySayItLike": "Így mondd", + "untitledCase": "Névtelen eset" }, "sparring": { "pro": "Mellette", diff --git a/src/pages/GeneratePage.tsx b/src/pages/GeneratePage.tsx index 8cb000d83..4fec501e4 100644 --- a/src/pages/GeneratePage.tsx +++ b/src/pages/GeneratePage.tsx @@ -190,6 +190,7 @@ export default function GeneratePage() { ...(section === 'opening' ? { opening: next.opening, exordio: next.exordio, caseStructure: next.caseStructure } : {}), ...(section === 'contentions' ? { contentions: next.contentions } : {}), ...(section === 'counters' ? { counters: next.counters, predictedOpponentCase: next.predictedOpponentCase } : {}), + ...(section === 'reinforce' ? { reinforce: next.reinforce } : {}), ...(section === 'whyWeWin' ? { whyWeWin: next.whyWeWin } : {}), ...(section === 'closing' ? { closingLine: next.closingLine } : {}), sources: mergeCaseSources(base.sources, next.sources), diff --git a/src/test/helpers/cases.ts b/src/test/helpers/cases.ts index b8c6ad47a..86bec9b43 100644 --- a/src/test/helpers/cases.ts +++ b/src/test/helpers/cases.ts @@ -57,3 +57,22 @@ export const OLD_CASE: GenerateOutput = { }], metadata: { side: 'pro', format: 'custom', generatedAt: '2026-01-06T10:00:00.000Z' }, }; + +// An R2 case in the shape of Adam's own R2 speech: who is speaking, the team's +// earlier argument backed up, then "If they say…" answers. No new arguments. +export const R2_CASE: GenerateOutput = { + opening: { hook: 'Schools present themselves as safe, but if that were true, thousands of children would not be suffering in silence every day.' }, + caseStructure: { thesis: "I'm the R2 of my team, and I'm here to show that school safety is an illusion." }, + contentions: [], + reinforce: [{ + argument: 'Our second argument: bullying', + text: 'Our second argument was about bullying. In Spain, 1 in 5 pupils has been bullied (Educo, 2022), and 1 in 3 victims tells nobody (UNESCO, 2021).', + }], + counters: [{ + targetArgument: 'Schools have emergency plans to protect pupils.', + counterpoint: 'Emergency plans do not stop everyday problems like bullying.', + source: 'UNESCO, 2021', + }], + closingLine: 'That is why it is clear that schools are NOT truly safe. Thank you.', + metadata: { side: 'con', format: 'custom', generatedAt: '2026-10-28T10:00:00.000Z' }, +}; diff --git a/src/test/i18n-untranslated.test.ts b/src/test/i18n-untranslated.test.ts index 4d11a051e..b12ab77be 100644 --- a/src/test/i18n-untranslated.test.ts +++ b/src/test/i18n-untranslated.test.ts @@ -46,9 +46,7 @@ const SAME_AS_ENGLISH: Record = { 'hu tools:motions.wordingHouse', 'hu tools:flow.letter', ], - 'Exordio, the rhetoric term, and unit abbreviations': [ - 'es translation:generateResults.exordio', 'hu translation:generateResults.exordio', - 'es translation:export.exordio', 'hu translation:export.exordio', + 'Unit abbreviations': [ 'es translation:generateResults.durationMs', 'hu translation:generateResults.durationMs', 'es translation:crossfire.duration3m', 'es translation:crossfire.duration5m', 'es translation:crossfire.duration8m', ], diff --git a/src/test/lib/case-exports.test.ts b/src/test/lib/case-exports.test.ts index 319c3b4b4..54133fb7a 100644 --- a/src/test/lib/case-exports.test.ts +++ b/src/test/lib/case-exports.test.ts @@ -3,7 +3,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { exportCaseToDocx } from '@/lib/export-utils'; import { exportCaseToPdf } from '@/lib/pdf-export-utils'; import { caseText } from '@/components/features/tools/result-text'; -import { NEW_CASE, OLD_CASE } from '../helpers/cases'; +import { NEW_CASE, OLD_CASE, R2_CASE } from '../helpers/cases'; + +// The labels and placeholders the restructure removed from every export. +const OLD_LABELS = /Thesis|Framework|Burden of Proof|Predicted Opponent Case|Counterpoint|No framework provided|No burden of proof provided/i; // Every line the PDF draws, recorded by a stand-in for jsPDF. Drawing calls the // test does not need (colours, fonts, shapes, save) do nothing. @@ -52,7 +55,8 @@ async function docxText(result: typeof NEW_CASE): Promise { reader.readAsArrayBuffer(blob!); }); const xml = zipEntry(Buffer.from(bytes), 'word/document.xml'); - return xml.replace(/<\/w:p>/g, '\n').replace(/<[^>]+>/g, ''); + const entities: Record = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" }; + return xml.replace(/<\/w:p>/g, '\n').replace(/<[^>]+>/g, '').replace(/&(amp|lt|gt|quot|apos);/g, (_, name: string) => entities[name]); } async function pdfText(result: typeof NEW_CASE): Promise { @@ -84,7 +88,7 @@ describe.each([ NEW_CASE.closingLine!, 'UNICEF (2025) – Age limits alone do not keep children safe – https://www.unicef.org/report', ]) expect(text).toContain(expected); - expect(text).not.toMatch(/framework|burden of proof/i); + expect(text).not.toMatch(OLD_LABELS); expect(text).not.toContain('javascript:'); }); @@ -99,6 +103,22 @@ describe.each([ 'Fare collection has heavy overhead.', 'Net cost is far below headline farebox revenue.', ]) expect(text).toContain(expected); + // The old sections keep their content, under plain names. + expect(text).not.toMatch(OLD_LABELS); + }); + + it('writes an R2 case as backed-up arguments and answers, with no new arguments', async () => { + const text = await exportText(R2_CASE); + for (const expected of [ + R2_CASE.caseStructure.thesis, + 'Our second argument: bullying', + R2_CASE.reinforce![0].text, + 'Schools have emergency plans to protect pupils.', + 'UNESCO, 2021', + R2_CASE.closingLine!, + ]) expect(text).toContain(expected); + expect(text).not.toMatch(/Argument 1|ARGUMENT 1/); + expect(text).not.toMatch(OLD_LABELS); }); it('writes a Conclusor case\'s reasons why we win when there are no arguments', async () => { diff --git a/src/test/lib/case-view.test.ts b/src/test/lib/case-view.test.ts index 0b5805272..61edfee71 100644 --- a/src/test/lib/case-view.test.ts +++ b/src/test/lib/case-view.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import { GenerateOutputSchema } from '@/lib/ai/schemas/generate'; import { mergeCaseSources, readCase } from '@debatelab/core/ai/case-view'; import { prefillFromGenerate } from '@/lib/crossfire/prefill'; -import { NEW_CASE, OLD_CASE } from '../helpers/cases'; +import { NEW_CASE, OLD_CASE, R2_CASE } from '../helpers/cases'; describe('generated case schema', () => { it('accepts a new case without any of the old sections', () => { @@ -70,6 +70,13 @@ describe('readCase', () => { }); }); + it('reads an R2 case as backed-up arguments with no new ones, dropping empty blocks', () => { + const view = readCase({ ...R2_CASE, reinforce: [...R2_CASE.reinforce!, { argument: 'Empty', text: ' ' }] }); + expect(view.arguments).toEqual([]); + expect(view.reinforce).toEqual([{ argument: 'Our second argument: bullying', text: R2_CASE.reinforce![0].text }]); + expect(view.ifTheySay).toHaveLength(1); + }); + it('survives a stored case with missing or malformed sections', () => { const broken = { caseStructure: { thesis: '' }, contentions: [null], counters: [{}], sources: [{}], metadata: {} } as unknown as typeof NEW_CASE; const view = readCase(broken); @@ -94,11 +101,11 @@ describe('mergeCaseSources', () => { describe('Crossfire prefill from a case', () => { it('leaves out empty old sections and includes a Conclusor\'s reasons', () => { const fresh = prefillFromGenerate('Motion', { ...NEW_CASE, contentions: [], whyWeWin: ['They never answered our point.'] }); - expect(fresh.argument).not.toContain('Framework'); - expect(fresh.argument).not.toContain('Burden of proof'); + expect(fresh.argument).not.toContain('How to judge this debate'); + expect(fresh.argument).not.toContain('What we have to show'); expect(fresh.argument).toContain('Why we win 1: They never answered our point.'); const old = prefillFromGenerate('Motion', OLD_CASE); - expect(old.argument).toContain('Burden of proof: We must show a net gain in practice.'); + expect(old.argument).toContain('What we have to show: We must show a net gain in practice.'); }); }); diff --git a/src/test/lib/flowchart.test.ts b/src/test/lib/flowchart.test.ts index b9af0c20e..d9727315c 100644 --- a/src/test/lib/flowchart.test.ts +++ b/src/test/lib/flowchart.test.ts @@ -123,6 +123,13 @@ describe('flowchartFromCase', () => { ]); expect(built.edges.map((edge) => `${edge.source}>${edge.target}`)).toEqual(['thesis>c-0', 'c-0>a-0', 'a-0>r-0']); + // R2 brings no new arguments: its backed-up arguments become the argument nodes. + const r2 = { ...fresh, contentions: [], reinforce: [{ argument: 'Our first argument: cleaner air', text: 'NO2 fell 30% in Oslo.' }] } as GenerateOutput; + expect(caseHasFlowchart(r2)).toBe(true); + expect(flowchartFromCase(r2, en).nodes.map((node) => node.data.description).slice(0, 3)).toEqual([ + 'Cities should ban cars downtown', 'Our first argument: cleaner air\nNO2 fell 30% in Oslo.', 'Deliveries get harder', + ]); + const conc = { ...fresh, contentions: [], counters: [], whyWeWin: ['They never answered air quality', 'Shops did fine'] } as GenerateOutput; expect(caseHasFlowchart(conc)).toBe(true); expect(flowchartFromCase(conc, en).nodes.map((node) => node.data.description)).toEqual([ diff --git a/src/test/pages/GeneratePage.test.tsx b/src/test/pages/GeneratePage.test.tsx index a7ddec106..d2c814563 100644 --- a/src/test/pages/GeneratePage.test.tsx +++ b/src/test/pages/GeneratePage.test.tsx @@ -246,7 +246,7 @@ describe('GeneratePage Integration', () => { state: expect.objectContaining({ source: 'generate', resolution: 'Existing motion', - argument: expect.stringContaining('Thesis: Existing Thesis'), + argument: expect.stringContaining('What we will show: Existing Thesis'), }), }); }); diff --git a/supabase/functions/_shared/control-enforcement.test.ts b/supabase/functions/_shared/control-enforcement.test.ts index c8c982c44..2a966ffd3 100644 --- a/supabase/functions/_shared/control-enforcement.test.ts +++ b/supabase/functions/_shared/control-enforcement.test.ts @@ -238,6 +238,44 @@ Deno.test("a Conclusor case without at least two reasons we win is rejected, not } }); +Deno.test("an R2 case must back up the team's arguments and answer theirs, or it is rejected", () => { + const cases: Array<[string, Record]> = [ + ["no reinforce", { reinforce: [], counters: [{}, {}, {}, {}, {}], contentions: [], metadata: {} }], + ["only blank reinforce", { reinforce: [{ argument: "Our first", text: " " }], counters: [{}, {}, {}, {}, {}], contentions: [], metadata: {} }], + ["too few answers", { reinforce: [{ argument: "Our first", text: "Backed up." }], counters: [{}, {}], contentions: [], metadata: {} }], + ]; + for (const [label, output] of cases) { + let rejected = false; + try { + enforceGenerateControls(output, "con", "custom", "standard", "r2"); + } catch { + rejected = true; + } + if (!rejected) throw new Error(`R2 output with ${label} must be rejected`); + } + + // New arguments from the model are dropped: R2 brings none. + const output: Record = { + reinforce: [{ argument: "Our first", text: "Backed up." }], + counters: [{}, {}, {}, {}, {}], + contentions: [{ claim: "A new point", whyItMatters: "x" }], + metadata: {}, + }; + enforceGenerateControls(output, "con", "custom", "standard", "r2"); + if ((output.contentions as unknown[]).length !== 0) throw new Error("R2 must not keep new arguments"); +}); + +Deno.test("why it matters is filed under the key saved cases already use", () => { + const output: Record = { + contentions: [{ claim: "a", whyItMatters: "People reach work." }, { claim: "b", impact: "Kept.", whyItMatters: "Ignored." }], + metadata: {}, + }; + enforceGenerateControls(output, "pro", "custom", "brief", "r1"); + const [first, second] = output.contentions as Array>; + if (first.impact !== "People reach work." || "whyItMatters" in first) throw new Error("whyItMatters must move to impact"); + if (second.impact !== "Kept." || "whyItMatters" in second) throw new Error("an existing impact must win"); +}); + Deno.test("case sources keep only links the research or Library supplied", () => { const output: Record = { sources: [ diff --git a/supabase/functions/_shared/control-enforcement.ts b/supabase/functions/_shared/control-enforcement.ts index 1aa5f0820..fefc4249e 100644 --- a/supabase/functions/_shared/control-enforcement.ts +++ b/supabase/functions/_shared/control-enforcement.ts @@ -155,6 +155,7 @@ export function enforceGenerateControls(output: unknown, side: string, format: s contentions?: unknown[]; counters?: unknown[]; whyWeWin?: unknown; + reinforce?: unknown; metadata?: { side?: string; format?: string; generatedAt?: string }; }; if (!generateOutput.metadata || typeof generateOutput.metadata !== "object" || Array.isArray(generateOutput.metadata)) { @@ -172,6 +173,39 @@ export function enforceGenerateControls(output: unknown, side: string, format: s delete (opening as { definition?: unknown }).definition; } + // The prompt asks for whyItMatters; saved cases keep it under impact. + if (Array.isArray(generateOutput.contentions)) { + for (const contention of generateOutput.contentions) { + if (!contention || typeof contention !== "object") continue; + const item = contention as { impact?: unknown; whyItMatters?: unknown }; + if (item.impact === undefined && typeof item.whyItMatters === "string") item.impact = item.whyItMatters; + delete item.whyItMatters; + } + } + + // R2 brings no new arguments: it backs up the team's earlier ones, then + // spends most of the speech on "If they say…" answers. + if (role === "r2") { + const reinforce = Array.isArray(generateOutput.reinforce) + ? generateOutput.reinforce.filter((item) => + item && typeof item === "object" && typeof (item as { text?: unknown }).text === "string" && (item as { text: string }).text.trim()) + : []; + const answers = Array.isArray(generateOutput.counters) ? generateOutput.counters : []; + const target = length === "brief" ? 5 : length === "comprehensive" ? 8 : 6; + if (reinforce.length < 1) { + throw new Error("Generate response returned no reinforce items, but R2 must back up the team's arguments."); + } + if (answers.length < 3) { + throw new Error(`Generate response returned ${answers.length} counters, but R2 needs ${target}.`); + } + generateOutput.reinforce = reinforce.slice(0, 3); + generateOutput.counters = answers.slice(0, target); + generateOutput.contentions = []; + delete generateOutput.whyWeWin; + return; + } + delete generateOutput.reinforce; + // The Conclusor brings no new arguments: 2-3 reasons we win replace them. if (role === "conc") { const reasons = Array.isArray(generateOutput.whyWeWin) diff --git a/supabase/functions/_shared/debate-context.ts b/supabase/functions/_shared/debate-context.ts index 9e70af995..030d09424 100644 --- a/supabase/functions/_shared/debate-context.ts +++ b/supabase/functions/_shared/debate-context.ts @@ -179,12 +179,14 @@ function digestCase(data: Record): string { const counters = list(data.counters).map(record).filter(Boolean) as Record[]; return lines([ text(structure.thesis, 300) && `Thesis: ${text(structure.thesis, 300)}`, - text(structure.framework, 240) && `Framework: ${text(structure.framework, 240)}`, + text(structure.framework, 240) && `How to judge this debate: ${text(structure.framework, 240)}`, ...contentions.slice(0, 4).map((item, index) => lines([ `Argument ${index + 1}: ${text(item.title, 80) ? `${text(item.title, 80)}. ` : ""}${text(item.claim, 200)}`, text(item.reasoning, 320) && ` Explanation: ${text(item.reasoning, 320)}`, text(item.impact, 240) && ` Why it matters: ${text(item.impact, 240)}`, ])), + ...list(data.reinforce).map(record).filter(Boolean).slice(0, 3).map((item) => + text(item!.text, 240) && `Backing up ${text(item!.argument, 80) || "an argument"}: ${text(item!.text, 240)}`), ...list(data.whyWeWin).slice(0, 3).map((item) => text(item, 240) && `Why we win: ${text(item, 240)}`), ...counters.slice(0, 3).map((item) => `If they say "${text(item.targetArgument, 120)}", we answer: ${text(item.counterpoint, 200)}`), ]); diff --git a/supabase/functions/_shared/prompts/generate.ts b/supabase/functions/_shared/prompts/generate.ts index b69fe4483..09f1e12b4 100644 --- a/supabase/functions/_shared/prompts/generate.ts +++ b/supabase/functions/_shared/prompts/generate.ts @@ -16,20 +16,25 @@ Write the case the way school debaters actually prepare it, in plain words: 4. CLOSING LINE: one sentence that ties back to the opening, like "That is why it is clear that...". 5. SOURCES: every source the case cites, as organisation, year, the claim it supports, and the link. -Do not use debate theory jargon anywhere in the output. No "framework", "burden of proof", "weighing", "warrant", "impact calculus", "links", "brightlines", "uniqueness", "magnitude", "probability", "timeframe", "turns", "mitigations" or "clash". Say the same thing in everyday words. +Do not use debate theory terms anywhere in the output: no technical labels for parts of a case, no talk of "links", "brightlines", "uniqueness" or "turns", and no scoring of size, likelihood or speed. Write everything in the everyday words a 14-year-old uses in class. === THE FOUR SPEAKERS (4-6-6-3) === This is a 4-speaker team format. The role decides what the speech contains: --- INTRODUCTOR (4 minutes) --- -Opens the team's case. A hook (a vivid scenario, a question, or a fact from the research), then "we will show N arguments that prove..." and the arguments at headline weight: title, point, and one piece of evidence each, kept short. If, and only if, a word in the motion is genuinely unclear (for example "AI tools" or "minors"), add ONE plain line saying how the team understands it in opening.definition. Otherwise leave opening.definition out. Never write a list of definitions. +Opens the team's case. A hook (a vivid scenario, a question, or a fact from the research), then "we will show N arguments that prove..." and the arguments in short form: title, point, and one piece of evidence each. If, and only if, a word in the motion is genuinely unclear (for example "AI tools" or "minors"), add ONE plain line saying how the team understands it in opening.definition. Otherwise leave opening.definition out. Never write a list of definitions. --- R1 - First Speaker (6 minutes) --- Builds the team's case in full. A short opening line, then the arguments in depth: each explanation walks through why it is true, step by step, and each piece of evidence names its source and year. Then the "If they say..." answers to the strongest opposing claims. --- R2 - Second Speaker (6 minutes) --- -About 2 minutes reinforcing the team's arguments, then about 4 minutes of refutation. Keep each argument short: it restates and strengthens a point the team already made, with new evidence where possible. Then the "If they say..." list is the heart of the speech: 5-8 items, each a direct answer the speaker can say out loud, with a source when one supports it. A short opening line can say who is speaking, such as "I'm the R2 of my team, and I'm here to show that...". +R2 brings NO new arguments: leave contentions as an empty array. The speech has four parts, in this order: +1. Opening (a few sentences). A hook that turns the motion around ("Schools present themselves as safe, but if that were true, thousands of children would not be suffering in silence every day."), then who is speaking and what they will prove: "I'm the R2 of my team, and I'm here to show that...". Put the hook in opening.hook and the "I'm the R2..." line in caseStructure.thesis. +2. Backing up our case (about 2 minutes, write it in reinforce). Go back to the arguments the Introductor and R1 already made and make them stronger. Each item names the earlier argument it returns to ("Our second argument was about bullying.") and then, in one spoken paragraph of about 120-150 words: the strongest fact for it with its source and year ("In Spain, 1 in 5 pupils has been bullied (Educo, 2022)"), what that means in plain words, one more fact or study if the research has it, and a sentence that ties it back to the motion ("If schools were really safe, these numbers would not look like this."). Never introduce a point the team has not made. If CURRENT DEBATE CONTEXT includes the team's earlier case, back up those arguments by name; otherwise back up the strongest arguments for our side as "our first argument" and "our second argument". +3. If they say... (about 4 minutes, the bulk of the speech, written in counters). Each item quotes what the other side has said or will say, in their words ("Schools have emergency plans to protect pupils."), then our answer in 2-4 short spoken sentences (about 40-70 words): say why their point does not hold, back it with a fact and its source when the research has one, and end on a plain line or a pointed question ("If that were true, why are there still so many cases?", "Every case matters. Safety is for everyone, not just most pupils."). Answer the point, not the person. +4. Closing line: one sentence that sums up and ends the speech, like "That is why it is clear that schools are NOT truly safe. Thank you." +Keep the sentences short and direct, the way a student speaks in class. --- CONCLUSOR (3 minutes) --- Closes the debate. NO new arguments: leave contentions and counters as empty arrays. Instead write whyWeWin: 2-3 short paragraphs, each summarising one reason our side won the debate, drawing on what the team already argued. Then the closing line. The opening is one sentence that sets up the summary. @@ -72,7 +77,7 @@ DO NOT: "evidence": "A statistic, study, or quote FROM THE PROVIDED RESEARCH, naming the source and year in the sentence, e.g. 'UNICEF said in 2025 that...'.", "needsSource": false, "sourceQuery": "", - "impact": "Why it matters: 1-2 plain sentences on what this means for real people." + "whyItMatters": "1-2 plain sentences on what this means for real people." } ], "counters": [ @@ -82,6 +87,9 @@ DO NOT: "source": "The source behind the answer as 'Organisation, year', when one supports it. Otherwise omit." } ], + "reinforce": [ + { "argument": "R2 only: the team's earlier argument this returns to, e.g. 'Our second argument: bullying'", "text": "R2 only: one spoken paragraph of about 120-150 words backing it up. Omit for other roles." } + ], "whyWeWin": ["Conclusor only: 2-3 short summaries of why our side won. Omit for other roles."], "closingLine": "One sentence that ties back to the opening.", "sources": [ @@ -94,13 +102,13 @@ DO NOT: } } -CONTROL PRECEDENCE: The Length Instruction sets the EXACT number of contentions (or, for the Conclusor, of whyWeWin items). Role guidance shapes their depth and order only. +CONTROL PRECEDENCE: The Length Instruction sets the EXACT number of items it names: contentions, reinforce and counters items for R2, or whyWeWin items for the Conclusor. Role guidance shapes their depth and order only. SPEECH-READY ARGUMENTS: Each contention must be something the requested speaker can deliver in round: - claim: one sentence a judge can write down. - reasoning: spell out every step from how things are now to the point, so a listener can see why it is true. Never state a conclusion as its own reason. - evidence: say how a specific research or Library source proves the point, naming the source and year in the sentence. When no attached source supports it, describe the evidence still needed and set needsSource to true. -- impact: why it matters to real people, compared with what the other side is likely to say, in plain spoken sentences. +- whyItMatters: why it matters to real people, compared with what the other side is likely to say, in plain spoken sentences. SOURCES LIST: List each source the case actually cites once. Copy the link exactly as it appears in the attached research or Library; never invent or guess a link, and leave "url" out when you have none. When no research is attached, the list may be empty. @@ -109,7 +117,7 @@ TEAM CONSISTENCY: If CURRENT DEBATE CONTEXT includes an earlier case or brainsto REQUIREMENTS: 1. Side must be exactly "pro" or "con". 2. Generate contentions appropriate to the role (see above). -3. Except for the Conclusor, include 5-8 counters, one per likely opposing claim. +3. Except for the Conclusor, include 5-8 counters, one per likely opposing claim (R2: the exact number the Length Instruction gives). 4. Use attached WEB RESEARCH when present. Cite only sources that appear in it. 5. Set needsSource to false only when the attached research supports the contention. Set it to true when the contention still needs evidence. 6. Write the ENTIRE output in the language specified by the user. All arguments, evidence, descriptions, and analysis must be in that language. @@ -121,7 +129,13 @@ export function buildGenerateUserPrompt(input: Record, searchCo const audience = asString(input.audience, "intermediate"); const tone = asString(input.tone, "formal"); const role = asString(input.role, "intro"); - const lengthInstruction = role === "conc" + const lengthInstruction = role === "r2" + ? ({ + brief: "Return no contentions. Return exactly 2 reinforce items and exactly 5 counters.", + standard: "Return no contentions. Return exactly 2 reinforce items and exactly 6 counters.", + comprehensive: "Return no contentions. Return exactly 2 reinforce items and exactly 8 counters.", + }[length] || "Return no contentions. Return exactly 2 reinforce items and exactly 6 counters.") + : role === "conc" ? ({ brief: "Return no contentions and no counters. Return exactly 2 whyWeWin items, each two or three sentences.", standard: "Return no contentions and no counters. Return exactly 3 whyWeWin items, each two or three sentences.", diff --git a/supabase/functions/_shared/prompts/prompt-examples.test.ts b/supabase/functions/_shared/prompts/prompt-examples.test.ts index 0350ff35a..b72e694e5 100644 --- a/supabase/functions/_shared/prompts/prompt-examples.test.ts +++ b/supabase/functions/_shared/prompts/prompt-examples.test.ts @@ -10,7 +10,7 @@ function assert(condition: unknown, message: string): asserts condition { // The feature-specific quality rules each prompt must carry. const QUALITY_RULES: Record = { - generate: ["SPEECH-READY ARGUMENTS", "Do not use debate theory jargon", "never invent or guess a link", "TEAM CONSISTENCY"], + generate: ["SPEECH-READY ARGUMENTS", "Do not use debate theory terms", "never invent or guess a link", "TEAM CONSISTENCY"], attack: ["TARGET, FLAW, RESPONSE", "exact words from CASE TEXT", "word for word"], brainstorm: ["GENUINELY DIFFERENT DIRECTIONS", "same response are one idea"], factoids: ["CUTTABLE AND HONEST", "limitations: never empty", "Never round, combine, or extrapolate"], diff --git a/supabase/functions/_shared/prompts/sparring.ts b/supabase/functions/_shared/prompts/sparring.ts index 1a57b1b3a..2b107c4b8 100644 --- a/supabase/functions/_shared/prompts/sparring.ts +++ b/supabase/functions/_shared/prompts/sparring.ts @@ -9,10 +9,10 @@ export const SPARRING_PROMPT = `You are DebateOwl's live debate sparring opponen The user request contains an action, session context, and at most 12 completed speeches. The context includes the motion, the user's side (pro or con), format (standard, ld, or pf), difficulty (easy, medium, or hard), current turn when responding, and output language. Follow the requested language for every prose field. The "standard" format is DebateOwl's school 4-6-6-3 format. Both sides speak in this order and must perform the named job: -- Introductor (4 minutes): open with a concise exordio, define the key terms strategically, establish the framework and thesis, then preview the team's arguments. Set the battlefield. Do not deliver the team's full constructive case. -- R1 (6 minutes): spend roughly four minutes building the team's core constructive arguments with claim, warrant, support, and impact, then use roughly two minutes to anticipate or answer the strongest opposing material already available. -- R2 (6 minutes): extend and deepen R1 rather than repeating it, answer the opponent directly, identify the central clashes, and compare impacts using magnitude, probability, and timeframe in natural speech. -- Conclusor (3 minutes): introduce no new arguments. Crystallize the two or three decisive clashes, weigh the round, explain why the side wins even if part of the opponent's case is true, and close memorably. +- Introductor (4 minutes): open with a hook, then "we will show N arguments that prove...", and give each argument in short form. Explain a word in the motion in one plain line only when it is genuinely unclear. Do not deliver the team's full arguments. +- R1 (6 minutes): spend roughly four minutes building the team's arguments in full (the point, why it is true, evidence with its source and year, and why it matters), then use roughly two minutes to answer the strongest opposing points already available. +- R2 (6 minutes): bring no new arguments. Spend about two minutes backing up the team's earlier arguments by name with stronger evidence, then about four minutes answering the other side point by point ("If they say X, we answer Y"), and end with one closing line. +- Conclusor (3 minutes): introduce no new arguments. Give the two or three reasons the side won, explain why it still wins even if part of the opponent's case is true, and close memorably. For action "respond": generate the AI side's speech for the current turn. Match the current turn's side and role, engage with the strongest relevant user point from the transcript, and keep the speech useful for the stated time limit. When format is "standard", follow the matching school-role duties above exactly. When format is "ld" or "pf", perform the conventional job named by that format's current turn rather than importing the school roles. Difficulty is a real control. Difficulty changes argument pressure, not vocabulary. Easy should use plain words and make one clear argument while leaving an obvious opening, medium should answer the central warrant and press a meaningful clash, and hard should apply stronger strategic pressure without becoming more ornate. Return exactly: { diff --git a/supabase/functions/ai/control-matrix.test.ts b/supabase/functions/ai/control-matrix.test.ts index 7b6e69e72..b3ff8808a 100644 --- a/supabase/functions/ai/control-matrix.test.ts +++ b/supabase/functions/ai/control-matrix.test.ts @@ -29,12 +29,19 @@ Deno.test("generate length and role controls preserve the output contract", () = exordio: { hook: "hook", transition: "transition" }, opening: { hook: "hook", definition: "definition" }, contentions: [{}, {}, {}, {}], - counters: [{}], + counters: Array.from({ length: 9 }, () => ({})), + reinforce: [{ argument: "Our first argument", text: "backed up" }, { argument: "", text: " " }], whyWeWin: ["a", "b", "c", "d"], metadata: {}, }; enforceGenerateControls(output, "pro", "custom", length, role); - if (role === "conc") { + if (role === "r2") { + // R2 brings no new arguments: it backs up the team's and answers theirs. + assert((output.contentions as unknown[]).length === 0, `generate.${length}.r2 has no contentions`); + assert((output.reinforce as unknown[]).length === 1, `generate.${length}.r2 keeps only reinforce items with text`); + assert((output.counters as unknown[]).length === { brief: 5, standard: 6, comprehensive: 8 }[length], `generate.${length}.r2 answer count`); + assert(!("whyWeWin" in output), "generate.r2 has no whyWeWin"); + } else if (role === "conc") { // The Conclusor brings 2-3 reasons we win and no new arguments. assert((output.contentions as unknown[]).length === 0, `generate.${length}.conc has no contentions`); assert((output.counters as unknown[]).length === 0, `generate.${length}.conc has no counters`); @@ -42,6 +49,7 @@ Deno.test("generate length and role controls preserve the output contract", () = } else { assert((output.contentions as unknown[]).length === targets[length], `generate.${length} output count`); assert(!("whyWeWin" in output), `generate.${role} has no whyWeWin`); + assert(!("reinforce" in output), `generate.${role} has no reinforce`); } assert((role === "intro") === ("exordio" in output), `generate.${role} exordio shape`); assert((role === "intro") === ("definition" in (output.opening as object)), `generate.${role} definition only for the Introductor`); diff --git a/supabase/functions/ai/prompt-builder.test.ts b/supabase/functions/ai/prompt-builder.test.ts index ef96038f3..104a9ce64 100644 --- a/supabase/functions/ai/prompt-builder.test.ts +++ b/supabase/functions/ai/prompt-builder.test.ts @@ -88,7 +88,10 @@ Deno.test("generate prompt matrix preserves every school role and length rule", assertIncludes(prompt, `Length: ${length}`); assertIncludes(prompt, role === "conc" ? `Length Instruction: Return no contentions and no counters. Return exactly ${length === "brief" ? 2 : 3} whyWeWin items` - : `Length Instruction: Return exactly ${contentionCount} contentions`); + : role === "r2" + // R2 brings no new arguments, so length sets how many answers it gives. + ? `Length Instruction: Return no contentions. Return exactly 2 reinforce items and exactly ${{ brief: 5, standard: 6, comprehensive: 8 }[length]} counters.` + : `Length Instruction: Return exactly ${contentionCount} contentions`); assertIncludes(prompt, "Language: Hungarian (hu)"); assertIncludes(prompt, "RESEARCH CONTEXT"); } diff --git a/supabase/functions/export-gdoc/generate_test.ts b/supabase/functions/export-gdoc/generate_test.ts index 5d4c6894b..5666004c8 100644 --- a/supabase/functions/export-gdoc/generate_test.ts +++ b/supabase/functions/export-gdoc/generate_test.ts @@ -17,6 +17,9 @@ function assertIncludes(text: string, expected: string) { if (!text.includes(expected)) throw new Error(`document is missing "${expected}"\n---\n${text}`); } +// The labels and placeholders the restructure removed from every export. +const OLD_LABELS = /Thesis|Framework|Burden of Proof|Predicted Opponent Case|Counterpoint|No framework provided|No burden of proof provided/i; + function assertExcludes(text: string, unexpected: RegExp) { if (unexpected.test(text)) throw new Error(`document must not contain ${unexpected}\n---\n${text}`); } @@ -41,7 +44,8 @@ Deno.test("Google Docs export writes a new case's opening, arguments, answers, c "Closing line\n", "That is why a ban is the wrong answer.", "Sources\n", "• UNICEF (2025) – Age limits alone fail – https://www.unicef.org/report\n", "• Old Wiki – Never linked\n", ]) assertIncludes(text, expected); - assertExcludes(text, /Framework|Burden of Proof|Predicted Opponent Case|javascript:/); + assertExcludes(text, OLD_LABELS); + assertExcludes(text, /javascript:/); }); Deno.test("Google Docs export still writes a case saved before the restructure", () => { @@ -55,12 +59,14 @@ Deno.test("Google Docs export still writes a case saved before the restructure", }); for (const expected of [ "Imagine a city where the bus is free. That city is closer than you think.", - "Free transit is a net public good.", "Framework: Net societal benefit.", "Burden of Proof: We must show a net gain.", - "Key Definitions: Transit: scheduled buses.", "Argument 1: Fares block riders.\n", - "We answer: Collection has overhead.", "Rebuttal: Net cost is small.", "Predicted Opponent Case\n", "• Overcrowding\n", + "Free transit is a net public good.", "How to judge this debate: Net societal benefit.", "What we have to show: We must show a net gain.", + "What we mean by the motion: Transit: scheduled buses.", "Argument 1: Fares block riders.\n", + "We answer: Collection has overhead.", "Say it like this: Net cost is small.", "They may also say\n", "• Overcrowding\n", ]) assertIncludes(text, expected); // An argument the answers already cover is not listed again. assertExcludes(text, /• Funding gap/); + // The old sections keep their content but none of the old labels. + assertExcludes(text, OLD_LABELS); }); Deno.test("Google Docs export gives a Conclusor case its reasons why we win", () => { @@ -74,3 +80,22 @@ Deno.test("Google Docs export gives a Conclusor case its reasons why we win", () assertIncludes(text, "Why we win\n1. They never answered our point.\n2. Only we showed who gets to school.\n"); assertExcludes(text, /Arguments\n|If they say/); }); + +Deno.test("Google Docs export gives R2 its backed-up arguments and answers, with no new arguments", () => { + const text = documentText({ + opening: { hook: "Schools look safe, but if that were true, thousands of children would not suffer in silence." }, + caseStructure: { thesis: "I'm the R2 of my team, and I'm here to show that schools are not safe." }, + contentions: [], + reinforce: [ + { argument: "Our second argument: bullying", text: "In Spain, 1 in 5 pupils has been bullied (Educo, 2022)." }, + { argument: "", text: " " }, + ], + counters: [{ targetArgument: "Schools have emergency plans.", counterpoint: "Emergency plans do not stop everyday bullying.", source: "UNESCO, 2021" }], + closingLine: "That is why it is clear that schools are NOT truly safe. Thank you.", + metadata: { side: "con" }, + }); + assertIncludes(text, "Backing up our case\nOur second argument: bullying\nIn Spain, 1 in 5 pupils has been bullied (Educo, 2022).\n"); + assertIncludes(text, "They say: Schools have emergency plans.\n"); + assertExcludes(text, /Arguments\n|Argument 1/); + assertExcludes(text, OLD_LABELS); +}); diff --git a/supabase/functions/export-gdoc/index.ts b/supabase/functions/export-gdoc/index.ts index ea010f2c5..24df74841 100644 --- a/supabase/functions/export-gdoc/index.ts +++ b/supabase/functions/export-gdoc/index.ts @@ -300,6 +300,7 @@ interface GenerateData { contentions?: { title?: string; claim?: string; reasoning?: string; evidence?: string; impact?: string }[]; predictedOpponentCase?: { likelyArguments?: string[] }; counters?: { targetArgument?: string; counterpoint?: string; rebuttal?: string; source?: string }[]; + reinforce?: { argument?: string; text?: string }[]; whyWeWin?: string[]; closingLine?: string; sources?: { org?: string; year?: string; claim?: string; url?: string }[]; @@ -315,21 +316,21 @@ function presentList(value: unknown): string[] { } // Mirrors readCase in packages/core/src/ai/case-view.ts: new cases have an -// opening, arguments, "If they say…", a closing line and sources; older saved -// cases keep exordio, framework, burden of proof, definitions and predicted -// arguments, which appear only when they have content. +// opening, arguments (or R2's backed-up arguments, or the Conclusor's reasons +// why we win), "If they say…", a closing line and sources. Older saved cases +// keep extra sections, which appear under plain names only when they have content. export function buildGenerateContent(data: GenerateData) { const { requests, insertText, applyTitle, applySubtitle, applySectionHeader, applySubHeader, applyLabel, applyBody, applyBullet, applyEmphasis } = createDocumentBuilder(); - const thesis = present(data.caseStructure?.thesis) || 'Untitled Thesis'; + const roadmap = present(data.caseStructure?.thesis) || 'Untitled case'; const motion = (data.metadata as Record)?.motion as string | undefined; - const thesisTitle = motion || (thesis.length > 200 ? thesis.substring(0, 197) + '...' : thesis); + const caseTitle = motion || (roadmap.length > 200 ? roadmap.substring(0, 197) + '...' : roadmap); // Title - Centered, large, bold - const titleRange = insertText(`${thesisTitle}\n`); + const titleRange = insertText(`${caseTitle}\n`); applyTitle(titleRange); // Metadata - Centered subtitle @@ -353,11 +354,11 @@ export function buildGenerateContent(data: GenerateData) { section('Opening'); if (hook) applyBody(insertText(`${hook}\n`)); labelled('What we mean', present(data.opening?.definition)); - applyEmphasis(insertText(`${thesis}\n`)); - labelled('Framework', present(data.caseStructure?.framework)); - labelled('Burden of Proof', present(data.caseStructure?.burdenOfProof)); + applyEmphasis(insertText(`${roadmap}\n`)); + labelled('How to judge this debate', present(data.caseStructure?.framework)); + labelled('What we have to show', present(data.caseStructure?.burdenOfProof)); const definitions = presentList(data.caseStructure?.keyDefinitions); - if (definitions.length) labelled('Key Definitions', definitions.join('; ')); + if (definitions.length) labelled('What we mean by the motion', definitions.join('; ')); // Arguments const contentions = Array.isArray(data.contentions) ? data.contentions : []; @@ -374,6 +375,17 @@ export function buildGenerateContent(data: GenerateData) { }); } + // R2: the team's earlier arguments, backed up, instead of new ones + const reinforce = (Array.isArray(data.reinforce) ? data.reinforce : []).filter((item) => present(item?.text)); + if (reinforce.length) { + section('Backing up our case'); + reinforce.forEach((item) => { + const argument = present(item?.argument); + if (argument) applySubHeader(insertText(`${argument}\n`)); + applyBody(insertText(`${present(item?.text)}\n`)); + }); + } + // Conclusor: why we win const reasons = presentList(data.whyWeWin); if (reasons.length) { @@ -394,11 +406,11 @@ export function buildGenerateContent(data: GenerateData) { const rebuttal = present(c?.rebuttal); applySubHeader(insertText(`They say: ${present(c?.targetArgument) ?? ''}\n`)); labelled('We answer', counterpoint ?? rebuttal); - if (counterpoint && rebuttal && rebuttal !== counterpoint) labelled('Rebuttal', rebuttal); + if (counterpoint && rebuttal && rebuttal !== counterpoint) labelled('Say it like this', rebuttal); labelled('Source', present(c?.source)); }); if (likelyArgs.length) { - applySubHeader(insertText('Predicted Opponent Case\n')); + applySubHeader(insertText('They may also say\n')); bullets(likelyArgs); } } @@ -423,7 +435,7 @@ export function buildGenerateContent(data: GenerateData) { bullets(sources); } - return { requests, title: `Debate Case: ${thesisTitle}` }; + return { requests, title: `Debate Case: ${caseTitle}` }; } // ============================================================ From e964bef06f673707e6d563e7b018722243a7f853 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 28 Oct 2026 09:38:55 +0000 Subject: [PATCH 07/12] Sparring's school-role guidance matches the new case structure in all three languages Co-Authored-By: Claude Opus 5.5 --- e2e/sparring.spec.ts | 2 +- src/locales/en.json | 8 ++++---- src/locales/es.json | 8 ++++---- src/locales/hu.json | 8 ++++---- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/e2e/sparring.spec.ts b/e2e/sparring.spec.ts index 96720893c..6c991ba5f 100644 --- a/e2e/sparring.spec.ts +++ b/e2e/sparring.spec.ts @@ -129,7 +129,7 @@ test('uses the school 4-6-6-3 roles and coaching in the default round', async ({ await expect(page.getByRole('region', { name: 'Transcript' }).getByText('Opponent response 1:')).toBeVisible(); await expect(page.getByRole('textbox', { name: 'Draft for Pro R1' })).toBeVisible(); - await expect(page.getByText(/Build the core arguments with claims, warrants, support, and impacts/)).toBeVisible(); + await expect(page.getByText(/Build the arguments in full: the point, why it is true, evidence with its source and year/)).toBeVisible(); expect(actions).toEqual(['respond']); }); diff --git a/src/locales/en.json b/src/locales/en.json index fb1f63100..a440db58c 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -2032,10 +2032,10 @@ "hard": "Ruthless" }, "roleGuidance": { - "introductor": "Open with an exordio, define the key terms, set the framework and thesis, then give the roadmap. Save the full arguments for R1.", - "r1": "Build the core arguments with claims, warrants, support, and impacts. Use the final part to anticipate or answer the strongest opposition.", - "r2": "Extend R1, answer the opponent directly, identify the central clashes, and compare the impacts. Do not simply repeat the case.", - "conclusor": "Add no new arguments. Crystallize the decisive clashes, weigh the round, and close with the reason your side wins." + "introductor": "Open with a hook, say \"we will show N arguments that prove…\", and give each argument in short form. Explain a motion word in one line only if it is unclear. Save the full arguments for R1.", + "r1": "Build the arguments in full: the point, why it is true, evidence with its source and year, and why it matters. Use the last part to answer what they are most likely to say.", + "r2": "Bring no new arguments. Spend about 2 minutes backing up your team's arguments, then about 4 minutes answering theirs: \"If they say…, we answer…\".", + "conclusor": "Add no new arguments. Give two or three reasons your side won, then end with one clear closing line." }, "category": { "Logic": "Logic", diff --git a/src/locales/es.json b/src/locales/es.json index 79a67b0cb..dc9896c4f 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -2032,10 +2032,10 @@ "hard": "Despiadado" }, "roleGuidance": { - "introductor": "Abre con un exordio, define los términos clave, fija el marco y la tesis, y presenta la hoja de ruta. Reserva los argumentos completos para R1.", - "r1": "Construye los argumentos centrales con afirmaciones, razonamientos, apoyo e impactos. Usa la parte final para anticipar o responder a la oposición más fuerte.", - "r2": "Amplía R1, responde directamente al oponente, identifica los choques centrales y compara los impactos. No te limites a repetir el caso.", - "conclusor": "No añadas argumentos nuevos. Cristaliza los choques decisivos, sopesa la ronda y cierra explicando por qué gana tu lado." + "introductor": "Empieza con un gancho, di «vamos a demostrar N argumentos que prueban que…» y presenta cada argumento en pocas palabras. Explica una palabra de la moción en una línea solo si no está clara. Deja los argumentos completos para el R1.", + "r1": "Desarrolla los argumentos completos: la idea, por qué es cierta, la evidencia con su fuente y año, y por qué importa. Usa la última parte para responder lo que probablemente dirán.", + "r2": "No traigas argumentos nuevos. Dedica unos 2 minutos a reforzar los argumentos de tu equipo y unos 4 minutos a responder los suyos: «Si dicen…, respondemos…».", + "conclusor": "No añadas argumentos nuevos. Da dos o tres razones por las que tu lado ganó y termina con una frase de cierre clara." }, "category": { "Logic": "Lógica", diff --git a/src/locales/hu.json b/src/locales/hu.json index 4cb38210b..c2eb33851 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -2032,10 +2032,10 @@ "hard": "Kíméletlen" }, "roleGuidance": { - "introductor": "Kezdj exordióval, határozd meg a kulcsfogalmakat, állítsd fel a keretet és a tézist, majd ismertesd az érvelés menetét. A teljes érveket hagyd az R1-re.", - "r1": "Építsd fel a fő érveket állítással, indoklással, alátámasztással és hatással. A végén előzd meg vagy válaszold meg a legerősebb ellenérvet.", - "r2": "Mélyítsd el az R1 érveit, válaszolj közvetlenül az ellenfélnek, nevezd meg a fő ütközési pontokat, és mérd össze a hatásokat. Ne csak ismételd az esetet.", - "conclusor": "Ne hozz új érvet. Kristályosítsd ki a döntő ütközési pontokat, mérlegeld a fordulót, és zárd le azzal, miért a te oldalad nyer." + "introductor": "Kezdj egy figyelemfelkeltő nyitással, mondd ki: „N érvvel bizonyítjuk, hogy…”, és röviden mutasd be az érveket. A téma egy szavát csak akkor magyarázd el egy mondatban, ha nem egyértelmű. A teljes érveket hagyd az R1-nek.", + "r1": "Fejtsd ki teljesen az érveket: az állítást, hogy miért igaz, a bizonyítékot forrással és évszámmal, és hogy miért fontos. A végén válaszolj arra, amit a másik csapat a legvalószínűbben mond.", + "r2": "Ne hozz új érvet. Kb. 2 percben erősítsd meg a csapatod érveit, majd kb. 4 percben válaszolj az övéikre: „Ha azt mondják…, mi azt feleljük…”.", + "conclusor": "Ne hozz új érvet. Mondj két-három okot, amiért az oldalad nyert, és zárj egy világos zárómondattal." }, "category": { "Logic": "Logika", From a0c16ae7cc4303479216fd874d738cd521a75fdc Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 28 Oct 2026 09:44:45 +0000 Subject: [PATCH 08/12] Hold R2 to the length of Adam's own R2 speech Co-Authored-By: Claude Opus 5.5 --- supabase/functions/_shared/prompts/generate.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/supabase/functions/_shared/prompts/generate.ts b/supabase/functions/_shared/prompts/generate.ts index 09f1e12b4..9e67c2510 100644 --- a/supabase/functions/_shared/prompts/generate.ts +++ b/supabase/functions/_shared/prompts/generate.ts @@ -31,10 +31,10 @@ Builds the team's case in full. A short opening line, then the arguments in dept --- R2 - Second Speaker (6 minutes) --- R2 brings NO new arguments: leave contentions as an empty array. The speech has four parts, in this order: 1. Opening (a few sentences). A hook that turns the motion around ("Schools present themselves as safe, but if that were true, thousands of children would not be suffering in silence every day."), then who is speaking and what they will prove: "I'm the R2 of my team, and I'm here to show that...". Put the hook in opening.hook and the "I'm the R2..." line in caseStructure.thesis. -2. Backing up our case (about 2 minutes, write it in reinforce). Go back to the arguments the Introductor and R1 already made and make them stronger. Each item names the earlier argument it returns to ("Our second argument was about bullying.") and then, in one spoken paragraph of about 120-150 words: the strongest fact for it with its source and year ("In Spain, 1 in 5 pupils has been bullied (Educo, 2022)"), what that means in plain words, one more fact or study if the research has it, and a sentence that ties it back to the motion ("If schools were really safe, these numbers would not look like this."). Never introduce a point the team has not made. If CURRENT DEBATE CONTEXT includes the team's earlier case, back up those arguments by name; otherwise back up the strongest arguments for our side as "our first argument" and "our second argument". -3. If they say... (about 4 minutes, the bulk of the speech, written in counters). Each item quotes what the other side has said or will say, in their words ("Schools have emergency plans to protect pupils."), then our answer in 2-4 short spoken sentences (about 40-70 words): say why their point does not hold, back it with a fact and its source when the research has one, and end on a plain line or a pointed question ("If that were true, why are there still so many cases?", "Every case matters. Safety is for everyone, not just most pupils."). Answer the point, not the person. +2. Backing up our case (about 2 minutes, write it in reinforce). Go back to the arguments the Introductor and R1 already made and make them stronger. Each item names the earlier argument it returns to ("Our second argument was about bullying.") and then, in one spoken paragraph of about 100-130 words: the strongest fact for it with its source and year ("In Spain, 1 in 5 pupils has been bullied (Educo, 2022)"), what that means in plain words, one more fact or study if the research has it, and a sentence that ties it back to the motion ("If schools were really safe, these numbers would not look like this."). Never introduce a point the team has not made. If CURRENT DEBATE CONTEXT includes the team's earlier case, back up those arguments by name; otherwise back up the strongest arguments for our side as "our first argument" and "our second argument". +3. If they say... (about 4 minutes, the bulk of the speech, written in counters). Each item quotes what the other side has said or will say, in their words ("Schools have emergency plans to protect pupils."), then our answer in 2-3 short spoken sentences, about 35-55 words: first say straight away why their point does not hold ("Emergency plans do not stop everyday problems like bullying."), then back it with one fact and its source when the research has one. End at least half of the answers on a pointed question or a short punchline ("If that were true, why are there still so many cases?", "Every case matters. Safety is for everyone, not just most pupils."). Answer the point, not the person. 4. Closing line: one sentence that sums up and ends the speech, like "That is why it is clear that schools are NOT truly safe. Thank you." -Keep the sentences short and direct, the way a student speaks in class. +Keep the sentences short and direct, the way a confident student speaks in class: say "we", never hedge ("This is a fair question", "we do not pretend"), and never describe a study's method. The whole speech, opening to closing line, should be about 650-750 words. --- CONCLUSOR (3 minutes) --- Closes the debate. NO new arguments: leave contentions and counters as empty arrays. Instead write whyWeWin: 2-3 short paragraphs, each summarising one reason our side won the debate, drawing on what the team already argued. Then the closing line. The opening is one sentence that sets up the summary. From 1fd9efbd4959edef78602af2be37473df54f87e2 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 28 Oct 2026 10:09:10 +0000 Subject: [PATCH 09/12] R2 answers each of their 2-3 arguments in full MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The other side usually runs only 2-3 arguments, so R2's "If they say…" holds one full answer per likely argument (about 90-120 words at standard length), built the way Adam writes his own refutations in Debate print 1: a direct reply, a fact with its source, what it means, a tie back to our argument, and a closing question or firm line. He underlines the ones the other side used and builds on them in the round. Co-Authored-By: Claude Opus 5.5 --- .../features/case-generation.md | 4 ++-- .../recipes/case-generation.spec.ts | 10 +++++----- e2e/full-stack/fake-openrouter.mjs | 12 ++++++++++-- .../_shared/control-enforcement.test.ts | 4 ++-- .../functions/_shared/control-enforcement.ts | 5 +++-- supabase/functions/_shared/prompts/generate.ts | 18 +++++++++++------- supabase/functions/ai/control-matrix.test.ts | 2 +- supabase/functions/ai/prompt-builder.test.ts | 4 ++-- 8 files changed, 36 insertions(+), 23 deletions(-) diff --git a/.claude/skills/verify-debatelab/features/case-generation.md b/.claude/skills/verify-debatelab/features/case-generation.md index 25e670979..c786058b3 100644 --- a/.claude/skills/verify-debatelab/features/case-generation.md +++ b/.claude/skills/verify-debatelab/features/case-generation.md @@ -1,6 +1,6 @@ # Case generation -A signed-in user enters a motion on Generate and receives a structured debate case: an opening (hook and "we will show N arguments" line), arguments (title, point, explanation, evidence, why it matters), an "If they say…" list, a closing line and a sources list. The role shapes it: the Introductor may add one definition line; R2 brings no new arguments, only `Backing up our case` (the team's earlier arguments) and the bulk in "If they say…"; the Conclusor has 2-3 "Why we win" points instead of new arguments. Saved cases from before still show their old sections under plain names (`How to judge this debate`, `What we have to show`, `They may also say`) when they have content. +A signed-in user enters a motion on Generate and receives a structured debate case: an opening (hook and "we will show N arguments" line), arguments (title, point, explanation, evidence, why it matters), an "If they say…" list, a closing line and a sources list. The role shapes it: the Introductor may add one definition line; R2 brings no new arguments, only `Backing up our case` (the team's earlier arguments) and "If they say…" with one full answer for each of their 2-3 likely arguments; the Conclusor has 2-3 "Why we win" points instead of new arguments. Saved cases from before still show their old sections under plain names (`How to judge this debate`, `What we have to show`, `They may also say`) when they have content. ## Sub-features @@ -10,7 +10,7 @@ A signed-in user enters a motion on Generate and receives a structured debate ca - `gen-quota` shows the dialog `Daily AI uses used up` when the account has no quota, without calling the provider. - `gen-save` saves a one-off case (the motion dialog answered with `Not now, use it once`) under a title into `cases` with `debate_id: null`. It appears in History under `One-off and unsorted work`, including from a fresh browser context. - `gen-regenerate` regenerates one section. Only that section shows the overlay `Regenerating
…`; the rest of the case and the result toolbar stay on screen, and every regenerate button and the form's submit button are disabled until it finishes. -- `gen-roles` shows the role's shape: on `/app/generate` pick the `Speaker Role` option `Introductor (4 min)` and the Opening card holds a `What we mean:` line; pick `R2 - Rebuttal/Build (6 min)` and there is no `Arguments` section, `Backing up our case` holds the team's earlier arguments, and `If they say…` lists six answers (standard length); pick `Conclusor (3 min)` and `Why we win` replaces `Arguments`. +- `gen-roles` shows the role's shape: on `/app/generate` pick the `Speaker Role` option `Introductor (4 min)` and the Opening card holds a `What we mean:` line; pick `R2 - Rebuttal/Build (6 min)` and there is no `Arguments` section, `Backing up our case` holds the team's earlier arguments, and `If they say…` holds three full answers, one per likely opposing argument (standard length); pick `Conclusor (3 min)` and `Why we win` replaces `Arguments`. - `gen-export` downloads a `.pdf` (starting with `%PDF`) and a `.docx` (starting with `PK`). - `gen-flowchart` builds a flowchart from the Current Debate's case. `/app/flowchart` opens the template; the button `Build from current case` replaces it with the thesis, one node per argument (title, point and evidence, or a Conclusor's reasons why we win), what the other side will say, and our answers, fitted to the canvas. Node titles the app wrote (`Thesis`, `Argument 1`) follow the UI language. diff --git a/.claude/skills/verify-debatelab/recipes/case-generation.spec.ts b/.claude/skills/verify-debatelab/recipes/case-generation.spec.ts index badae4106..1e1131288 100644 --- a/.claude/skills/verify-debatelab/recipes/case-generation.spec.ts +++ b/.claude/skills/verify-debatelab/recipes/case-generation.spec.ts @@ -193,13 +193,13 @@ test("gen-roles: the speaker role shapes the case: Introductor, R2 and Conclusor for (const item of r2.reinforce ?? []) await expect(page.getByText(item.text).first()).toBeVisible(); await expect(page.getByRole("button", { name: "Regenerate If they say…" })).toBeVisible(); await expect(page.getByText("They say", { exact: true })).toHaveCount(r2.counters.length); - // Standard length asks for six answers, the bulk of the speech. The Edge - // Function keeps a live reply with at least three and cuts it at six. + // One full answer for each of their 2-3 likely arguments: standard length + // asks for three, and the Edge Function keeps a live reply with at least two. if (LIVE) { - expect(r2.counters.length).toBeGreaterThanOrEqual(3); - expect(r2.counters.length).toBeLessThanOrEqual(6); + expect(r2.counters.length).toBeGreaterThanOrEqual(2); + expect(r2.counters.length).toBeLessThanOrEqual(3); } else { - expect(r2.counters).toHaveLength(6); + expect(r2.counters).toHaveLength(3); } await evidence.shot("02-r2"); await evidence.aria("02-r2"); diff --git a/e2e/full-stack/fake-openrouter.mjs b/e2e/full-stack/fake-openrouter.mjs index 9c2902963..570dfef4b 100644 --- a/e2e/full-stack/fake-openrouter.mjs +++ b/e2e/full-stack/fake-openrouter.mjs @@ -75,6 +75,13 @@ const counter = (index) => ({ ...(index % 2 === 0 ? { source: "UITP, 2024" } : {}), }); +// R2's answers are full spoken blocks the speaker underlines and builds on. +const r2Answer = (index) => ({ + ...counter(index), + counterpoint: `${MARK} answer ${index + 1}: that is not what the numbers show. Fare collection itself costs a large share of what it brings in, so the real gap is far smaller than they say (UITP, 2024). For a family on a low income, that difference is the bus to work or the walk. This is exactly our first argument: fares shut out the people who need transit most. If the money were really the problem, why do cities that dropped fares keep them dropped?`, + source: "UITP, 2024", +}); + // The role decides the shape, as the real prompt asks: the Introductor may // define an unclear term, R2 backs up the team's arguments and then answers // theirs, and the Conclusor brings reasons why we win. Neither R2 nor the @@ -85,7 +92,8 @@ const generate = (user) => { const count = { brief: 2, standard: 3, comprehensive: 4 }[length] ?? 3; const conc = role === "conc"; const r2 = role === "r2"; - const answers = { brief: 5, standard: 6, comprehensive: 8 }[length] ?? 6; + // R2: one full answer for each of their 2-3 likely arguments. + const answers = length === "brief" ? 2 : 3; return { opening: { hook: `${MARK} opening hook: picture a nurse who skips the night bus because the fare is a tenth of her shift's pay.`, @@ -99,7 +107,7 @@ const generate = (user) => { : `${MARK} case thesis: today we will show ${count} arguments that prove free transit is a net public good`, }, contentions: conc || r2 ? [] : Array.from({ length: count }, (_, index) => contention(index)), - counters: conc ? [] : Array.from({ length: r2 ? answers : 3 }, (_, index) => counter(index)), + counters: conc ? [] : Array.from({ length: r2 ? answers : 3 }, (_, index) => (r2 ? r2Answer(index) : counter(index))), ...(r2 ? { reinforce: [ { argument: "Our first argument: fares shut people out", text: `${MARK} backing up: our first argument was that fares shut out the riders who need transit most. Eurostat found in 2023 that low-income households spend more of their budget on transport. If fares were fair, those numbers would not look like this.` }, diff --git a/supabase/functions/_shared/control-enforcement.test.ts b/supabase/functions/_shared/control-enforcement.test.ts index 2a966ffd3..b6fcb9f87 100644 --- a/supabase/functions/_shared/control-enforcement.test.ts +++ b/supabase/functions/_shared/control-enforcement.test.ts @@ -242,7 +242,7 @@ Deno.test("an R2 case must back up the team's arguments and answer theirs, or it const cases: Array<[string, Record]> = [ ["no reinforce", { reinforce: [], counters: [{}, {}, {}, {}, {}], contentions: [], metadata: {} }], ["only blank reinforce", { reinforce: [{ argument: "Our first", text: " " }], counters: [{}, {}, {}, {}, {}], contentions: [], metadata: {} }], - ["too few answers", { reinforce: [{ argument: "Our first", text: "Backed up." }], counters: [{}, {}], contentions: [], metadata: {} }], + ["too few answers", { reinforce: [{ argument: "Our first", text: "Backed up." }], counters: [{}], contentions: [], metadata: {} }], ]; for (const [label, output] of cases) { let rejected = false; @@ -257,7 +257,7 @@ Deno.test("an R2 case must back up the team's arguments and answer theirs, or it // New arguments from the model are dropped: R2 brings none. const output: Record = { reinforce: [{ argument: "Our first", text: "Backed up." }], - counters: [{}, {}, {}, {}, {}], + counters: [{}, {}, {}], contentions: [{ claim: "A new point", whyItMatters: "x" }], metadata: {}, }; diff --git a/supabase/functions/_shared/control-enforcement.ts b/supabase/functions/_shared/control-enforcement.ts index fefc4249e..132e1b658 100644 --- a/supabase/functions/_shared/control-enforcement.ts +++ b/supabase/functions/_shared/control-enforcement.ts @@ -191,11 +191,12 @@ export function enforceGenerateControls(output: unknown, side: string, format: s item && typeof item === "object" && typeof (item as { text?: unknown }).text === "string" && (item as { text: string }).text.trim()) : []; const answers = Array.isArray(generateOutput.counters) ? generateOutput.counters : []; - const target = length === "brief" ? 5 : length === "comprehensive" ? 8 : 6; + // One full answer for each of their 2-3 likely arguments. + const target = length === "brief" ? 2 : 3; if (reinforce.length < 1) { throw new Error("Generate response returned no reinforce items, but R2 must back up the team's arguments."); } - if (answers.length < 3) { + if (answers.length < 2) { throw new Error(`Generate response returned ${answers.length} counters, but R2 needs ${target}.`); } generateOutput.reinforce = reinforce.slice(0, 3); diff --git a/supabase/functions/_shared/prompts/generate.ts b/supabase/functions/_shared/prompts/generate.ts index 9e67c2510..5f0fda97d 100644 --- a/supabase/functions/_shared/prompts/generate.ts +++ b/supabase/functions/_shared/prompts/generate.ts @@ -32,9 +32,13 @@ Builds the team's case in full. A short opening line, then the arguments in dept R2 brings NO new arguments: leave contentions as an empty array. The speech has four parts, in this order: 1. Opening (a few sentences). A hook that turns the motion around ("Schools present themselves as safe, but if that were true, thousands of children would not be suffering in silence every day."), then who is speaking and what they will prove: "I'm the R2 of my team, and I'm here to show that...". Put the hook in opening.hook and the "I'm the R2..." line in caseStructure.thesis. 2. Backing up our case (about 2 minutes, write it in reinforce). Go back to the arguments the Introductor and R1 already made and make them stronger. Each item names the earlier argument it returns to ("Our second argument was about bullying.") and then, in one spoken paragraph of about 100-130 words: the strongest fact for it with its source and year ("In Spain, 1 in 5 pupils has been bullied (Educo, 2022)"), what that means in plain words, one more fact or study if the research has it, and a sentence that ties it back to the motion ("If schools were really safe, these numbers would not look like this."). Never introduce a point the team has not made. If CURRENT DEBATE CONTEXT includes the team's earlier case, back up those arguments by name; otherwise back up the strongest arguments for our side as "our first argument" and "our second argument". -3. If they say... (about 4 minutes, the bulk of the speech, written in counters). Each item quotes what the other side has said or will say, in their words ("Schools have emergency plans to protect pupils."), then our answer in 2-3 short spoken sentences, about 35-55 words: first say straight away why their point does not hold ("Emergency plans do not stop everyday problems like bullying."), then back it with one fact and its source when the research has one. End at least half of the answers on a pointed question or a short punchline ("If that were true, why are there still so many cases?", "Every case matters. Safety is for everyone, not just most pupils."). Answer the point, not the person. +3. If they say... (about 4 minutes, the bulk of the speech, written in counters). The other side usually has only 2-3 arguments, so write one block for each of their 2-3 most likely arguments, no more. The speaker underlines the blocks the other side actually used, then speaks them and builds on them, so each block must be complete enough to say in full. Each item: + - targetArgument: their argument as they would put it, in one sentence ("Teachers and the school solve almost all bullying."). + - counterpoint: a full spoken answer of about 90-120 words, built like this: start by saying straight away why their point does not hold ("That is what the adults see, but more than 60% of pupils say their school does nothing."); give the strongest fact against it with its source and year; say in plain words what that means for real people ("There is a gap between what teachers say and what pupils live."); tie it to our own case by name ("This is exactly our second argument: ..."); and end on a firm line or a pointed question ("If the rules worked, why does 1 in 5 pupils still suffer?"). + - source: the source of that fact as "Organisation, year". + Answer the argument, not the person. 4. Closing line: one sentence that sums up and ends the speech, like "That is why it is clear that schools are NOT truly safe. Thank you." -Keep the sentences short and direct, the way a confident student speaks in class: say "we", never hedge ("This is a fair question", "we do not pretend"), and never describe a study's method. The whole speech, opening to closing line, should be about 650-750 words. +Keep the sentences short and direct, the way a confident student speaks in class: say "we", never hedge ("This is a fair question", "we do not pretend"), and never describe a study's method. The whole speech, opening to closing line, should be about 600-700 words. --- CONCLUSOR (3 minutes) --- Closes the debate. NO new arguments: leave contentions and counters as empty arrays. Instead write whyWeWin: 2-3 short paragraphs, each summarising one reason our side won the debate, drawing on what the team already argued. Then the closing line. The opening is one sentence that sets up the summary. @@ -117,7 +121,7 @@ TEAM CONSISTENCY: If CURRENT DEBATE CONTEXT includes an earlier case or brainsto REQUIREMENTS: 1. Side must be exactly "pro" or "con". 2. Generate contentions appropriate to the role (see above). -3. Except for the Conclusor, include 5-8 counters, one per likely opposing claim (R2: the exact number the Length Instruction gives). +3. Introductor and R1: include 5-8 counters, one per likely opposing claim. R2: the exact number of counters the Length Instruction gives, one per likely opposing argument. Conclusor: none. 4. Use attached WEB RESEARCH when present. Cite only sources that appear in it. 5. Set needsSource to false only when the attached research supports the contention. Set it to true when the contention still needs evidence. 6. Write the ENTIRE output in the language specified by the user. All arguments, evidence, descriptions, and analysis must be in that language. @@ -131,10 +135,10 @@ export function buildGenerateUserPrompt(input: Record, searchCo const role = asString(input.role, "intro"); const lengthInstruction = role === "r2" ? ({ - brief: "Return no contentions. Return exactly 2 reinforce items and exactly 5 counters.", - standard: "Return no contentions. Return exactly 2 reinforce items and exactly 6 counters.", - comprehensive: "Return no contentions. Return exactly 2 reinforce items and exactly 8 counters.", - }[length] || "Return no contentions. Return exactly 2 reinforce items and exactly 6 counters.") + brief: "Return no contentions. Return exactly 2 reinforce items and exactly 2 counters, each answer about 70-90 words.", + standard: "Return no contentions. Return exactly 2 reinforce items and exactly 3 counters, each answer about 90-120 words.", + comprehensive: "Return no contentions. Return exactly 2 reinforce items and exactly 3 counters, each answer about 120-150 words.", + }[length] || "Return no contentions. Return exactly 2 reinforce items and exactly 3 counters, each answer about 90-120 words.") : role === "conc" ? ({ brief: "Return no contentions and no counters. Return exactly 2 whyWeWin items, each two or three sentences.", diff --git a/supabase/functions/ai/control-matrix.test.ts b/supabase/functions/ai/control-matrix.test.ts index b3ff8808a..75bb2cbed 100644 --- a/supabase/functions/ai/control-matrix.test.ts +++ b/supabase/functions/ai/control-matrix.test.ts @@ -39,7 +39,7 @@ Deno.test("generate length and role controls preserve the output contract", () = // R2 brings no new arguments: it backs up the team's and answers theirs. assert((output.contentions as unknown[]).length === 0, `generate.${length}.r2 has no contentions`); assert((output.reinforce as unknown[]).length === 1, `generate.${length}.r2 keeps only reinforce items with text`); - assert((output.counters as unknown[]).length === { brief: 5, standard: 6, comprehensive: 8 }[length], `generate.${length}.r2 answer count`); + assert((output.counters as unknown[]).length === (length === "brief" ? 2 : 3), `generate.${length}.r2 answer count`); assert(!("whyWeWin" in output), "generate.r2 has no whyWeWin"); } else if (role === "conc") { // The Conclusor brings 2-3 reasons we win and no new arguments. diff --git a/supabase/functions/ai/prompt-builder.test.ts b/supabase/functions/ai/prompt-builder.test.ts index 104a9ce64..19272d581 100644 --- a/supabase/functions/ai/prompt-builder.test.ts +++ b/supabase/functions/ai/prompt-builder.test.ts @@ -89,8 +89,8 @@ Deno.test("generate prompt matrix preserves every school role and length rule", assertIncludes(prompt, role === "conc" ? `Length Instruction: Return no contentions and no counters. Return exactly ${length === "brief" ? 2 : 3} whyWeWin items` : role === "r2" - // R2 brings no new arguments, so length sets how many answers it gives. - ? `Length Instruction: Return no contentions. Return exactly 2 reinforce items and exactly ${{ brief: 5, standard: 6, comprehensive: 8 }[length]} counters.` + // R2 brings no new arguments: one full answer for each of their 2-3 arguments. + ? `Length Instruction: Return no contentions. Return exactly 2 reinforce items and exactly ${length === "brief" ? 2 : 3} counters` : `Length Instruction: Return exactly ${contentionCount} contentions`); assertIncludes(prompt, "Language: Hungarian (hu)"); assertIncludes(prompt, "RESEARCH CONTEXT"); From 2916c51c707eacd3e582e0a402dcc48c72b00772 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 28 Oct 2026 10:20:13 +0000 Subject: [PATCH 10/12] R2 answers push back first, as Adam's do, and keep to his length Co-Authored-By: Claude Opus 5.5 --- supabase/functions/_shared/prompts/generate.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/supabase/functions/_shared/prompts/generate.ts b/supabase/functions/_shared/prompts/generate.ts index 5f0fda97d..658e41928 100644 --- a/supabase/functions/_shared/prompts/generate.ts +++ b/supabase/functions/_shared/prompts/generate.ts @@ -31,10 +31,10 @@ Builds the team's case in full. A short opening line, then the arguments in dept --- R2 - Second Speaker (6 minutes) --- R2 brings NO new arguments: leave contentions as an empty array. The speech has four parts, in this order: 1. Opening (a few sentences). A hook that turns the motion around ("Schools present themselves as safe, but if that were true, thousands of children would not be suffering in silence every day."), then who is speaking and what they will prove: "I'm the R2 of my team, and I'm here to show that...". Put the hook in opening.hook and the "I'm the R2..." line in caseStructure.thesis. -2. Backing up our case (about 2 minutes, write it in reinforce). Go back to the arguments the Introductor and R1 already made and make them stronger. Each item names the earlier argument it returns to ("Our second argument was about bullying.") and then, in one spoken paragraph of about 100-130 words: the strongest fact for it with its source and year ("In Spain, 1 in 5 pupils has been bullied (Educo, 2022)"), what that means in plain words, one more fact or study if the research has it, and a sentence that ties it back to the motion ("If schools were really safe, these numbers would not look like this."). Never introduce a point the team has not made. If CURRENT DEBATE CONTEXT includes the team's earlier case, back up those arguments by name; otherwise back up the strongest arguments for our side as "our first argument" and "our second argument". +2. Backing up our case (about 2 minutes, write it in reinforce). Go back to the arguments the Introductor and R1 already made and make them stronger. Each item names the earlier argument it returns to ("Our second argument was about bullying.") and then, in one spoken paragraph of about 100-120 words (keep to it): the strongest fact for it with its source and year ("In Spain, 1 in 5 pupils has been bullied (Educo, 2022)"), what that means in plain words, one more fact or study if the research has it, and a sentence that ties it back to the motion ("If schools were really safe, these numbers would not look like this."). Never introduce a point the team has not made. If CURRENT DEBATE CONTEXT includes the team's earlier case, back up those arguments by name; otherwise back up the strongest arguments for our side as "our first argument" and "our second argument". 3. If they say... (about 4 minutes, the bulk of the speech, written in counters). The other side usually has only 2-3 arguments, so write one block for each of their 2-3 most likely arguments, no more. The speaker underlines the blocks the other side actually used, then speaks them and builds on them, so each block must be complete enough to say in full. Each item: - targetArgument: their argument as they would put it, in one sentence ("Teachers and the school solve almost all bullying."). - - counterpoint: a full spoken answer of about 90-120 words, built like this: start by saying straight away why their point does not hold ("That is what the adults see, but more than 60% of pupils say their school does nothing."); give the strongest fact against it with its source and year; say in plain words what that means for real people ("There is a gap between what teachers say and what pupils live."); tie it to our own case by name ("This is exactly our second argument: ..."); and end on a firm line or a pointed question ("If the rules worked, why does 1 in 5 pupils still suffer?"). + - counterpoint: a full spoken answer of about 90-110 words, built like this: start by pushing back in the first sentence, never by agreeing or conceding ("We accept...", "That is a fair worry", "we agree" are not allowed), and say straight away why their point does not hold ("That is what the adults see, but more than 60% of pupils say their school does nothing."); give the strongest fact against it with its source and year; say in plain words what that means for real people ("There is a gap between what teachers say and what pupils live."); tie it to our own case by name ("This is exactly our second argument: ..."); and end on a firm line or a pointed question ("If the rules worked, why does 1 in 5 pupils still suffer?"). - source: the source of that fact as "Organisation, year". Answer the argument, not the person. 4. Closing line: one sentence that sums up and ends the speech, like "That is why it is clear that schools are NOT truly safe. Thank you." @@ -136,9 +136,9 @@ export function buildGenerateUserPrompt(input: Record, searchCo const lengthInstruction = role === "r2" ? ({ brief: "Return no contentions. Return exactly 2 reinforce items and exactly 2 counters, each answer about 70-90 words.", - standard: "Return no contentions. Return exactly 2 reinforce items and exactly 3 counters, each answer about 90-120 words.", + standard: "Return no contentions. Return exactly 2 reinforce items and exactly 3 counters, each answer about 90-110 words.", comprehensive: "Return no contentions. Return exactly 2 reinforce items and exactly 3 counters, each answer about 120-150 words.", - }[length] || "Return no contentions. Return exactly 2 reinforce items and exactly 3 counters, each answer about 90-120 words.") + }[length] || "Return no contentions. Return exactly 2 reinforce items and exactly 3 counters, each answer about 90-110 words.") : role === "conc" ? ({ brief: "Return no contentions and no counters. Return exactly 2 whyWeWin items, each two or three sentences.", From 7cdc2e81da9a711808fa6c0960d2bdf4b262793d Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 28 Oct 2026 10:39:48 +0000 Subject: [PATCH 11/12] R2 prepares a fuller answer to everything they are likely to say MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adam asked for more "If they say…" blocks, each with more content, like his own debates. R2 now writes 6 blocks at standard length (5 brief): their main arguments, the points they use to back them up, and their answers to our case. Each answer pushes back at once, gives at least two specific facts with source and year, says what they mean, asks a question that turns their point around, and ties back to our argument by name. Numbers must come from the attached research and every cited source must be in the sources list. Co-Authored-By: Claude Opus 5.5 --- .../features/case-generation.md | 4 +-- .../recipes/case-generation.spec.ts | 10 +++---- e2e/full-stack/fake-openrouter.mjs | 6 ++--- .../_shared/control-enforcement.test.ts | 4 +-- .../functions/_shared/control-enforcement.ts | 7 ++--- .../functions/_shared/prompts/generate.ts | 27 +++++++++++-------- supabase/functions/ai/control-matrix.test.ts | 2 +- supabase/functions/ai/prompt-builder.test.ts | 4 +-- 8 files changed, 35 insertions(+), 29 deletions(-) diff --git a/.claude/skills/verify-debatelab/features/case-generation.md b/.claude/skills/verify-debatelab/features/case-generation.md index c786058b3..3ba4b2aa5 100644 --- a/.claude/skills/verify-debatelab/features/case-generation.md +++ b/.claude/skills/verify-debatelab/features/case-generation.md @@ -1,6 +1,6 @@ # Case generation -A signed-in user enters a motion on Generate and receives a structured debate case: an opening (hook and "we will show N arguments" line), arguments (title, point, explanation, evidence, why it matters), an "If they say…" list, a closing line and a sources list. The role shapes it: the Introductor may add one definition line; R2 brings no new arguments, only `Backing up our case` (the team's earlier arguments) and "If they say…" with one full answer for each of their 2-3 likely arguments; the Conclusor has 2-3 "Why we win" points instead of new arguments. Saved cases from before still show their old sections under plain names (`How to judge this debate`, `What we have to show`, `They may also say`) when they have content. +A signed-in user enters a motion on Generate and receives a structured debate case: an opening (hook and "we will show N arguments" line), arguments (title, point, explanation, evidence, why it matters), an "If they say…" list, a closing line and a sources list. The role shapes it: the Introductor may add one definition line; R2 brings no new arguments, only `Backing up our case` (the team's earlier arguments) and "If they say…" with one full answer for each thing the other side is likely to say; the Conclusor has 2-3 "Why we win" points instead of new arguments. Saved cases from before still show their old sections under plain names (`How to judge this debate`, `What we have to show`, `They may also say`) when they have content. ## Sub-features @@ -10,7 +10,7 @@ A signed-in user enters a motion on Generate and receives a structured debate ca - `gen-quota` shows the dialog `Daily AI uses used up` when the account has no quota, without calling the provider. - `gen-save` saves a one-off case (the motion dialog answered with `Not now, use it once`) under a title into `cases` with `debate_id: null`. It appears in History under `One-off and unsorted work`, including from a fresh browser context. - `gen-regenerate` regenerates one section. Only that section shows the overlay `Regenerating
…`; the rest of the case and the result toolbar stay on screen, and every regenerate button and the form's submit button are disabled until it finishes. -- `gen-roles` shows the role's shape: on `/app/generate` pick the `Speaker Role` option `Introductor (4 min)` and the Opening card holds a `What we mean:` line; pick `R2 - Rebuttal/Build (6 min)` and there is no `Arguments` section, `Backing up our case` holds the team's earlier arguments, and `If they say…` holds three full answers, one per likely opposing argument (standard length); pick `Conclusor (3 min)` and `Why we win` replaces `Arguments`. +- `gen-roles` shows the role's shape: on `/app/generate` pick the `Speaker Role` option `Introductor (4 min)` and the Opening card holds a `What we mean:` line; pick `R2 - Rebuttal/Build (6 min)` and there is no `Arguments` section, `Backing up our case` holds the team's earlier arguments, and `If they say…` holds six full answers covering their main arguments, supporting points and answers to our case (standard length); pick `Conclusor (3 min)` and `Why we win` replaces `Arguments`. - `gen-export` downloads a `.pdf` (starting with `%PDF`) and a `.docx` (starting with `PK`). - `gen-flowchart` builds a flowchart from the Current Debate's case. `/app/flowchart` opens the template; the button `Build from current case` replaces it with the thesis, one node per argument (title, point and evidence, or a Conclusor's reasons why we win), what the other side will say, and our answers, fitted to the canvas. Node titles the app wrote (`Thesis`, `Argument 1`) follow the UI language. diff --git a/.claude/skills/verify-debatelab/recipes/case-generation.spec.ts b/.claude/skills/verify-debatelab/recipes/case-generation.spec.ts index 1e1131288..5020e69c3 100644 --- a/.claude/skills/verify-debatelab/recipes/case-generation.spec.ts +++ b/.claude/skills/verify-debatelab/recipes/case-generation.spec.ts @@ -193,13 +193,13 @@ test("gen-roles: the speaker role shapes the case: Introductor, R2 and Conclusor for (const item of r2.reinforce ?? []) await expect(page.getByText(item.text).first()).toBeVisible(); await expect(page.getByRole("button", { name: "Regenerate If they say…" })).toBeVisible(); await expect(page.getByText("They say", { exact: true })).toHaveCount(r2.counters.length); - // One full answer for each of their 2-3 likely arguments: standard length - // asks for three, and the Edge Function keeps a live reply with at least two. + // One full answer for each thing they are likely to say: standard length + // asks for six, and the Edge Function keeps a live reply with at least four. if (LIVE) { - expect(r2.counters.length).toBeGreaterThanOrEqual(2); - expect(r2.counters.length).toBeLessThanOrEqual(3); + expect(r2.counters.length).toBeGreaterThanOrEqual(4); + expect(r2.counters.length).toBeLessThanOrEqual(6); } else { - expect(r2.counters).toHaveLength(3); + expect(r2.counters).toHaveLength(6); } await evidence.shot("02-r2"); await evidence.aria("02-r2"); diff --git a/e2e/full-stack/fake-openrouter.mjs b/e2e/full-stack/fake-openrouter.mjs index 570dfef4b..5be51c986 100644 --- a/e2e/full-stack/fake-openrouter.mjs +++ b/e2e/full-stack/fake-openrouter.mjs @@ -78,7 +78,7 @@ const counter = (index) => ({ // R2's answers are full spoken blocks the speaker underlines and builds on. const r2Answer = (index) => ({ ...counter(index), - counterpoint: `${MARK} answer ${index + 1}: that is not what the numbers show. Fare collection itself costs a large share of what it brings in, so the real gap is far smaller than they say (UITP, 2024). For a family on a low income, that difference is the bus to work or the walk. This is exactly our first argument: fares shut out the people who need transit most. If the money were really the problem, why do cities that dropped fares keep them dropped?`, + counterpoint: `${MARK} answer ${index + 1}: that is not what the numbers show. Fare collection itself costs a large share of what it brings in, so the real gap is far smaller than they say (UITP, 2024). Low-income households already spend more of their budget on transport than anyone else (Eurostat, 2023). For a family on a low income, that difference is the bus to work or the walk. If the money were really the problem, why do cities that dropped fares keep them dropped? This is exactly our first argument: fares shut out the people who need transit most.`, source: "UITP, 2024", }); @@ -92,8 +92,8 @@ const generate = (user) => { const count = { brief: 2, standard: 3, comprehensive: 4 }[length] ?? 3; const conc = role === "conc"; const r2 = role === "r2"; - // R2: one full answer for each of their 2-3 likely arguments. - const answers = length === "brief" ? 2 : 3; + // R2: one full answer for each thing they are likely to say. + const answers = length === "brief" ? 5 : 6; return { opening: { hook: `${MARK} opening hook: picture a nurse who skips the night bus because the fare is a tenth of her shift's pay.`, diff --git a/supabase/functions/_shared/control-enforcement.test.ts b/supabase/functions/_shared/control-enforcement.test.ts index b6fcb9f87..49dc207cb 100644 --- a/supabase/functions/_shared/control-enforcement.test.ts +++ b/supabase/functions/_shared/control-enforcement.test.ts @@ -242,7 +242,7 @@ Deno.test("an R2 case must back up the team's arguments and answer theirs, or it const cases: Array<[string, Record]> = [ ["no reinforce", { reinforce: [], counters: [{}, {}, {}, {}, {}], contentions: [], metadata: {} }], ["only blank reinforce", { reinforce: [{ argument: "Our first", text: " " }], counters: [{}, {}, {}, {}, {}], contentions: [], metadata: {} }], - ["too few answers", { reinforce: [{ argument: "Our first", text: "Backed up." }], counters: [{}], contentions: [], metadata: {} }], + ["too few answers", { reinforce: [{ argument: "Our first", text: "Backed up." }], counters: [{}, {}, {}], contentions: [], metadata: {} }], ]; for (const [label, output] of cases) { let rejected = false; @@ -257,7 +257,7 @@ Deno.test("an R2 case must back up the team's arguments and answer theirs, or it // New arguments from the model are dropped: R2 brings none. const output: Record = { reinforce: [{ argument: "Our first", text: "Backed up." }], - counters: [{}, {}, {}], + counters: [{}, {}, {}, {}, {}], contentions: [{ claim: "A new point", whyItMatters: "x" }], metadata: {}, }; diff --git a/supabase/functions/_shared/control-enforcement.ts b/supabase/functions/_shared/control-enforcement.ts index 132e1b658..c0380d2df 100644 --- a/supabase/functions/_shared/control-enforcement.ts +++ b/supabase/functions/_shared/control-enforcement.ts @@ -191,12 +191,13 @@ export function enforceGenerateControls(output: unknown, side: string, format: s item && typeof item === "object" && typeof (item as { text?: unknown }).text === "string" && (item as { text: string }).text.trim()) : []; const answers = Array.isArray(generateOutput.counters) ? generateOutput.counters : []; - // One full answer for each of their 2-3 likely arguments. - const target = length === "brief" ? 2 : 3; + // One full answer for each thing they are likely to say: their main + // arguments, supporting points and answers to our case. + const target = length === "brief" ? 5 : 6; if (reinforce.length < 1) { throw new Error("Generate response returned no reinforce items, but R2 must back up the team's arguments."); } - if (answers.length < 2) { + if (answers.length < 4) { throw new Error(`Generate response returned ${answers.length} counters, but R2 needs ${target}.`); } generateOutput.reinforce = reinforce.slice(0, 3); diff --git a/supabase/functions/_shared/prompts/generate.ts b/supabase/functions/_shared/prompts/generate.ts index 658e41928..698ea5026 100644 --- a/supabase/functions/_shared/prompts/generate.ts +++ b/supabase/functions/_shared/prompts/generate.ts @@ -32,13 +32,18 @@ Builds the team's case in full. A short opening line, then the arguments in dept R2 brings NO new arguments: leave contentions as an empty array. The speech has four parts, in this order: 1. Opening (a few sentences). A hook that turns the motion around ("Schools present themselves as safe, but if that were true, thousands of children would not be suffering in silence every day."), then who is speaking and what they will prove: "I'm the R2 of my team, and I'm here to show that...". Put the hook in opening.hook and the "I'm the R2..." line in caseStructure.thesis. 2. Backing up our case (about 2 minutes, write it in reinforce). Go back to the arguments the Introductor and R1 already made and make them stronger. Each item names the earlier argument it returns to ("Our second argument was about bullying.") and then, in one spoken paragraph of about 100-120 words (keep to it): the strongest fact for it with its source and year ("In Spain, 1 in 5 pupils has been bullied (Educo, 2022)"), what that means in plain words, one more fact or study if the research has it, and a sentence that ties it back to the motion ("If schools were really safe, these numbers would not look like this."). Never introduce a point the team has not made. If CURRENT DEBATE CONTEXT includes the team's earlier case, back up those arguments by name; otherwise back up the strongest arguments for our side as "our first argument" and "our second argument". -3. If they say... (about 4 minutes, the bulk of the speech, written in counters). The other side usually has only 2-3 arguments, so write one block for each of their 2-3 most likely arguments, no more. The speaker underlines the blocks the other side actually used, then speaks them and builds on them, so each block must be complete enough to say in full. Each item: - - targetArgument: their argument as they would put it, in one sentence ("Teachers and the school solve almost all bullying."). - - counterpoint: a full spoken answer of about 90-110 words, built like this: start by pushing back in the first sentence, never by agreeing or conceding ("We accept...", "That is a fair worry", "we agree" are not allowed), and say straight away why their point does not hold ("That is what the adults see, but more than 60% of pupils say their school does nothing."); give the strongest fact against it with its source and year; say in plain words what that means for real people ("There is a gap between what teachers say and what pupils live."); tie it to our own case by name ("This is exactly our second argument: ..."); and end on a firm line or a pointed question ("If the rules worked, why does 1 in 5 pupils still suffer?"). - - source: the source of that fact as "Organisation, year". - Answer the argument, not the person. +3. If they say... (about 4 minutes, the bulk of the speech, written in counters). Prepare one block for each thing the other side is likely to say: their 2-3 main arguments, then the supporting points they will use to back them up, and the answers they will give to our arguments. The speaker underlines the blocks the other side actually used, then speaks them and builds on them, so each block must be complete enough to say in full. Each item: + - targetArgument: what they say, in one sentence, the way they would put it ("Teachers and the school solve almost all bullying."). + - counterpoint: our full spoken answer, built like this: + a) Push back in the first sentence, never by agreeing or conceding ("We accept...", "That is a fair worry", "we agree" are not allowed): say straight away why their point does not hold ("That is what the adults see, but more than 60% of pupils say their school does nothing."). + b) Give at least two specific facts against it, each with a number where the research has one, naming the source and year in the sentence ("1 in 3 victims never tells anyone (UNESCO, 2021)"). + c) Say in plain words what those facts mean for real people ("There is a gap between what teachers say and what pupils live."). + d) Ask a pointed question that turns their point around ("If the rules worked, why does 1 in 5 pupils still suffer?"). + e) Tie it to our own case by name ("This is exactly our second argument: ..."). + - source: the source of the main fact as "Organisation, year". + Every number and fact must come from the attached research or Library. Never invent or estimate one: if the research has no number for a point, make the point with reasoning instead. Every source an answer names must also appear in the sources list. Answer the argument, not the person. 4. Closing line: one sentence that sums up and ends the speech, like "That is why it is clear that schools are NOT truly safe. Thank you." -Keep the sentences short and direct, the way a confident student speaks in class: say "we", never hedge ("This is a fair question", "we do not pretend"), and never describe a study's method. The whole speech, opening to closing line, should be about 600-700 words. +Keep the sentences short and direct, the way a confident student speaks in class: say "we", never hedge ("This is a fair question", "we do not pretend"), and never describe a study's method. Not every "If they say..." block is spoken, so the written answers can run longer than the four minutes. --- CONCLUSOR (3 minutes) --- Closes the debate. NO new arguments: leave contentions and counters as empty arrays. Instead write whyWeWin: 2-3 short paragraphs, each summarising one reason our side won the debate, drawing on what the team already argued. Then the closing line. The opening is one sentence that sets up the summary. @@ -121,7 +126,7 @@ TEAM CONSISTENCY: If CURRENT DEBATE CONTEXT includes an earlier case or brainsto REQUIREMENTS: 1. Side must be exactly "pro" or "con". 2. Generate contentions appropriate to the role (see above). -3. Introductor and R1: include 5-8 counters, one per likely opposing claim. R2: the exact number of counters the Length Instruction gives, one per likely opposing argument. Conclusor: none. +3. Introductor and R1: include 5-8 counters, one per likely opposing claim. R2: the exact number of counters the Length Instruction gives, covering their main arguments, supporting points and answers to our case. Conclusor: none. 4. Use attached WEB RESEARCH when present. Cite only sources that appear in it. 5. Set needsSource to false only when the attached research supports the contention. Set it to true when the contention still needs evidence. 6. Write the ENTIRE output in the language specified by the user. All arguments, evidence, descriptions, and analysis must be in that language. @@ -135,10 +140,10 @@ export function buildGenerateUserPrompt(input: Record, searchCo const role = asString(input.role, "intro"); const lengthInstruction = role === "r2" ? ({ - brief: "Return no contentions. Return exactly 2 reinforce items and exactly 2 counters, each answer about 70-90 words.", - standard: "Return no contentions. Return exactly 2 reinforce items and exactly 3 counters, each answer about 90-110 words.", - comprehensive: "Return no contentions. Return exactly 2 reinforce items and exactly 3 counters, each answer about 120-150 words.", - }[length] || "Return no contentions. Return exactly 2 reinforce items and exactly 3 counters, each answer about 90-110 words.") + brief: "Return no contentions. Return exactly 2 reinforce items and exactly 5 counters, each answer about 140-170 words.", + standard: "Return no contentions. Return exactly 2 reinforce items and exactly 6 counters, each answer about 170-220 words.", + comprehensive: "Return no contentions. Return exactly 2 reinforce items and exactly 6 counters, each answer about 220-270 words.", + }[length] || "Return no contentions. Return exactly 2 reinforce items and exactly 6 counters, each answer about 170-220 words.") : role === "conc" ? ({ brief: "Return no contentions and no counters. Return exactly 2 whyWeWin items, each two or three sentences.", diff --git a/supabase/functions/ai/control-matrix.test.ts b/supabase/functions/ai/control-matrix.test.ts index 75bb2cbed..5bbc5aff4 100644 --- a/supabase/functions/ai/control-matrix.test.ts +++ b/supabase/functions/ai/control-matrix.test.ts @@ -39,7 +39,7 @@ Deno.test("generate length and role controls preserve the output contract", () = // R2 brings no new arguments: it backs up the team's and answers theirs. assert((output.contentions as unknown[]).length === 0, `generate.${length}.r2 has no contentions`); assert((output.reinforce as unknown[]).length === 1, `generate.${length}.r2 keeps only reinforce items with text`); - assert((output.counters as unknown[]).length === (length === "brief" ? 2 : 3), `generate.${length}.r2 answer count`); + assert((output.counters as unknown[]).length === (length === "brief" ? 5 : 6), `generate.${length}.r2 answer count`); assert(!("whyWeWin" in output), "generate.r2 has no whyWeWin"); } else if (role === "conc") { // The Conclusor brings 2-3 reasons we win and no new arguments. diff --git a/supabase/functions/ai/prompt-builder.test.ts b/supabase/functions/ai/prompt-builder.test.ts index 19272d581..941b01e5d 100644 --- a/supabase/functions/ai/prompt-builder.test.ts +++ b/supabase/functions/ai/prompt-builder.test.ts @@ -89,8 +89,8 @@ Deno.test("generate prompt matrix preserves every school role and length rule", assertIncludes(prompt, role === "conc" ? `Length Instruction: Return no contentions and no counters. Return exactly ${length === "brief" ? 2 : 3} whyWeWin items` : role === "r2" - // R2 brings no new arguments: one full answer for each of their 2-3 arguments. - ? `Length Instruction: Return no contentions. Return exactly 2 reinforce items and exactly ${length === "brief" ? 2 : 3} counters` + // R2 brings no new arguments: one full answer for each thing they are likely to say. + ? `Length Instruction: Return no contentions. Return exactly 2 reinforce items and exactly ${length === "brief" ? 5 : 6} counters` : `Length Instruction: Return exactly ${contentionCount} contentions`); assertIncludes(prompt, "Language: Hungarian (hu)"); assertIncludes(prompt, "RESEARCH CONTEXT"); From 08db62ff244a898e4fc969bf15a837c0a02a2978 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 28 Oct 2026 11:05:59 +0000 Subject: [PATCH 12/12] Give a case room to reason so a long R2 is not cut off Claude Haiku reasoned for 6,000-10,000 tokens on an R2 case at low effort, past the 3,000 that a 15,000 max_tokens left it. One reply was cut off and its retry finished at 128 s of the 130 s budget. Generate now keeps at least 10,000 tokens for reasoning (max_tokens 22,000 at low) while its reply cap stays at 12,000, so every cap still fits its retry. Also keep R2's backing-up paragraphs short beside the longer answers. Co-Authored-By: Claude Opus 5.5 --- supabase/functions/_shared/output-caps.test.ts | 14 ++++++++++++-- supabase/functions/_shared/output-caps.ts | 15 ++++++++++++++- supabase/functions/_shared/prompts/generate.ts | 2 +- supabase/functions/ai/provider.ts | 12 +++++++++--- 4 files changed, 36 insertions(+), 7 deletions(-) diff --git a/supabase/functions/_shared/output-caps.test.ts b/supabase/functions/_shared/output-caps.test.ts index 78f4f7d09..766fee2f7 100644 --- a/supabase/functions/_shared/output-caps.test.ts +++ b/supabase/functions/_shared/output-caps.test.ts @@ -2,7 +2,7 @@ // leaves a model no room to answer. These pin the formula from outside it. import { assertEquals } from "jsr:@std/assert@1.0.19"; -import { outputTokenLimit, REPLY_TOKEN_CAPS, TRUNCATION_RETRY_FACTOR } from "./output-caps.ts"; +import { outputTokenLimit, REASONING_FLOOR_TOKENS, REPLY_TOKEN_CAPS, type ReplyCap, TRUNCATION_RETRY_FACTOR } from "./output-caps.ts"; import type { ReasoningEffort } from "./model-catalog.ts"; // OpenRouter's Claude budget: max(max_tokens * share, 1024). @@ -22,7 +22,7 @@ Deno.test("every cap leaves its full reply after Claude's reasoning budget and a for (const [cap, reply] of Object.entries(REPLY_TOKEN_CAPS)) { for (const effort of EFFORTS) { for (const replyTokens of [reply, reply * TRUNCATION_RETRY_FACTOR]) { - const limit = outputTokenLimit(replyTokens, effort, 128_000); + const limit = outputTokenLimit(replyTokens, effort, 128_000, REASONING_FLOOR_TOKENS[cap as ReplyCap]); const claudeBudget = Math.max(Math.floor(limit * CLAUDE_SHARE[effort]), 1_024); if (limit - claudeBudget < replyTokens) throw new Error(`${cap} at ${effort}: Claude keeps ${limit - claudeBudget} of ${replyTokens}`); if (limit - replyTokens < 2_048) throw new Error(`${cap} at ${effort}: only ${limit - replyTokens} tokens for reasoning`); @@ -30,3 +30,13 @@ Deno.test("every cap leaves its full reply after Claude's reasoning budget and a } } }); + +Deno.test("a case keeps 10,000 tokens for reasoning at low effort, on the first try and the retry", () => { + // Claude Haiku reasoned for up to 10,000 tokens on an R2 case at low effort; + // with 3,000 of room one reply was cut off and its retry nearly timed out. + const floor = REASONING_FLOOR_TOKENS.generate; + assertEquals(outputTokenLimit(REPLY_TOKEN_CAPS.generate, "low", 128_000, floor), 22_000); + assertEquals(outputTokenLimit(REPLY_TOKEN_CAPS.generate * TRUNCATION_RETRY_FACTOR, "low", 128_000, floor), 34_000); + // A floor below the effort's own room changes nothing. + assertEquals(outputTokenLimit(1_500, "high", 128_000, 1_000), 9_692); +}); diff --git a/supabase/functions/_shared/output-caps.ts b/supabase/functions/_shared/output-caps.ts index 4f87b3787..93327f2a9 100644 --- a/supabase/functions/_shared/output-caps.ts +++ b/supabase/functions/_shared/output-caps.ts @@ -57,6 +57,17 @@ const REASONING_SHARE_PERCENT: Record = { low: 20, medi */ const REASONING_HEADROOM: Record = { low: 2_048, medium: 4_096, high: 8_192 }; +/** + * More reasoning room than the effort's floor, for a reply that measured it. + * A case is long JSON written after a lot of planning: Claude Haiku at low + * effort reasoned for 6,000-10,000 tokens on an R2 case, far past its share + * of 15,000, and one reply was cut off and finished only at 128 s of the + * 130 s budget. The room only bounds the reply, so it costs nothing unless used. + */ +export const REASONING_FLOOR_TOKENS: Partial> = { + generate: 10_000, +}; + /** * The max_tokens to send so the reply keeps `replyTokens` after reasoning: * after Claude's full budget, and after the headroom for every other model. @@ -66,7 +77,9 @@ export function outputTokenLimit( replyTokens: number, effort: ReasoningEffort, modelMaxTokens: number, + reasoningFloor = 0, ): number { const afterBudget = Math.ceil((replyTokens * 100) / (100 - REASONING_SHARE_PERCENT[effort])); - return Math.min(modelMaxTokens, Math.max(afterBudget, replyTokens + REASONING_HEADROOM[effort])); + const headroom = Math.max(REASONING_HEADROOM[effort], reasoningFloor); + return Math.min(modelMaxTokens, Math.max(afterBudget, replyTokens + headroom)); } diff --git a/supabase/functions/_shared/prompts/generate.ts b/supabase/functions/_shared/prompts/generate.ts index 698ea5026..4289c782c 100644 --- a/supabase/functions/_shared/prompts/generate.ts +++ b/supabase/functions/_shared/prompts/generate.ts @@ -31,7 +31,7 @@ Builds the team's case in full. A short opening line, then the arguments in dept --- R2 - Second Speaker (6 minutes) --- R2 brings NO new arguments: leave contentions as an empty array. The speech has four parts, in this order: 1. Opening (a few sentences). A hook that turns the motion around ("Schools present themselves as safe, but if that were true, thousands of children would not be suffering in silence every day."), then who is speaking and what they will prove: "I'm the R2 of my team, and I'm here to show that...". Put the hook in opening.hook and the "I'm the R2..." line in caseStructure.thesis. -2. Backing up our case (about 2 minutes, write it in reinforce). Go back to the arguments the Introductor and R1 already made and make them stronger. Each item names the earlier argument it returns to ("Our second argument was about bullying.") and then, in one spoken paragraph of about 100-120 words (keep to it): the strongest fact for it with its source and year ("In Spain, 1 in 5 pupils has been bullied (Educo, 2022)"), what that means in plain words, one more fact or study if the research has it, and a sentence that ties it back to the motion ("If schools were really safe, these numbers would not look like this."). Never introduce a point the team has not made. If CURRENT DEBATE CONTEXT includes the team's earlier case, back up those arguments by name; otherwise back up the strongest arguments for our side as "our first argument" and "our second argument". +2. Backing up our case (about 2 minutes, write it in reinforce). Go back to the arguments the Introductor and R1 already made and make them stronger. Each item names the earlier argument it returns to ("Our second argument was about bullying.") and then, in one spoken paragraph of about 100-120 words (keep to it, even though the answers in part 3 are much longer): the strongest fact for it with its source and year ("In Spain, 1 in 5 pupils has been bullied (Educo, 2022)"), what that means in plain words, one more fact or study if the research has it, and a sentence that ties it back to the motion ("If schools were really safe, these numbers would not look like this."). Never introduce a point the team has not made. If CURRENT DEBATE CONTEXT includes the team's earlier case, back up those arguments by name; otherwise back up the strongest arguments for our side as "our first argument" and "our second argument". 3. If they say... (about 4 minutes, the bulk of the speech, written in counters). Prepare one block for each thing the other side is likely to say: their 2-3 main arguments, then the supporting points they will use to back them up, and the answers they will give to our arguments. The speaker underlines the blocks the other side actually used, then speaks them and builds on them, so each block must be complete enough to say in full. Each item: - targetArgument: what they say, in one sentence, the way they would put it ("Teachers and the school solve almost all bullying."). - counterpoint: our full spoken answer, built like this: diff --git a/supabase/functions/ai/provider.ts b/supabase/functions/ai/provider.ts index a49172eaf..5c0f42f7d 100644 --- a/supabase/functions/ai/provider.ts +++ b/supabase/functions/ai/provider.ts @@ -13,6 +13,7 @@ import { } from "../_shared/model-catalog.ts"; import { outputTokenLimit, + REASONING_FLOOR_TOKENS, REPLY_TOKEN_CAPS, type ReplyCap, TRUNCATION_RETRY_FACTOR, @@ -294,14 +295,15 @@ export async function callAI( outputSchema?: JsonOutputSchema, ): Promise { const replyTokens = REPLY_TOKEN_CAPS[cap]; + const reasoningFloor = REASONING_FLOOR_TOKENS[cap] ?? 0; try { - return await callAIOnce(provider, model, systemPrompt, messages, reasoningEffort, replyTokens, signal, stream, outputSchema); + return await callAIOnce(provider, model, systemPrompt, messages, reasoningEffort, replyTokens, signal, stream, outputSchema, reasoningFloor); } catch (err) { if (err instanceof OutputTruncatedError) { console.warn(`[AI] Reply hit its ${cap} cap (${replyTokens} tokens, ${model}, effort ${reasoningEffort}); retrying with ${replyTokens * TRUNCATION_RETRY_FACTOR}`); stream?.reset(); return await retryOnce(err.usage, "truncation_retry", () => - callAIOnce(provider, model, systemPrompt, messages, reasoningEffort, replyTokens * TRUNCATION_RETRY_FACTOR, signal, stream, outputSchema) + callAIOnce(provider, model, systemPrompt, messages, reasoningEffort, replyTokens * TRUNCATION_RETRY_FACTOR, signal, stream, outputSchema, reasoningFloor) ); } if (!(err instanceof InvalidJsonResponseError)) throw attachMissingUsage(err, "call"); @@ -319,6 +321,7 @@ export async function callAI( signal, stream, outputSchema, + reasoningFloor, ) ); } @@ -384,6 +387,7 @@ export async function callAIOnce( signal?: AbortSignal, stream?: GenerationStream, outputSchema?: JsonOutputSchema, + reasoningFloor = 0, ): Promise { if (provider !== OPENROUTER_PROVIDER) throw new Error(`Unsupported provider: ${provider}`); return await callOpenRouter( @@ -394,6 +398,7 @@ export async function callAIOnce( signal, stream, outputSchema, + reasoningFloor, ); } @@ -418,6 +423,7 @@ export async function callOpenRouter( signal?: AbortSignal, stream?: GenerationStream, outputSchema?: JsonOutputSchema, + reasoningFloor = 0, ): Promise { const apiKey = Deno.env.get("OPENROUTER_API_KEY"); if (!apiKey) throw new Error("OPENROUTER_API_KEY missing"); @@ -426,7 +432,7 @@ export async function callOpenRouter( const payload: Record = { model, messages: withPromptCaching(model, messages), - max_tokens: outputTokenLimit(replyTokens, reasoningEffort, modelEntry.maxTokens), + max_tokens: outputTokenLimit(replyTokens, reasoningEffort, modelEntry.maxTokens, reasoningFloor), // With require_parameters, a schema routes only to endpoints that enforce it. response_format: outputSchema ? { type: "json_schema", json_schema: { name: outputSchema.name, strict: true, schema: outputSchema.schema } }