From a07962812b3b3a1855d6f71d3a22b0d771c2cff5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1borj=C3=A1ni-Szab=C3=B3=20=C3=81d=C3=A1m?= <117768520+Programcode@users.noreply.github.com> Date: Wed, 28 Oct 2026 07:32:30 -0400 Subject: [PATCH 01/38] Generate writes cases the way school debaters prepare them (#244) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * WIP: restructure generated debate cases Co-Authored-By: Claude Opus 5.5 * Test the new case shape, old saved cases and every case export Co-Authored-By: Claude Opus 5.5 * Type the PDF stand-in in the case export test Co-Authored-By: Claude Opus 5.5 * Accept null for the new optional case fields Co-Authored-By: Claude Opus 5.5 * Open the folded Researched sources panel before checking its links Co-Authored-By: Claude Opus 5.5 * R2 backs up the team's arguments instead of adding new ones 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 * Sparring's school-role guidance matches the new case structure in all three languages Co-Authored-By: Claude Opus 5.5 * Hold R2 to the length of Adam's own R2 speech Co-Authored-By: Claude Opus 5.5 * R2 answers each of their 2-3 arguments in full 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 * R2 answers push back first, as Adam's do, and keep to his length Co-Authored-By: Claude Opus 5.5 * R2 prepares a fuller answer to everything they are likely to say 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 * 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 --------- Co-authored-by: Claude Opus 5.5 --- .../features/case-generation.md | 7 +- .../features/live-generation.md | 4 +- .../recipes/case-generation.spec.ts | 77 +++- .../recipes/live-generation.spec.ts | 2 +- agentic/case-generation.e2e.ts | 4 +- e2e.config.ts | 2 +- e2e/fixtures/ai-responses.ts | 17 +- e2e/full-stack/fake-openrouter.mjs | 83 +++- e2e/sparring.spec.ts | 4 +- packages/core/src/ai/case-view.ts | 159 ++++++++ packages/core/src/ai/schemas/generate.ts | 71 +++- packages/core/src/crossfire/prefill.ts | 25 +- .../features/generate/GenerateResults.tsx | 368 ++++++++++-------- .../features/sparring/SparringRound.tsx | 8 +- src/components/features/tools/discuss.ts | 6 +- .../features/tools/live-sections.ts | 41 +- src/components/features/tools/result-text.ts | 42 +- src/lib/ai/features/generate.ts | 39 +- src/lib/export-utils.ts | 170 +++----- src/lib/flowchart.ts | 41 +- src/lib/pdf-export-utils.ts | 111 ++++-- src/locales/en.json | 104 ++--- src/locales/es.json | 104 ++--- src/locales/hu.json | 104 ++--- src/pages/GeneratePage.tsx | 12 +- src/test/helpers/cases.ts | 78 ++++ src/test/i18n-untranslated.test.ts | 4 +- src/test/lib/case-exports.test.ts | 129 ++++++ src/test/lib/case-view.test.ts | 111 ++++++ src/test/lib/flowchart.test.ts | 32 +- src/test/pages/GeneratePage.test.tsx | 2 +- .../_shared/control-enforcement.test.ts | 84 ++++ .../functions/_shared/control-enforcement.ts | 111 +++++- supabase/functions/_shared/debate-context.ts | 13 +- .../functions/_shared/output-caps.test.ts | 14 +- supabase/functions/_shared/output-caps.ts | 15 +- .../functions/_shared/prompts/generate.ts | 165 ++++---- .../_shared/prompts/prompt-examples.test.ts | 2 +- .../_shared/prompts/prompt-examples.ts | 4 +- .../functions/_shared/prompts/sparring.ts | 8 +- supabase/functions/ai/control-matrix.test.ts | 22 +- supabase/functions/ai/features/generate.ts | 9 +- supabase/functions/ai/prompt-builder.test.ts | 7 +- supabase/functions/ai/provider.ts | 12 +- .../functions/export-gdoc/generate_test.ts | 101 +++++ supabase/functions/export-gdoc/index.ts | 212 +++++----- 46 files changed, 1929 insertions(+), 811 deletions(-) create mode 100644 packages/core/src/ai/case-view.ts 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/.claude/skills/verify-debatelab/features/case-generation.md b/.claude/skills/verify-debatelab/features/case-generation.md index 943db2124..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: 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 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,8 +10,9 @@ 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 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 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 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 `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`; 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/features/live-generation.md b/.claude/skills/verify-debatelab/features/live-generation.md index 2defe01ea..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 181585152..5020e69c3 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,73 @@ 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[]; + reinforce?: Array<{ argument: string; text: string }>; + 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); + // 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); + // 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(4); + expect(r2.counters.length).toBeLessThanOrEqual(6); + } else { + 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); @@ -161,8 +228,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"); }); @@ -202,7 +271,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 +283,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 214059c9b..941d84651 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.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/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 4f48206d2..5be51c986 100644 --- a/e2e/full-stack/fake-openrouter.mjs +++ b/e2e/full-stack/fake-openrouter.mjs @@ -51,34 +51,83 @@ 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.", + // The prompt asks for whyItMatters; the Edge Function files it under impact. + whyItMatters: "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.", + "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" } : {}), +}); + +// 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). 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", +}); + +// 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 +// 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"; + // R2: one full answer for each thing they are likely to say. + const answers = length === "brief" ? 5 : 6; 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` + : 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`, }, - counters: [{ - targetArgument: "Funding gap", - counterpoint: "Fare collection itself carries substantial overhead.", - rebuttal: "Net cost is far below headline farebox revenue.", - }], + contentions: conc || r2 ? [] : Array.from({ length: count }, (_, index) => contention(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.` }, + { 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.", + "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: 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" }, + ], metadata: { side: "pro", format: "custom", generatedAt: new Date().toISOString() }, }; }; diff --git a/e2e/sparring.spec.ts b/e2e/sparring.spec.ts index e69bcac41..6c991ba5f 100644 --- a/e2e/sparring.spec.ts +++ b/e2e/sparring.spec.ts @@ -123,13 +123,13 @@ 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(); 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/packages/core/src/ai/case-view.ts b/packages/core/src/ai/case-view.ts new file mode 100644 index 000000000..ecefda310 --- /dev/null +++ b/packages/core/src/ai/case-view.ts @@ -0,0 +1,159 @@ +// 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. 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, Reinforcement } 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[]; + /** R2: the team's earlier arguments, backed up again. */ + reinforce: Reinforcement[]; + 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), + })), + 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); + 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[] | 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(); + 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..10f14c73b 100644 --- a/packages/core/src/ai/schemas/generate.ts +++ b/packages/core/src/ai/schemas/generate.ts @@ -14,8 +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().nullish(), claim: z.string(), reasoning: z.string(), evidence: z.string(), @@ -24,30 +29,69 @@ 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().nullish(), +}); + +/** 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().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(), + claim: z.string(), + url: z.string().nullish(), +}); + export const GenerateOutputSchema = z.object({ + 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. 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), + /** 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(), + sources: z.array(CaseSourceSchema).nullish(), metadata: z.object({ side: z.enum(['pro', 'con']), format: z.string(), @@ -71,3 +115,6 @@ 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; +export type Reinforcement = z.infer; diff --git a/packages/core/src/crossfire/prefill.ts b/packages/core/src/crossfire/prefill.ts index e437eadec..0914ebdce 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,25 @@ 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('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 f8158d8b8..def1dd047 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,182 @@ 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; they show under plain names. */} + {legacy.framework && ( +
+

{t('generateResults.legacyJudgeBy')}

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

{t('generateResults.legacyMustShow')}

+ {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} + {view.arguments.length > 0 && ( + } + onRegenerate={regenerate('contentions')} + {...regenerateState('contentions')} + > +
+ {view.arguments.map((argument, index) => ( + + ))}
-
-

{t('generateResults.keyDefinitions')}

-
    - {result.caseStructure.keyDefinitions?.map((def, i) => ( -
  • {def}
  • - )) ||
  • {t('generateResults.noDefinitions')}
  • } -
+ + )} + + {/* 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} +
+ ))}
-
-
+ + )} - {/* 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.legacySayItLike')} + {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/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 35cc4912e..b2a46c616 100644 --- a/src/components/features/tools/discuss.ts +++ b/src/components/features/tools/discuss.ts @@ -42,7 +42,11 @@ 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.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 e4a428e7c..2f8d63aa5 100644 --- a/src/components/features/tools/live-sections.ts +++ b/src/components/features/tools/live-sections.ts @@ -56,42 +56,39 @@ 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), + // The model writes whyItMatters; the server files it under impact. + ...block(t('generateResults.whyItMatters'), contention.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('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); 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..f6907cf9c 100644 --- a/src/components/features/tools/result-text.ts +++ b/src/components/features/tools/result-text.ts @@ -2,27 +2,39 @@ 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 && `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}`, [ + 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}`); - }); - } + 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})` : ''}`)); + 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..ed992283a 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,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); - if (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 48cbe4cf6..df17fac12 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 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 framework = result.caseStructure.framework?.trim() || 'No framework provided.'; - const burdenOfProof = result.caseStructure.burdenOfProof?.trim() || 'No burden of proof provided.'; + const caseTitle = motion || roadmap.split('.')[0] || roadmap; const sideLabel = result.metadata.side === 'pro' ? i18n.t('export.proposition') : result.metadata.side === 'con' @@ -58,61 +57,49 @@ 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.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 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)] : []), + ]); + + // 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.legacySayItLike'), answer.inRound, answer.source ? 100 : 200)] : []), + ...(answer.source ? [labelled(i18n.t('export.source'), answer.source, 200)] : []), + ]); const doc = new Document({ sections: [ @@ -121,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 }, @@ -140,40 +127,17 @@ 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] : []), + ...(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.legacyAlsoSay'), 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 +516,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..9eb2f695b 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, 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); + Boolean(result?.caseStructure?.thesis && (result.contentions?.length || result.reinforce?.length || result.whyWeWin?.length)); const NODE_GAP_X = 300; const ROW_GAP_Y = 150; @@ -234,30 +235,38 @@ 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 (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[] = []; 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.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}`; 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 +276,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 d319dc883..28601cba0 100644 --- a/src/lib/pdf-export-utils.ts +++ b/src/lib/pdf-export-utils.ts @@ -3,6 +3,7 @@ import i18n from '@/i18n'; import site from '@/config/site.json'; 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'; @@ -342,7 +343,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 @@ -375,36 +376,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) { - addSubsectionLabel(i18n.t('export.framework')); - addParagraph(result.caseStructure.framework, 2); + // 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.untitledCase'), 2); + if (view.legacy.framework) { + addSubsectionLabel(i18n.t('export.legacyJudgeBy')); + addParagraph(view.legacy.framework, 2); } - if (result.caseStructure?.burdenOfProof) { - addSubsectionLabel(i18n.t('export.burdenOfProof')); - addParagraph(result.caseStructure.burdenOfProof, 2); + if (view.legacy.burdenOfProof) { + addSubsectionLabel(i18n.t('export.legacyMustShow')); + addParagraph(view.legacy.burdenOfProof, 2); + } + if (view.legacy.definitions.length) { + addSubsectionLabel(i18n.t('export.legacyDefinitions')); + 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 @@ -415,33 +414,61 @@ 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); + // 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); } - // 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); + // 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); + } + + // 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.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.legacyAlsoSay')); + 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 b06a60c40..a440db58c 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", @@ -1126,7 +1102,36 @@ "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", + "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", @@ -1872,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", @@ -1913,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", @@ -1944,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", @@ -1981,7 +1966,26 @@ "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", + "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", @@ -2028,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 570d75bc9..dc9896c4f 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", @@ -1126,7 +1102,36 @@ "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", + "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", @@ -1872,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", @@ -1913,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", @@ -1944,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", @@ -1981,7 +1966,26 @@ "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", + "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", @@ -2028,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 dd30fbab1..c2eb33851 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", @@ -1126,7 +1102,36 @@ "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", + "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", @@ -1872,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", @@ -1913,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", @@ -1944,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", @@ -1981,7 +1966,26 @@ "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", + "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", @@ -2028,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", diff --git a/src/pages/GeneratePage.tsx b/src/pages/GeneratePage.tsx index 765958ab8..4fec501e4 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,14 @@ 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 === 'reinforce' ? { reinforce: next.reinforce } : {}), + ...(section === 'whyWeWin' ? { whyWeWin: next.whyWeWin } : {}), + ...(section === 'closing' ? { closingLine: next.closingLine } : {}), + sources: mergeCaseSources(base.sources, next.sources), metadata: next.metadata, }; setGenerateResult(regenerated); diff --git a/src/test/helpers/cases.ts b/src/test/helpers/cases.ts new file mode 100644 index 000000000..86bec9b43 --- /dev/null +++ b/src/test/helpers/cases.ts @@ -0,0 +1,78 @@ +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' }, +}; + +// 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 new file mode 100644 index 000000000..54133fb7a --- /dev/null +++ b/src/test/lib/case-exports.test.ts @@ -0,0 +1,129 @@ +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, 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. +const drawn = vi.hoisted(() => [] as string[]); + +vi.mock('jspdf', () => ({ + jsPDF: function FakePdf(): object { + const doc: Record = { + 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): unknown => (key in target ? Reflect.get(target, key) : (): undefined => 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 { + 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'); + 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 { + 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(OLD_LABELS); + 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); + // 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 () => { + 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..61edfee71 --- /dev/null +++ b/src/test/lib/case-view.test.ts @@ -0,0 +1,111 @@ +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, R2_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('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); + 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('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); + 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('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('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 21b4b0ad5..d9727315c 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,34 @@ 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']); + + // 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([ + 'Cities should ban cars downtown', 'They never answered air quality', 'Shops did fine', + ]); + }); }); 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 42cffb596..49dc207cb 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,89 @@ 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("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: [ + { 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/control-enforcement.ts b/supabase/functions/_shared/control-enforcement.ts index 3987c620d..c0380d2df 100644 --- a/supabase/functions/_shared/control-enforcement.ts +++ b/supabase/functions/_shared/control-enforcement.ts @@ -151,21 +151,83 @@ export function enforceGenerateControls(output: unknown, side: string, format: s } const generateOutput = output as { exordio?: unknown; + opening?: unknown; 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)) { 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 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 : []; + // 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 < 4) { + 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) + ? 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 +236,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..030d09424 100644 --- a/supabase/functions/_shared/debate-context.ts +++ b/supabase/functions/_shared/debate-context.ts @@ -179,13 +179,16 @@ 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([ - `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.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/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 fb5f79b65..4289c782c 100644 --- a/supabase/functions/_shared/prompts/generate.ts +++ b/supabase/functions/_shared/prompts/generate.ts @@ -7,51 +7,56 @@ 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 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) --- -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 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) --- -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. +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, 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: + 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. Not every "If they say..." block is spoken, so the written answers can run longer than the four minutes. --- 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 +64,46 @@ 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." + "whyItMatters": "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." } ], + "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": [ + { "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 +111,55 @@ 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 items it names: contentions, reinforce and counters items for R2, or whyWeWin items for the Conclusor. 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. +- 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. -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. 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. -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 === "r2" + ? ({ + 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.", + 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 +168,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/_shared/prompts/prompt-examples.test.ts b/supabase/functions/_shared/prompts/prompt-examples.test.ts index 0d8bff0b3..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 CONTENTIONS", "weigh it", "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/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/_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 db941a48e..5bbc5aff4 100644 --- a/supabase/functions/ai/control-matrix.test.ts +++ b/supabase/functions/ai/control-matrix.test.ts @@ -27,12 +27,32 @@ 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: 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); - assert((output.contentions as unknown[]).length === targets[length], `generate.${length} output count`); + 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 === (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. + 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(!("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/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/ai/prompt-builder.test.ts b/supabase/functions/ai/prompt-builder.test.ts index 8babfa8fe..941b01e5d 100644 --- a/supabase/functions/ai/prompt-builder.test.ts +++ b/supabase/functions/ai/prompt-builder.test.ts @@ -86,7 +86,12 @@ 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` + : role === "r2" + // 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"); } 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 } } diff --git a/supabase/functions/export-gdoc/generate_test.ts b/supabase/functions/export-gdoc/generate_test.ts new file mode 100644 index 000000000..5666004c8 --- /dev/null +++ b/supabase/functions/export-gdoc/generate_test.ts @@ -0,0 +1,101 @@ +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}`); +} + +// 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}`); +} + +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, OLD_LABELS); + assertExcludes(text, /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.", "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", () => { + 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/); +}); + +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 4f3206de2..24df74841 100644 --- a/supabase/functions/export-gdoc/index.ts +++ b/supabase/functions/export-gdoc/index.ts @@ -294,26 +294,43 @@ 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 }[]; + reinforce?: { argument?: string; text?: 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 (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 = data.caseStructure?.thesis?.trim() || '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 @@ -323,109 +340,102 @@ 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(`${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('What we mean by the motion', 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); + // 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`)); }); } - // 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); + // 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`))); + } - 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`)); - } - }); + // 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('Say it like this', rebuttal); + labelled('Source', present(c?.source)); + }); + if (likelyArgs.length) { + applySubHeader(insertText('They may also say\n')); + bullets(likelyArgs); + } + } + + 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}` }; + return { requests, title: `Debate Case: ${caseTitle}` }; } // ============================================================ From a04c15680b3a41a104fceb220f5fe0cf6ad546af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1borj=C3=A1ni-Szab=C3=B3=20=C3=81d=C3=A1m?= <117768520+Programcode@users.noreply.github.com> Date: Wed, 28 Oct 2026 07:48:07 -0400 Subject: [PATCH 02/38] Describe the new case format on the public guides (#252) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Describe the new case format on the public guides The guides still described the old case: thesis, framework, burden of proof, contentions with warrants and impacts, and a predicted opponent case. Generate now writes an opening, arguments (point, explanation, evidence, why it matters), "If they say… we answer…" replies, a closing line and sources (#244). The copy changes in seo_guides.py, seo_answers.py and the PAGES meta in generate_seo_content.py, and the pages and llms.txt are regenerated from them. The LD, PF, Policy and weighing format guides are unchanged. Co-Authored-By: Claude Opus 5.5 * Match the case guide to the shipped R2: no new arguments, five or six If they say blocks --------- Co-authored-by: Claude Opus 5.5 --- public/ai-debate-case-generator/index.html | 22 +- public/ai-debate-coach/index.html | 20 +- public/ai-debate-practice/index.html | 14 +- public/ai-rebuttal-generator/index.html | 8 +- public/argument-flowchart/index.html | 20 +- public/best-ai-debate-tools/index.html | 8 +- public/debate-evidence-finder/index.html | 14 +- public/debate-rebuttal-practice/index.html | 16 +- public/debate-topics-for-students/index.html | 8 +- public/debateowl-vs-chatgpt/index.html | 10 +- public/debateowl-vs-debateclub/index.html | 8 +- public/debateowl-vs-windebate/index.html | 8 +- public/how-to-practice-rebuttals/index.html | 8 +- public/how-to-write-a-debate-case/index.html | 20 +- public/llms.txt | 14 +- scripts/generate_seo_content.py | 14 +- scripts/seo_answers.py | 10 +- scripts/seo_guides.py | 229 ++++++++++--------- 18 files changed, 228 insertions(+), 223 deletions(-) diff --git a/public/ai-debate-case-generator/index.html b/public/ai-debate-case-generator/index.html index b02ed87e1..440a48a0e 100644 --- a/public/ai-debate-case-generator/index.html +++ b/public/ai-debate-case-generator/index.html @@ -4,7 +4,7 @@ AI Debate Case Generator with Citations | DebateOwl - + @@ -16,21 +16,21 @@ - + - + - + - +
@@ -39,12 +39,12 @@
-
Tool guide

AI Debate Case Generator with Citations

Draft faster, verify carefully, and walk into the round with a structured case.

Published · Updated

By the DebateOwl team

-

Can an AI debate case generator write a case I can actually use?

Yes, as a draft. DebateOwl's case generator turns a motion into a structured case (thesis, framework, contentions with warrants and impacts, the likely opposing case and counters) and lists the web sources it used, which you then verify and rewrite in your own words. Treat its citations as research leads: open every source and confirm the exact claim. The judgement calls, such as which contention to cut and where to pre-empt the opposition, stay with you.

-
What is an AI debate case generator?
A tool that drafts a debate case for a given motion and side, with claims, warrants, impacts and evidence leads, for a debater to verify and adapt.
-

Structured case drafts

Each draft has a thesis, a framework, contentions with warrants and impacts, the case the other side is likely to run, and counters to it.

Evidence leads

Every draft is researched on the web and lists the sources it found. Check each one against the original before it goes on a card.

Roles and length

Pick your side and speaker role, then set the case length, the audience level and the tone of the draft.

-

What the case generator produces

Generate takes the motion from your Current Debate and returns a structured case, not a block of prose. Every draft has the same parts, so you always know where to look:

  • Thesis: the one-sentence reason your side should win.
  • Framework: how the judge should decide the round, such as the definition, the burden or the weighing standard you will argue for.
  • Contentions: each with a claim, the warrant that makes it true, and the impact that makes it matter.
  • Predicted opponent case: the arguments a strong team on the other side is likely to run.
  • Counters: first responses to that predicted case.

Because the opponent case is part of the draft, you start preparing rebuttals at the same time as your own case. When one section is weak, regenerate just that section; the rest of the case stays where it is. Finished cases export to PDF or DOCX.

Generated draft vs. finished case: what you still do

What the case generator gives you, and what you check before the round
SectionThe generator givesYou check
ThesisOne sentence on why your side should winThat it answers the motion exactly as worded and you can say it without notes
FrameworkThe definition, burden or weighing standard to argue forThat it fits your format, for example a value and criterion in Lincoln-Douglas
ContentionsA claim, the warrant that makes it true and the impact that makes it matterWhich ones clash with the other side; cut the rest to fit your speech time
Predicted opponent caseThe arguments a strong team on the other side is likely to runWhich of your contentions answers each one
CountersFirst responses to the predicted caseThat each response is short enough to deliver as a rebuttal block
SourcesThe web sources it found, with contentions that still need evidence marked Needs SourceOpen every original, confirm the exact claim and record the full citation

From motion to verified case in seven steps

  1. Start a Current Debate with the exact motion wording. Small wording changes, such as should against would, change the burden.
  2. Add the sources you already trust to the Library. Active sources are used when the case is generated, so the draft builds on your research instead of ignoring it.
  3. Choose your side and speaker role: introductor, R1, R2 or conclusor. Under More options, set the length (two, three or four contentions), the audience level and the tone. If you need a particular angle, for example a principled argument or an economic one, put it in the Current Debate's Instructions.
  4. Generate the case. Generate researches the motion on the web every time, lists the sources it found under the draft, and saves the relevant ones to the debate's Library.
  5. Read the predicted opponent case first and decide which of your contentions actually answers it. Cut contentions that do not clash with anything.
  6. Regenerate any section that is vague, then verify every evidence lead against its original source.
  7. Rewrite the final case in your own words and time a full reading of it before you call it ready.

Worked example: a generated case, and what the debater changed

Motion: This house would introduce a four-day school week

Side: Proposition.

Thesis: A shorter week concentrates instruction where it works and gives schools a recruitment advantage they cannot buy with pay.

Framework: The round should be decided by learning outcomes for students across the school, not by the convenience of any single group.

Contention 1: Teacher retention. Claim: a four-day week keeps experienced teachers. Warrant: schools competing for staff cannot always raise salaries, but they can offer time. Impact: experienced teachers are among the strongest in-school drivers of student learning.

Predicted opposition: Childcare costs for working parents on the fifth day, and lost instruction time for students who are already behind.

Counter: Schools can run an optional fifth-day programme for families who need it, and longer remaining days protect total instruction hours.

Evidence lead to verify: Research on districts that moved to four-day weeks, looking at attendance, teacher turnover and test results. Read the methodology: results differ between rural and urban districts.

What the debater changed: Cut a weaker cost-saving contention the opposition would easily mitigate, and moved the instruction-hours counter into the first speech so it was on the table before the opposition raised it.

The draft gave the debater a full structure in minutes. The judgement calls, which contention to cut and where to pre-empt the opposition, were still theirs, and that is where the round was won.

Checking citations before they reach a card

Generated evidence is a starting point. Before any source goes into a speech:

  • Open the original. If you cannot find it, do not use it.
  • Find the exact sentence that supports your claim, and check that the surrounding paragraph does not qualify it.
  • Check the date, the author and who published it. A think-tank brief and a peer-reviewed study carry different weight.
  • Make sure the evidence supports your link, not just your topic. A study showing four-day weeks save money does not prove they improve learning.
  • Record the full citation so you can produce it if an opponent or judge asks.

Factoids is useful here: give it a single statement from your case and it researches that statement and suggests searches you can use to confirm it.

Adapting a generated case to your format

World Schools (WSDC)

A WSDC case is split between the first and second speakers, with a team line both can repeat. Use the thesis as the team line, give the first speaker the framework and the first contention, and keep the strongest contention for the second speaker so the case builds rather than front-loads. Leave the Current Debate's format on Custom and describe this split in its Instructions.

British Parliamentary (BP)

Set the Current Debate's format to British Parliamentary and name your team position in its Instructions; Generate's own role options follow the School 4-6-6-3 speakers. Opening government needs a clear definition and mechanism in the draft. Closing teams should treat the generated case as material the opening half will probably use, and look in the predicted opponent case for an extension nobody else in the room is likely to run.

Public Forum (PF)

A PF constructive is four minutes, which usually means two contentions with evidence, not four. Since PF teams often find out their side shortly before the round, generate both sides of the resolution in the same debate and prepare the counters for each.

Lincoln-Douglas (LD)

In LD, the framework section becomes your value and criterion. Check that every contention explicitly links back to the criterion; if one does not, add your criterion to the Current Debate's Instructions and regenerate the contentions. Prepare a short defence of the criterion itself, because the negative will often attack it first.

Mistakes to avoid with generated cases

  • Reading the draft in round. Judges hear the difference between a case you wrote and one you are reading for the first time.
  • Keeping every contention. Three well-defended points beat five thin ones.
  • Skipping the predicted opponent case. It is the fastest route to your rebuttals.
  • Citing an evidence lead you never opened.
-

Frequently asked questions

Does the case generator write a complete case?

It writes a complete draft: thesis, framework, contentions with warrants and impacts, the likely opposing case and counters. Treat it as a structure to cut, verify and rewrite in your own words.

Are the generated citations reliable?

They are research leads. Every draft is researched on the web and lists the sources it found, and a contention that still needs evidence is marked Needs Source. Open every source, confirm the exact claim and record the full citation before using it.

Which debate formats does the generator support?

Set the Current Debate's format to British Parliamentary, Policy, Classroom or Custom, and Generate reads it along with the debate's Instructions. Generate's own speaker roles (introductor, R1, R2 and conclusor) follow the School 4-6-6-3 format. For WSDC, Public Forum and Lincoln-Douglas, use Custom and describe the structure in the Instructions.

Can I change one part of a case without starting over?

Yes. Regenerate a single section and the rest of the case stays on screen unchanged.

Can I export a generated case?

Yes. Cases export to PDF and DOCX, and cases created in a Current Debate are saved to it automatically.

Is there a free AI debate case generator?

DebateOwl's case generator is included in the 3-day free trial, with no credit card, and then in Pro at $5/month + tax; there is no permanent free plan. Without an account you can use the free debate timer, motion generator, flow sheet and speech time calculator, but not the case generator.

Can AI write a Public Forum case?

It can draft one. Set the Current Debate's format to Custom and describe the Public Forum structure in its Instructions, since Generate's own speaker roles follow School 4-6-6-3. A PF constructive is four minutes, which usually fits two contentions with evidence, so cut the draft to that and verify every source.

Is using an AI case generator cheating?

Using it to draft and research before a round is preparation, but check your league's and your school's rules first: most leagues forbid electronic help during a round. Verify every source, rewrite the case in your own words and make sure you can defend each contention, because a case you cannot explain is easy to beat.

+
Tool guide

AI Debate Case Generator with Citations

Draft faster, verify carefully, and walk into the round with a case you can read out.

Published · Updated

By the DebateOwl team

+

Can an AI debate case generator write a case I can actually use?

Yes, as a draft. DebateOwl's case generator turns a motion into a ready-to-read case: an opening, arguments with explanation, evidence and why it matters, "If they say… we answer…" replies, a closing line and the sources it used. You then verify it and rewrite it in your own words. Treat its citations as research leads: open every source and confirm the exact claim. The judgement calls, such as which argument to cut and where to answer the opposition early, stay with you.

+
What is an AI debate case generator?
A tool that turns a motion into a draft debate case for one side and speaker role, with an opening, arguments backed by evidence, answers to what the other side will say and a source list, for a debater to verify and adapt.
+

A case you can read out

Each draft has an opening, arguments with an explanation, evidence and why it matters, "If they say… we answer…" replies, a closing line and the sources it cites.

Evidence leads

Every draft is researched on the web, names the source and year in each piece of evidence and lists its sources at the end. Check each one against the original before it goes on a card.

Roles and length

Pick your side and speaker role, then set the case length, the audience level and the tone of the draft.

+

What the case generator produces

Generate takes the motion from your Current Debate and returns a case you can read out, not a block of prose. Every draft has the same parts, in the order you would say them:

  • Opening: a hook that makes the judge listen, then the line "Today we will show N arguments that prove…".
  • Arguments: each with a short title, the point in one sentence, an explanation of why it is true, evidence that names its source and year, and why it matters.
  • If they say…: the claims the other side is likely to make, each with our answer and, when there is one, the source behind it.
  • Closing line: one sentence that ties back to the opening.
  • Sources: every source the case cites, with the organisation, the year, the claim it supports and the link.

Your speaker role shapes the speech. The introductor gets the opening and short headline arguments, plus one line on what a motion term means when it is genuinely unclear. R1 gets the full arguments. R2 brings no new arguments: it spends about two minutes backing up the team's arguments, then answers the other side with five or six "If they say…" blocks. The conclusor brings no new arguments: it gets two or three reasons why your side won and the closing line.

Because the answers to the other side are part of the draft, you start preparing rebuttals at the same time as your own case. When one section is weak, regenerate just that section; the rest of the case stays where it is. Finished cases export to PDF or DOCX.

Generated draft vs. finished case: what you still do

What the case generator gives you, and what you check before the round
SectionThe generator givesYou check
OpeningA hook and the line "Today we will show N arguments that prove…"That the hook is true, the line answers the motion exactly as worded and you can say it without notes
ArgumentsA title, the point, an explanation, evidence and why it mattersWhich ones meet what the other side will say; cut the rest to fit your speech time
If they say…The other side's likely claims, each with our answerThat each answer meets the claim as they would make it and is short enough to say in a rebuttal
Closing lineOne sentence that ties back to the openingThat it sums up the case in words you would actually say
SourcesEvery source the case cites, with arguments that still need evidence marked Needs SourceOpen every original, confirm the exact claim and record the full citation

From motion to verified case in seven steps

  1. Start a Current Debate with the exact motion wording. Small wording changes, such as should against would, change the burden.
  2. Add the sources you already trust to the Library. Active sources are used when the case is generated, so the draft builds on your research instead of ignoring it.
  3. Choose your side and speaker role: introductor, R1, R2 or conclusor. Under More options, set the length, the audience level and the tone. Length sets two, three or four arguments for the introductor and R1, and for R2 how many "If they say…" answers you get. If you need a particular angle, for example a principled argument or an economic one, put it in the Current Debate's Instructions.
  4. Generate the case. Generate researches the motion on the web every time, lists the sources it found under the draft, and saves the relevant ones to the debate's Library.
  5. Read the "If they say…" answers first and decide which of your arguments actually meets each claim. Cut arguments that do not clash with anything.
  6. Regenerate any section that is vague, then verify every evidence lead against its original source.
  7. Rewrite the final case in your own words and time a full reading of it before you call it ready.

Worked example: a generated case, and what the debater changed

Motion: This house would introduce a four-day school week

Side and role: Proposition, R1.

Opening: A school that cannot pay more can still offer its teachers time. Today we will show two arguments that prove a four-day week helps students learn.

Argument 1, keeping teachers: Point: a four-day week keeps experienced teachers. Explanation: schools competing for staff cannot always raise salaries, but they can offer time. Why it matters: experienced teachers are among the strongest in-school drivers of student learning.

If they say: Working parents will pay for childcare on the fifth day, and students who are already behind lose teaching time.

We answer: Schools can run an optional fifth-day programme for families who need it, and longer remaining days protect total teaching hours.

Closing line: That is why a four-day week is the cheapest way to keep the teachers students learn most from.

Evidence lead to verify: Research on districts that moved to four-day weeks, looking at attendance, teacher turnover and test results. Read the methodology: results differ between rural and urban districts.

What the debater changed: Cut a weaker cost-saving argument the opposition would easily answer, and moved the teaching-hours answer into the first speech so it was on the table before the opposition raised it.

The draft gave the debater a full structure in minutes. The judgement calls, which argument to cut and where to answer the opposition early, were still theirs, and that is where the round was won.

Checking citations before they reach a card

Generated evidence is a starting point. Before any source goes into a speech:

  • Open the original. If you cannot find it, do not use it.
  • Find the exact sentence that supports your claim, and check that the surrounding paragraph does not qualify it.
  • Check the date, the author and who published it. A think-tank brief and a peer-reviewed study carry different weight.
  • Make sure the evidence supports your point, not just your topic. A study showing four-day weeks save money does not prove they improve learning.
  • Record the full citation so you can produce it if an opponent or judge asks.

Factoids is useful here: give it a single statement from your case and it researches that statement and suggests searches you can use to confirm it.

Adapting a generated case to your format

World Schools (WSDC)

A WSDC case is split between the first and second speakers, with a team line both can repeat. Use the opening's "we will show" line as the team line, give the first speaker the opening and the first argument, and keep the strongest argument for the second speaker so the case builds rather than front-loads. Leave the Current Debate's format on Custom and describe this split in its Instructions.

British Parliamentary (BP)

Set the Current Debate's format to British Parliamentary and name your team position in its Instructions; Generate's own role options follow the School 4-6-6-3 speakers. Opening government needs a clear definition and mechanism, so describe them in the Instructions if the draft does not give them. Closing teams should treat the generated case as material the opening half will probably use, and look in the "If they say…" answers for an extension nobody else in the room is likely to run.

Public Forum (PF)

A PF constructive is four minutes, which usually means two arguments with evidence, not four. Since PF teams often find out their side shortly before the round, generate both sides of the resolution in the same debate and prepare the answers for each.

Lincoln-Douglas (LD)

Generate does not write a value and criterion for you, so put yours in the Current Debate's Instructions before you generate. Then check that each argument's why it matters links back to the criterion; if one does not, regenerate the arguments. Prepare a short defence of the criterion itself, because the negative will often attack it first.

Mistakes to avoid with generated cases

  • Reading the draft in round. Judges hear the difference between a case you wrote and one you are reading for the first time.
  • Keeping every argument. Three well-defended points beat five thin ones.
  • Skipping the "If they say…" answers. They are the fastest route to your rebuttals.
  • Citing an evidence lead you never opened.
+

Frequently asked questions

Does the case generator write a complete case?

It writes a complete draft: an opening, arguments with explanation, evidence and why each matters, "If they say… we answer…" replies, a closing line and the sources it cites. Treat it as a structure to cut, verify and rewrite in your own words.

Are the generated citations reliable?

They are research leads. Every draft is researched on the web and lists the sources it cites, and an argument that still needs evidence is marked Needs Source. Open every source, confirm the exact claim and record the full citation before using it.

Which debate formats does the generator support?

Set the Current Debate's format to British Parliamentary, Policy, Classroom or Custom, and Generate reads it along with the debate's Instructions. Generate's own speaker roles (introductor, R1, R2 and conclusor) follow the School 4-6-6-3 format. For WSDC, Public Forum and Lincoln-Douglas, use Custom and describe the structure in the Instructions.

Can I change one part of a case without starting over?

Yes. Regenerate a single section and the rest of the case stays on screen unchanged.

Can I export a generated case?

Yes. Cases export to PDF and DOCX, and cases created in a Current Debate are saved to it automatically.

Is there a free AI debate case generator?

DebateOwl's case generator is included in the 3-day free trial, with no credit card, and then in Pro at $5/month + tax; there is no permanent free plan. Without an account you can use the free debate timer, motion generator, flow sheet and speech time calculator, but not the case generator.

Can AI write a Public Forum case?

It can draft one. Set the Current Debate's format to Custom and describe the Public Forum structure in its Instructions, since Generate's own speaker roles follow School 4-6-6-3. A PF constructive is four minutes, which usually fits two arguments with evidence, so cut the draft to that and verify every source.

Is using an AI case generator cheating?

Using it to draft and research before a round is preparation, but check your league's and your school's rules first: most leagues forbid electronic help during a round. Verify every source, rewrite the case in your own words and make sure you can defend each argument, because a case you cannot explain is easy to beat.