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}` }; } // ============================================================