Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions .claude/skills/verify-debatelab/features/case-generation.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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 <section>…`; 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)

Expand All @@ -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.

Expand Down
4 changes: 2 additions & 2 deletions .claude/skills/verify-debatelab/features/live-generation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand All @@ -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.
Expand Down
77 changes: 73 additions & 4 deletions .claude/skills/verify-debatelab/recipes/case-generation.spec.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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);
Expand All @@ -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");
});
Expand Down Expand Up @@ -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");
Expand All @@ -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");
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
4 changes: 2 additions & 2 deletions agentic/case-generation.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.`);
Expand All @@ -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.");
});
2 changes: 1 addition & 1 deletion e2e.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: <motion>\".",
"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.",
Expand Down
17 changes: 6 additions & 11 deletions e2e/fixtures/ai-responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() },
});

Expand Down
Loading
Loading