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:
Martin Dvořák 2026-10-28 19:11:39 +00:00
commit e3b7bbbd6c
13 changed files with 345 additions and 46 deletions

View file

@ -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)));