Bigger decks: pick up to 30 activities, built in batches
The create wizard stopped at 15 activities because the API, the prompt and the salvage path all capped at 18: one reply must be one valid JSON object, and past ~18 activities models truncate or drop cards. Raising the cap alone would have shipped decks that came back short. - The count picker offers 8 / 10 / 12 / 15 / 20 / 30; a deck's "add more" row gained +15. The help line says bigger decks take longer. - /api/generate accepts up to 30 (FREEZMO_DEFAULT_ACTIVITY_COUNT now clamps to 30 as well) and sizes its overall deadline from the number of model calls instead of a fixed two. - generateActivities splits anything above 18 evenly across calls (30 → 15 + 15, 25 → 13 + 12) and merges them into one deck. The first call keeps the "add to this deck" contract; later calls are told the title, the types already used and the topics already covered. A normalised content signature drops a card that repeats an earlier one, so the same idea never ships twice. requested/generated still travel in the stream meta. - phase1-test: a stub Ollama answers two 15-card batches (the second repeats one card on purpose) → two calls, the even split, the "already covered" prompt, 29 merged cards, the duplicate shipped once, title from the first reply, and count 31 answered 400 (59 checks). - wizard-test: the picker offers 30, the wizard sends count: 30, and the 30-card deck is stored and opened (99 checks). Version 0.8.4, SW v106.
This commit is contained in:
parent
705808d45a
commit
e3b7bbbd6c
13 changed files with 345 additions and 46 deletions
|
|
@ -500,6 +500,116 @@ async function openSettings(page) {
|
|||
}
|
||||
}
|
||||
|
||||
/* 9b. big decks: built in batches, merged and deduplicated ----------- */
|
||||
{
|
||||
check("batches: app port free", !(await portInUse(3216)));
|
||||
const promptBodies = [];
|
||||
const batchReply = (batch) => ({
|
||||
title: "Big deck",
|
||||
language: "English",
|
||||
summary: "Two batches of the same notes.",
|
||||
activities: Array.from({ length: 15 }, (_, index) => ({
|
||||
type: "flashcard",
|
||||
topic: `Topic ${batch}`,
|
||||
front: `BATCH-${batch}-Q${index}`,
|
||||
back: `BATCH-${batch}-A${index}`,
|
||||
explanation: "One gap in the explanation.",
|
||||
})),
|
||||
});
|
||||
const stub = http.createServer((request, response) => {
|
||||
if (request.url?.startsWith("/api/tags")) {
|
||||
response.writeHead(200, { "Content-Type": "application/json" });
|
||||
response.end(JSON.stringify({ models: [{ name: "stub", model: "stub" }] }));
|
||||
return;
|
||||
}
|
||||
let raw = "";
|
||||
request.on("data", (chunk) => (raw += chunk));
|
||||
request.on("end", () => {
|
||||
let body = {};
|
||||
try {
|
||||
body = JSON.parse(raw);
|
||||
} catch {
|
||||
/* keep the default */
|
||||
}
|
||||
promptBodies.push(body);
|
||||
const reply = batchReply(promptBodies.length);
|
||||
/* The second call repeats one card on purpose: the merge must not ship
|
||||
the same card twice. */
|
||||
if (promptBodies.length === 2) {
|
||||
reply.activities[0] = {
|
||||
type: "flashcard",
|
||||
topic: "Topic 1",
|
||||
front: "BATCH-1-Q0",
|
||||
back: "BATCH-1-A0",
|
||||
explanation: "One gap in the explanation.",
|
||||
};
|
||||
}
|
||||
response.writeHead(200, { "Content-Type": "application/json" });
|
||||
response.end(JSON.stringify({ message: { content: JSON.stringify(reply) } }));
|
||||
});
|
||||
});
|
||||
await new Promise((resolve) => stub.listen(12003, "127.0.0.1", resolve));
|
||||
|
||||
const app = spawnApp(3216, {
|
||||
OLLAMA_BASE_URL: "http://127.0.0.1:12003",
|
||||
OLLAMA_AUTH: "local",
|
||||
OLLAMA_VERIFY_KEY: "false",
|
||||
});
|
||||
|
||||
try {
|
||||
const up = await waitForHttp("http://127.0.0.1:3216/api/health");
|
||||
check("batches: stub app is up", up);
|
||||
if (up) {
|
||||
const response = await fetch("http://127.0.0.1:3216/api/generate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ text: "The sky is blue because molecules scatter light.", difficulty: "easy", count: 30 }),
|
||||
});
|
||||
const events = (await response.text())
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line));
|
||||
const deck = events.find((event) => event.type === "deck")?.deck;
|
||||
const meta = events.find((event) => event.type === "deck")?.meta;
|
||||
const activities = deck?.activities ?? [];
|
||||
const prompts = promptBodies.map((body) => String(body.messages?.[1]?.content ?? ""));
|
||||
check("batches: a deck of 30 is built in two model calls", promptBodies.length === 2, `calls: ${promptBodies.length}`);
|
||||
check(
|
||||
"batches: each call asks for an even share",
|
||||
prompts[0]?.includes("Create a study deck with 15 activities") && prompts[1]?.includes("Create a study deck with 15 activities"),
|
||||
prompts.map((prompt) => prompt.slice(0, 60)).join(" | "),
|
||||
);
|
||||
check(
|
||||
"batches: the second call is told what is already covered",
|
||||
prompts[1]?.includes("addition to the existing deck") &&
|
||||
prompts[1]?.includes("Topics already covered") &&
|
||||
prompts[1]?.includes("Topic 1"),
|
||||
);
|
||||
check("batches: both batches are merged", activities.length === 29, `activities: ${activities.length}`);
|
||||
check(
|
||||
"batches: a repeated card is shipped once",
|
||||
activities.filter((activity) => activity.front === "BATCH-1-Q0").length === 1,
|
||||
);
|
||||
check(
|
||||
"batches: the deck keeps the first reply's title and the requested count",
|
||||
deck?.title === "Big deck" && meta?.requested === 30 && meta?.generated === 29,
|
||||
JSON.stringify({ title: deck?.title, meta }),
|
||||
);
|
||||
|
||||
const tooMany = await fetch("http://127.0.0.1:3216/api/generate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ text: "The sky is blue.", difficulty: "easy", count: 31 }),
|
||||
});
|
||||
check("batches: more than 30 in one deck → 400", tooMany.status === 400, String(tooMany.status));
|
||||
}
|
||||
} finally {
|
||||
app.stop();
|
||||
stub.close();
|
||||
}
|
||||
}
|
||||
|
||||
/* 10. reports: appended as JSON lines, write-only over HTTP --------- */
|
||||
{
|
||||
check("reports: app port free", !(await portInUse(3211)));
|
||||
|
|
|
|||
|
|
@ -305,5 +305,70 @@ for (const viewport of [{ width: 390, height: 844 }, { width: 360, height: 740 }
|
|||
await context.close();
|
||||
}
|
||||
|
||||
/* 6. bigger decks: the count picker goes up to 30 -------------------- */
|
||||
{
|
||||
const { context, page } = await makePage(browser);
|
||||
await seed(page, { decks: [] });
|
||||
let request = null;
|
||||
const reply = {
|
||||
id: "big",
|
||||
title: "Big deck",
|
||||
subject: "Biology",
|
||||
summary: "Thirty cards.",
|
||||
language: "English",
|
||||
sourceText: "Photosynthesis needs light and water.",
|
||||
difficulty: "medium",
|
||||
activities: Array.from({ length: 30 }, (_, index) => ({
|
||||
type: "flashcard",
|
||||
id: `big-${index}`,
|
||||
xp: 10,
|
||||
topic: "Photosynthesis",
|
||||
front: `Question ${index}`,
|
||||
back: `Answer ${index}`,
|
||||
explanation: "One gap in the explanation.",
|
||||
})),
|
||||
};
|
||||
await page.route("**/api/generate", async (route) => {
|
||||
request = JSON.parse(route.request().postData() ?? "{}");
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/x-ndjson",
|
||||
body: JSON.stringify({ type: "deck", deck: reply }) + "\n",
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto(`${BASE}/create`, { waitUntil: "networkidle" });
|
||||
await page.waitForTimeout(600);
|
||||
await page.getByRole("button", { name: "Paste text instead" }).click();
|
||||
await page.locator("textarea").first().fill("Photosynthesis needs light and water.");
|
||||
await page.getByRole("button", { name: "Next" }).click();
|
||||
await page.waitForTimeout(400);
|
||||
|
||||
const sizes = ["8", "10", "12", "15", "20", "30"];
|
||||
const offered = [];
|
||||
for (const size of sizes) {
|
||||
offered.push((await page.getByRole("button", { name: size, exact: true }).count()) >= 1);
|
||||
}
|
||||
check("count: the picker offers up to 30", offered.every(Boolean), JSON.stringify(sizes.map((size, index) => `${size}:${offered[index]}`)));
|
||||
|
||||
await page.getByRole("button", { name: "30", exact: true }).click();
|
||||
check(
|
||||
"count: 30 reads as the chosen size",
|
||||
(await page.getByRole("button", { name: "30", exact: true }).getAttribute("aria-pressed")) === "true",
|
||||
);
|
||||
await page.getByRole("button", { name: "Next" }).click();
|
||||
await page.waitForTimeout(300);
|
||||
await page.getByRole("button", { name: "Freeze my deck" }).click();
|
||||
await page.waitForTimeout(1600);
|
||||
check("count: the wizard sends the chosen size", request?.count === 30, JSON.stringify({ count: request?.count }));
|
||||
const decks = await page.evaluate(() => JSON.parse(window.localStorage.getItem("freezmo.decks.v1") ?? "[]"));
|
||||
check(
|
||||
"count: a 30-card deck is stored and opened",
|
||||
(decks[0]?.activities ?? []).length === 30 && /\/deck\//.test(page.url()),
|
||||
`activities: ${decks[0]?.activities?.length} url: ${page.url()}`,
|
||||
);
|
||||
await context.close();
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
finish();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue