Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
a079628
Generate writes cases the way school debaters prepare them (#244)
Programcode Oct 28, 2026
a04c156
Describe the new case format on the public guides (#252)
Programcode Oct 28, 2026
3409a12
Staging sync checks the project by ref, not its display name (#255)
Programcode Oct 28, 2026
cb37e2d
Ask reads a saved case's R2 and Conclusor paragraphs in full (#256)
Programcode Oct 28, 2026
a314898
Merge origin/main: the new case format reaches the app
Programcode Oct 28, 2026
cd49409
iPad sidebar lists every tool, grouped and collapsible
Programcode Oct 28, 2026
f43bf73
Ollie's welcome, the first-run tour, the trial banner and the expired…
Programcode Oct 28, 2026
b039b31
Accessibility pass: Done bars, finished-answer announcements, heading…
Programcode Oct 28, 2026
7cb539c
Tabs, tool forms, documents and the Flowchart scroll fields clear of …
Programcode Oct 28, 2026
ea9dcef
Share links, universal-link routing and Word export in the app
Programcode Oct 28, 2026
636d6e7
Current Debate widget, quick actions and Spotlight, for the developme…
Programcode Oct 28, 2026
63fa756
Offline queue for notes, source edits and Active switches
Programcode Oct 28, 2026
fe1ed61
Describe round 4 in the app's guide
Programcode Oct 28, 2026
abde38a
Spotlight indexes the Current Debate's cases and is cleared on sign-out
Programcode Oct 28, 2026
9e2ceb4
Screenshots show a case in the new format
Programcode Oct 28, 2026
9fb89db
Expired-session notice is decided where the session is removed
Programcode Oct 28, 2026
6dd45a0
Offline queue: a lost insert answer keeps later changes; a refused ed…
Programcode Oct 28, 2026
f136019
A shared link with unreadable data shows the fallback instead of load…
Programcode Oct 28, 2026
27ab8f5
A linked screen opens above the tabs, so Back and the sidebar have them
Programcode Oct 28, 2026
e946150
A refused offline change leaves no saved copy behind; a syncing note …
Programcode Oct 28, 2026
45c8d9f
A refused change reads the server's version back; deleting the open s…
Programcode Oct 28, 2026
01252ea
Screenshots wait only for visible loading indicators
Programcode Oct 28, 2026
e913899
Ollie comes alive, buttons spring, success pops: the motion primitives
Programcode Oct 28, 2026
0188eae
Sidebar sections spring open, live drafts pop in, working owls blink
Programcode Oct 28, 2026
ede60ce
Offline queue: a refused change is undone with no network; a synced n…
Programcode Oct 28, 2026
e6f27ab
Lists load as shimmering skeletons, rows arrive staggered and press in
Programcode Oct 28, 2026
b163bb0
A celebration when a round, a crossfire or a case is done
Programcode Oct 28, 2026
958f143
Celebrations cover the screen fully; screenshots expect the new case …
Programcode Oct 28, 2026
db35dab
Reduce Motion follows the setting live; the queue's undo is reliable
Programcode Oct 28, 2026
9f1bd57
Closed sidebar sections never flash open; preview menus sit under the…
Programcode Oct 28, 2026
cf19d65
Screenshot fake Library keeps edits and switches
Programcode Oct 28, 2026
0c22ab7
Layout animations follow Reduce Motion live; a recovered note keeps i…
Programcode Oct 28, 2026
97d33b4
Ollie greets an empty Sparring list; a judged round shows the mint check
Programcode Oct 28, 2026
deb28ba
Clear all drops queued notes; recovered notes keep the Library offlin…
Programcode Oct 28, 2026
48dd067
Clear all keeps queued work if the clear fails; case arguments keyed …
Programcode Oct 28, 2026
df0db7d
Queued means saved on the phone; an unreadable queue is never overwri…
Programcode Oct 28, 2026
fd3cf3d
A queued change that cannot be stored stays queued; nothing writes be…
Programcode Oct 28, 2026
4638ab5
While the phone cannot store the queue, nothing is sent or added; an …
Programcode Oct 28, 2026
346d6a2
A save that never ran for its account is not reported as kept
Programcode Oct 29, 2026
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.");
});
Loading
Loading