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
11 changes: 10 additions & 1 deletion .claude/skills/verify-debatelab/features/library-sources.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,15 @@ Users save reference texts to their Library. The Library belongs to the Current

## Sub-features

- `lib-add` adds a source with a title and content. It appears with its toggle on, and the banner `Grounding Active` shows.
- `lib-add` adds a source with a title and content. It shows at once while it saves, then a toast `Saved <title> to the Library`; its toggle is on, and the banner `Grounding Active` shows.
- `lib-toggle` pauses a source through its switch, which keeps it out of prompts.
- `lib-offline` offline, the switch does not move and a toast says `You are offline. Reconnect and try again.` A write that fails online puts the switch back with a toast.
- `lib-edit-while-switching` the source editor's untouched fields follow the stored source while it is open (a switch that rolls back shows as rolled back), and `Save` sends only the fields edited in the sheet. Text typed while a save runs stays an edit and goes out with the next `Save`.
- `lib-dedupe` keeps one row per web page per debate. URLs match after normalizing (host without `www.`, no scheme, fragment, tracking parameters or trailing slash). Importing a page the debate has warns `This page is already in this debate's Library.` before any fetch; research refreshes the existing row instead of adding one.
- `lib-layout` on a phone the row's controls take their own line, so a long title gets the row's width and two lines.
- `lib-ground` includes only active sources' text in the `system` of later AI calls (Generate, Brainstorm, and others).
- `lib-delete` deletes a source. It stays gone after a reload and in the `sources` table.
- `lib-counts` the Current Debate summary on `/app` shows `<total> sources (<active> active)`, and Ask's `Using <n> sources` counts the same active sources.
- `lib-import` imports a web page by URL through the `import-source` Edge Function, which fetches the page itself (no Exa), so it works in both modes when the VPS has outbound internet.
- `lib-erase` has Settings → `Delete All My Data` remove this account's sources and nobody else's.

Expand All @@ -24,8 +29,12 @@ Preconditions:

- **Add.** Click the button named `Add Source`. Fill the textbox named `Title` (exact), fill the textbox named `Content` (exact) with a unique marker, and click `Save to Library`. Leaving either empty shows an inline error under the field and moves focus to it. The switch named `Toggle <title>` is checked. `sources` has a row with `is_active: true` and the debate's `debate_id`.
- **Toggle.** Click the switch named `Toggle <title>`. It becomes not checked.
- **Offline toggle.** `page.context().setOffline(true)`, click the switch: the offline toast shows, the switch stays checked, and the row's `is_active` is unchanged. Still offline, open the source, change its `Content` and click `Save`: the toast `Could not save this source.` shows and the typed text stays in the sheet. Set offline back to false and click `Save` again: `Source updated` shows and the row has the new content with `is_active` unchanged.
- **Duplicate import.** Insert a URL source through `admin`, then import the same page with `?utm_source=x#top`: the duplicate warning shows and no `import-source` request is made.
- **Phone layout.** At a 360px viewport the title button is wider than 200px and the page has no sideways scroll.
- **Ground.** Go to `/app/brainstorm`; the motion is prefilled from the debate. Run it (see [ai-tools](./ai-tools.md)). `providerCall("brainstorm").system` contains the active marker and does not contain the paused marker.
- **Delete.** Click the button named `Delete <title>` (use `exact`). It deletes at once, with no confirmation, and a toast `Deleted <title>` offers `Undo`, which saves the source again. The switch count drops to 0, and stays 0 after `page.reload()`. `sources` no longer has the title.
- **Counts.** Insert 7 URL sources through `admin`, 5 with `is_active: true`. `/app` test id `home-current-debate` contains `7 sources (5 active)`; on `/app/ask` test id `debate-context-control` contains `Using 5 sources`.
- **Import.** In the add panel, fill `Import from URL` and click `Import URL`. The `Import preview` panel appears, then `Save imported source` stores a row whose `origin` is the URL.
- **Erase.** On Settings, click `Delete All My Data`. In the dialog, fill `Type your email to confirm` with `user.email` and click the dialog's `Delete All My Data`. The text `Data Deleted` appears, and `sources` for the user is `[]`.

Expand Down
1 change: 1 addition & 0 deletions .claude/skills/verify-debatelab/features/practice.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ Live practice modes. Sparring runs a multi-speech round against an AI opponent a
- `cross-turn` answers a user question with an AI response and attaches a Jev `Direct answer` signal.
- `cross-debrief` ends the session and shows the debrief. A `crossfires` row is filed in the debate for its resolution.
- `cross-ai-questions` reverses the roles: the AI asks (`mode-ai-questions-user`), the user answers, and the AI follows up.
- `cross-history` after leaving the page, `/app/history?tab=crossfire` lists the round (test id `history-crossfire-row`). `Open Crossfire round: <resolution>` shows test id `saved-crossfire` with the debrief (`saved-crossfire-debrief`) and transcript (`saved-crossfire-transcript`); `Delete Crossfire round: <resolution>` asks first and removes the row.
- `cross-daily-limit` with no AI requests left, the first AI turn is refused: the daily-limit dialog opens and the page stays in setup with no clock and no `end-crossfire`.
- `cross-end-early` ending a round before any exchange returns to setup with no error.
- `spar-daily-limit` Sparring setup warns (test id `sparring-request-shortfall`) when today's remaining requests are fewer than the round needs.
Expand Down
125 changes: 125 additions & 0 deletions .claude/skills/verify-debatelab/recipes/library.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ test("lib-add, lib-toggle, lib-ground, lib-delete: only active sources ground AI
await addSource(page, "Paused study", "PAUSED-SOURCE-MARKER congestion did not change.");
await page.getByRole("switch", { name: "Toggle Paused study" }).click();
await expect(page.getByRole("switch", { name: "Toggle Paused study" })).not.toBeChecked();
// The switch moves before its write lands; leaving the page first could cancel the write.
await expect.poll(async () => (await admin.from("sources").select("is_active").eq("user_id", await user.userId()).eq("title", "Paused study")).data)
.toEqual([{ is_active: false }]);
await evidence.step("Added two sources and paused one");
await evidence.shot("01-library");

Expand Down Expand Up @@ -83,3 +86,125 @@ test("lib-import: a web page imports into the Library", async ({ signedIn: page,
const { data } = await admin.from("sources").select("title,origin").eq("user_id", await user.userId());
await evidence.json("02-imported-row", data);
});

test("lib-offline: offline, the switch does not move and says the change was not saved", async ({ signedIn: page, user, evidence }) => {
await startDebate(page, motion);
await page.goto("/app/library");
await addSource(page, "Offline study", "Fares and ridership.");
await expect(page.getByText("Saved Offline study to the Library")).toBeVisible();
await page.context().setOffline(true);
await page.getByRole("switch", { name: "Toggle Offline study" }).click();
await expect(page.getByText("You are offline. Reconnect and try again.")).toBeVisible();
await expect(page.getByRole("switch", { name: "Toggle Offline study" })).toBeChecked();
await evidence.shot("01-offline-toggle");
// A save that fails keeps what the student typed, so they can retry.
await page.getByRole("button", { name: "Offline study", exact: true }).click();
const sheet = page.getByRole("dialog");
await sheet.getByRole("textbox", { name: "Content" }).fill("Fares, ridership and a rewritten note.");
await sheet.getByRole("button", { name: "Save" }).click();
await expect(page.getByText("Could not save this source.")).toBeVisible();
await expect(sheet.getByRole("textbox", { name: "Content" })).toHaveValue("Fares, ridership and a rewritten note.");
await evidence.shot("02-offline-edit-kept");
await page.context().setOffline(false);
const row = async () => (await admin.from("sources").select("content,is_active").eq("user_id", await user.userId()).eq("title", "Offline study")).data;
expect(await row()).toEqual([{ content: "Fares and ridership.", is_active: true }]);
// Back online, the kept text saves on retry and the switch is left as it was.
await sheet.getByRole("button", { name: "Save" }).click();
await expect(page.getByText("Source updated")).toBeVisible();
await expect.poll(row).toEqual([{ content: "Fares, ridership and a rewritten note.", is_active: true }]);
await evidence.json("03-row-after-retry", await row());
});

test("lib-edit-while-switching: the editor follows a switch that rolls back, and Save sends only what was edited", async ({ signedIn: page, user, evidence }) => {
await startDebate(page, motion);
await page.goto("/app/library");
await addSource(page, "Race study", "Original text.");
// The switch write hangs, then fails, while the editor is open.
const failSwitch = async (route: import("@playwright/test").Route) => {
if (route.request().method() === "PATCH" && (route.request().postData() ?? "").includes("is_active")) {
await new Promise((resolve) => setTimeout(resolve, 1_500));
await route.abort("failed");
return;
}
await route.continue();
};
await page.route("**/rest/v1/sources?**", failSwitch);
await page.getByRole("switch", { name: "Toggle Race study" }).click();
await page.getByRole("button", { name: "Race study", exact: true }).click();
const sheet = page.getByRole("dialog");
// The editor opened while the write was still running, so it shows the switch off.
await expect(sheet.getByRole("switch", { name: "Use for AI grounding" })).not.toBeChecked();
await expect(page.getByText("Could not change this source")).toBeVisible();
// The switch went back on; the editor shows that, since it was not edited there.
await expect(sheet.getByRole("switch", { name: "Use for AI grounding" })).toBeChecked();
await page.unroute("**/rest/v1/sources?**", failSwitch);
await sheet.getByRole("textbox", { name: "Content" }).fill("Edited text.");
await sheet.getByRole("button", { name: "Save" }).click();
await expect(page.getByText("Source updated")).toBeVisible();
const row = async () => (await admin.from("sources").select("content,is_active").eq("user_id", await user.userId()).eq("title", "Race study")).data;
await expect.poll(row).toEqual([{ content: "Edited text.", is_active: true }]);
await evidence.shot("01-editor-after-rollback");

// Typing on while a save runs keeps that text as an edit, so the next Save sends it.
const slowSave = async (route: import("@playwright/test").Route) => {
if (route.request().method() === "PATCH") await new Promise((resolve) => setTimeout(resolve, 1_500));
await route.continue();
};
await page.route("**/rest/v1/sources?**", slowSave);
const content = sheet.getByRole("textbox", { name: "Content" });
await content.fill("Second edit.");
await sheet.getByRole("button", { name: "Save" }).click();
await content.fill("Second edit, typed on.");
await expect.poll(row).toEqual([{ content: "Second edit.", is_active: true }]);
await expect(sheet.getByRole("button", { name: "Save" })).toBeEnabled();
await page.unroute("**/rest/v1/sources?**", slowSave);
await sheet.getByRole("button", { name: "Save" }).click();
await expect.poll(row).toEqual([{ content: "Second edit, typed on.", is_active: true }]);
await evidence.json("02-row", await row());
});

test("lib-dedupe and lib-layout: a page saved once per debate, and long titles keep their room on a phone", async ({ signedIn: page, user, evidence }) => {
await startDebate(page, motion);
const userId = await user.userId();
const { data: debateRow } = await admin.from("debates").select("id").eq("user_id", userId).eq("motion", motion).single();
const title = "Por qué deberían prohibirse los deberes escolares en la educación primaria según los expertos";
const url = "https://www.example.org/estudios/deberes-escolares-primaria/";
await admin.from("sources").insert({ user_id: userId, debate_id: debateRow!.id, title, content: "Texto", type: "url", origin: url, folder: "General", is_active: true });
await page.setViewportSize({ width: 360, height: 780 });
await page.goto("/app/library");
const titleButton = page.getByRole("button", { name: title, exact: true });
await expect(titleButton).toBeVisible();
// Before, the controls shared the title's line and left it a few letters.
expect((await titleButton.boundingBox())!.width).toBeGreaterThan(200);
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(360);
await evidence.shot("01-phone-row");

// The same page with tracking and a fragment is refused before any fetch.
let imported = false;
page.on("request", (request) => { if (request.url().includes("/functions/v1/import-source")) imported = true; });
await page.getByRole("button", { name: "Add Source" }).click();
await page.getByRole("textbox", { name: "Import from URL" }).fill("http://example.org/estudios/deberes-escolares-primaria?utm_source=x#top");
await page.getByRole("button", { name: "Import URL" }).click();
await expect(page.getByText("This page is already in this debate's Library.")).toBeVisible();
expect(imported).toBe(false);
await evidence.shot("02-duplicate-warning");
const { data } = await admin.from("sources").select("id").eq("user_id", userId);
expect(data).toHaveLength(1);
});

test("lib-counts: the debate summary and Ask agree on how many sources a request uses", async ({ signedIn: page, user, evidence }) => {
await startDebate(page, motion);
const userId = await user.userId();
const { data: debateRow } = await admin.from("debates").select("id").eq("user_id", userId).eq("motion", motion).single();
// What Attack's research leaves behind: several pages switched on, some off.
await admin.from("sources").insert([1, 2, 3, 4, 5, 6, 7].map((n) => ({
user_id: userId, debate_id: debateRow!.id, title: `Researched page ${n}`, content: `Finding ${n}`, type: "url",
origin: `https://example.org/page-${n}`, folder: "Auto-saved", is_active: n <= 5,
})));
await page.goto("/app");
await expect(page.getByTestId("home-current-debate")).toContainText("7 sources (5 active)");
await evidence.shot("01-home-counts");
await page.goto("/app/ask");
await expect(page.getByTestId("debate-context-control")).toContainText("Using 5 sources");
await evidence.shot("02-ask-counts");
});
18 changes: 18 additions & 0 deletions .claude/skills/verify-debatelab/recipes/practice.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,24 @@ test("cross-turn and cross-debrief: questioning the AI gets an answer, a Jev sig
await expectCrossfireSaved(await user.userId());
// The turn and the debrief were shown live (features/live-generation.md).
await expectStreamed(["crossfire"]);

// cross-history: after leaving the page, History reopens the round and can delete it.
await page.goto("/app/history?tab=crossfire");
const row = page.getByTestId("history-crossfire-row");
await expect(row).toHaveCount(1);
await expect(row).toContainText(resolution);
await row.getByRole("button", { name: `Open Crossfire round: ${resolution}` }).click();
const saved = page.getByTestId("saved-crossfire");
await expect(saved.getByTestId("saved-crossfire-debrief")).toBeVisible();
await expect(saved.getByTestId("saved-crossfire-transcript")).toContainText(text!.slice(0, 40));
await evidence.shot("04-history-crossfire");
await evidence.aria("04-history-crossfire", saved);
await page.keyboard.press("Escape");
await page.getByRole("button", { name: `Delete Crossfire round: ${resolution}` }).click();
await page.getByRole("alertdialog").getByRole("button", { name: "Delete" }).click();
await expect(row).toHaveCount(0);
await expect.poll(async () => (await admin.from("crossfires").select("id").eq("user_id", await user.userId())).data).toEqual([]);
await evidence.step("Reopened the round from History, then deleted it");
});

test("cross-ai-questions: the AI questions the user, answers are judged, and the debrief completes", async ({ signedIn: page, user, evidence }) => {
Expand Down
4 changes: 3 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,9 @@ The repository is an npm workspace. The web app is the root package and still li

[`src/lib/supabase/AuthProvider.tsx`](../src/lib/supabase/AuthProvider.tsx) coordinates store hydration and reset across authentication changes. Store code is not the database schema source of truth; migrations are.

Discovered-source autosave in [`src/lib/ai/sources.ts`](../src/lib/ai/sources.ts) keeps one Library row per page per debate. It matches on a normalized URL (host without `www.`, no scheme, fragment, tracking parameters or trailing slash) and refreshes the existing row's research metadata instead of inserting a second one. Batches run one at a time within a tab; two tabs can still race, which only a unique index would stop.
The Library keeps one row per web page per debate. [`src/lib/source-url.ts`](../src/lib/source-url.ts) normalizes URLs (host without `www.`, no scheme, fragment, tracking parameters or trailing slash). `addSource` in the session store refuses a page the debate already holds, checking the loaded Library and then the debate's rows on the server, so manual adds, URL imports and research autosave all go through the same check; Undo is exempt. Autosave in [`src/lib/ai/sources.ts`](../src/lib/ai/sources.ts) refreshes the existing row's research metadata instead, and its batches run one at a time within a tab. Two tabs inserting the same page at the same moment can still both pass the check; only a unique index would stop that, and existing duplicate rows on production keep one from being added.

A new source shows in the Library at once as a `pending-` row and is swapped for the saved row by id. Every write that turns sources on or off (the switch, a folder's Activate/Deactivate, the source editor) joins a per-source queue in the session store: the server applies them in click order, the newest decides what shows, a failure falls back to the last value the server accepted, and a server reload does not undo a write made while it ran.

[`src/hooks/use-subscription.ts`](../src/hooks/use-subscription.ts) estimates today's remaining AI requests from the account's `ai_requests` rows (refused calls and `jev_*` helper rows excluded) and its own `rate_limits` override. Global `rate_limits` rows are server-only, so the estimate only warns (Sparring setup) and informs (Settings); it is null, never blocking, when any read fails. The `ai` function's 402 stays the decision, and every tool shows it with the same daily-limit dialog.

Expand Down
Loading
Loading