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

@ -90,4 +90,5 @@ TEXT_MODEL=gpt-oss:120b-cloud
# OLLAMA_HEALTH_TIMEOUT_MS=8000 # OLLAMA_HEALTH_TIMEOUT_MS=8000
# Send `think: false` to thinking models for faster JSON output. # Send `think: false` to thinking models for faster JSON output.
# OLLAMA_THINK=false # OLLAMA_THINK=false
# Deck size when the request does not send one (6–30; the wizard offers 8–30).
# FREEZMO_DEFAULT_ACTIVITY_COUNT=10 # FREEZMO_DEFAULT_ACTIVITY_COUNT=10

View file

@ -3,7 +3,7 @@
**Turn photos of your study notes into a swipeable feed of mini study games.** **Turn photos of your study notes into a swipeable feed of mini study games.**
Freezmo is a TikTok/Gizmo-style study app. You paste or upload photos of your notes, an AI reads Freezmo is a TikTok/Gizmo-style study app. You paste or upload photos of your notes, an AI reads
them and builds a deck of 8–15 interactive activities — flashcards, quizzes, matching games and them and builds a deck of 8–30 interactive activities — flashcards, quizzes, matching games and
more — that you swipe through vertically like a feed. Every answer gives instant feedback, XP and more — that you swipe through vertically like a feed. Every answer gives instant feedback, XP and
streaks, and the deck is saved in your browser so you can retry it later. streaks, and the deck is saved in your browser so you can retry it later.
@ -105,7 +105,8 @@ no database, no data leaving your machine except the requests you send to Ollama
- Up to 8 photos + 6 documents per deck (max 12 images reach the model); photos are resized to max - Up to 8 photos + 6 documents per deck (max 12 images reach the model); photos are resized to max
1600 px and re-encoded as JPEG before upload 1600 px and re-encoded as JPEG before upload
- Optional extra text (pasted notes) and a topic hint - Optional extra text (pasted notes) and a topic hint
- Easy / Medium / Hard difficulty and 8 / 10 / 12 / 15 activities per deck - Easy / Medium / Hard difficulty and 8 / 10 / 12 / 15 / 20 / 30 activities per deck (a big deck
is built in several model calls — see the generation pipeline)
- Fun staged loading state (`Reading your notes… → Finding the key ideas… → Building your activities…`) - Fun staged loading state (`Reading your notes… → Finding the key ideas… → Building your activities…`)
- Friendly error cards with a **Retry** button that point at Settings when a key is needed - Friendly error cards with a **Retry** button that point at Settings when a key is needed
@ -782,7 +783,7 @@ Limits and behaviour worth knowing:
▼ ▼ ▼ ▼
┌──────────────────────────┐ ┌───────────────────────────┐ ┌──────────────────────────┐ ┌───────────────────────────┐
│ browser: study feed │ ◄───────────────────── │ TEXT_MODEL │ │ browser: study feed │ ◄───────────────────── │ TEXT_MODEL │
│ JSON validated by zod │ deck JSON │ design 8–15 activities │ │ JSON validated by zod │ deck JSON │ design 8–30 activities │
└──────────────────────────┘ └───────────────────────────┘ └──────────────────────────┘ └───────────────────────────┘
``` ```
@ -794,8 +795,14 @@ Limits and behaviour worth knowing:
with the validation error fed back to the model (`lib/ai.ts`). with the validation error fed back to the model (`lib/ai.ts`).
- Individual broken activities are dropped rather than failing the whole deck; if fewer than half - Individual broken activities are dropped rather than failing the whole deck; if fewer than half
the requested activities survive, the step is retried too. the requested activities survive, the step is retried too.
- **Bigger decks are built in batches.** One model reply has to be one valid JSON object, and past
~18 activities replies get truncated or lose cards, so a count above that is split across several
calls (30 → 15 + 15, 25 → 13 + 12) with the same notes. Each call after the first is told the
deck's title, the types already used and the topics already covered, and the merged deck drops
cards whose content repeats (so the same idea never ships twice). The route's overall deadline
scales with the number of calls.
- Requests use `stream: false` with per-request timeouts and a hard overall deadline - Requests use `stream: false` with per-request timeouts and a hard overall deadline
(two sequential model calls). Errors surface as friendly messages (the vision step plus one model call per batch). Errors surface as friendly messages
("Ollama isn't reachable, check your settings") with a retry button. ("Ollama isn't reachable, check your settings") with a retry button.
- `POST /api/generate` answers with newline-delimited JSON (`stage` / `deck` / `error` events) so the - `POST /api/generate` answers with newline-delimited JSON (`stage` / `deck` / `error` events) so the
UI can show real progress between the vision and generation steps. UI can show real progress between the vision and generation steps.
@ -1513,6 +1520,22 @@ Things I chose while building, with the reasoning:
into view as you type. No strings, so no i18n; the additive change sits on top of the merged into view as you type. No strings, so no i18n; the additive change sits on top of the merged
scene-heat rework unchanged. scene-heat rework unchanged.
112. **Bigger decks are one request but several model calls.** The create wizard topped out at 15
activities (`COUNT_OPTIONS`) because the API, the prompt and the salvage path all capped at 18:
one reply has to be one valid JSON object, and past that size models start truncating or
quietly dropping cards. Raising the cap alone would have shipped decks that came back short.
The picker now offers 8 / 10 / 12 / 15 / 20 / 30 (and a deck's "add more" row gained +15); the
route accepts up to 30; and `generateActivities` splits anything above 18 evenly across calls
(30 → 15 + 15), merging them into one deck. The first call keeps the existing "add to this
deck" contract; later calls are told the title, the types already used and the topics already
covered, and a normalised content signature (`activitySignature`) drops a card that repeats an
earlier one — the stub in phase 1 returns the same first card in both batches to pin exactly
that (29 from two 15-card replies). The route's overall deadline now scales with the number of
calls, and the requested/generated pair still travels in the stream's meta. Verified by the
phase-1 suite (59 checks: the two calls, the even split, the "already covered" prompt, the
merge + dedupe, and a 31-card request answered 400) and the wizard suite (99: the picker
offers 30, the wizard sends `count: 30`, and the 30-card deck is stored and opened).
111. **The model dropdown was clipped by the card it lived in.** "The model selection in teach it 111. **The model dropdown was clipped by the card it lived in.** "The model selection in teach it
back is kinda broken at least on desktop": the pickers' desktop panels were back is kinda broken at least on desktop": the pickers' desktop panels were
`position: absolute` children of whatever container held their chip. In chat that is the `position: absolute` children of whatever container held their chip. In chat that is the

View file

@ -1,6 +1,6 @@
import { z } from "zod"; import { z } from "zod";
import { extractNotes, generateActivities, notesToText, type PipelineStage } from "@/lib/ai"; import { activityBatches, extractNotes, generateActivities, notesToText, type PipelineStage } from "@/lib/ai";
import { getServerConfig, resolveModelName } from "@/lib/config"; import { getServerConfig, resolveModelName } from "@/lib/config";
import { INERT_OLLAMA_AUTH, resolveModelTarget } from "@/lib/providers-server"; import { INERT_OLLAMA_AUTH, resolveModelTarget } from "@/lib/providers-server";
import { friendlyErrorMessage, OllamaError } from "@/lib/ollama"; import { friendlyErrorMessage, OllamaError } from "@/lib/ollama";
@ -26,7 +26,8 @@ const requestSchema = z.object({
images: z.array(dataUrlSchema(MAX_IMAGE_CHARS)).max(16).optional(), images: z.array(dataUrlSchema(MAX_IMAGE_CHARS)).max(16).optional(),
text: z.string().max(120_000).optional(), text: z.string().max(120_000).optional(),
difficulty: difficultySchema.default("medium"), difficulty: difficultySchema.default("medium"),
count: z.number().int().min(4).max(18).optional(), /** One deck; a big count is built in several model calls (lib/ai.ts). */
count: z.number().int().min(4).max(30).optional(),
focus: z.string().max(300).optional(), focus: z.string().max(300).optional(),
/** Language the deck must be written in (usually detected by the vision step). */ /** Language the deck must be written in (usually detected by the vision step). */
language: z.string().max(60).optional(), language: z.string().max(60).optional(),
@ -129,8 +130,10 @@ export async function POST(request: Request) {
const requestedCount = count ?? config.defaultActivityCount; const requestedCount = count ?? config.defaultActivityCount;
const encode = encoder(); const encode = encoder();
const abort = new AbortController(); const abort = new AbortController();
// A single hard deadline for the whole pipeline (two sequential model calls). // A single hard deadline for the whole pipeline: the vision step (when there
const globalTimer = setTimeout(() => abort.abort(), config.timeoutMs * 2); // are photos) plus one model call per activity batch.
const modelCalls = activityBatches(requestedCount).length + 1;
const globalTimer = setTimeout(() => abort.abort(), config.timeoutMs * modelCalls);
const onRequestAbort = () => abort.abort(); const onRequestAbort = () => abort.abort();
if (request.signal.aborted) abort.abort(); if (request.signal.aborted) abort.abort();
else request.signal.addEventListener("abort", onRequestAbort, { once: true }); else request.signal.addEventListener("abort", onRequestAbort, { once: true });

View file

@ -22,7 +22,7 @@ import { Button, ProgressBar } from "@/components/ui";
type Busy = null | "more" | "regenerate"; type Busy = null | "more" | "regenerate";
const ADD_OPTIONS = [3, 5, 8]; const ADD_OPTIONS = [3, 5, 8, 15];
/** /**
* One deck in the dashboard grid: title, muted metadata, tags, summary and a * One deck in the dashboard grid: title, muted metadata, tags, summary and a

View file

@ -83,7 +83,9 @@ const DIFFICULTIES: Array<{ value: Difficulty; labelKey: string; icon: React.Rea
{ value: "hard", labelKey: "upload.hard", icon: <IceCubeIcon className="size-5" />, blurbKey: "upload.hardBlurb" }, { value: "hard", labelKey: "upload.hard", icon: <IceCubeIcon className="size-5" />, blurbKey: "upload.hardBlurb" },
]; ];
const COUNT_OPTIONS = [8, 10, 12, 15]; /** Deck sizes the wizard offers. Above 18 the server builds the deck in
* several model calls; 30 is the largest it accepts. */
const COUNT_OPTIONS = [8, 10, 12, 15, 20, 30];
const GENERATION_TIPS = ["upload.tip1", "upload.tip2", "upload.tip3", "upload.tip4"]; const GENERATION_TIPS = ["upload.tip1", "upload.tip2", "upload.tip3", "upload.tip4"];

144
lib/ai.ts
View file

@ -358,6 +358,59 @@ function validateDeck(parsed: unknown, minimum: number, requested: number) {
return { ok: true as const, value: { title, subject, summary, language, activities } }; return { ok: true as const, value: { title, subject, summary, language, activities } };
} }
/** One model reply's deck shape (validated by validateDeck). */
type ActivityDeck = {
title: string;
subject?: string;
summary?: string;
language?: string;
activities: GeneratedActivity[];
};
/** The largest deck one request may ask for (the wizard offers up to 30). */
export const MAX_ACTIVITY_COUNT = 30;
/**
* The most activities asked for in one model call. A single reply has to be
* valid JSON in one go; past this size replies get truncated or start losing
* cards, so a bigger deck is built in several calls and merged.
*/
export const ACTIVITY_BATCH_SIZE = 18;
/**
* How to split a requested deck across model calls: as few calls as possible,
* spread evenly (30 → 15 + 15, 25 → 13 + 12, 8 → 8). Exported so the route can
* size its deadline from the number of calls.
*/
export function activityBatches(count: number): number[] {
const requested = Math.min(Math.max(Math.round(count) || 4, 4), MAX_ACTIVITY_COUNT);
const calls = Math.ceil(requested / ACTIVITY_BATCH_SIZE);
const base = Math.floor(requested / calls);
const extra = requested % calls;
return Array.from({ length: calls }, (_, index) => base + (index < extra ? 1 : 0));
}
/** A card's content, normalised — catches the same idea reappearing in a later
* batch (the model cannot see the earlier replies). */
function activitySignature(activity: GeneratedActivity): string {
const normal = (value: string) => value.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, " ").trim();
switch (activity.type) {
case "flashcard":
return `flashcard:${normal(activity.front)}|${normal(activity.back)}`;
case "multiple_choice":
return `multiple_choice:${normal(activity.question)}`;
case "true_false":
return `true_false:${normal(activity.statement)}`;
case "fill_blank":
return `fill_blank:${normal(activity.sentence)}`;
case "matching":
return `matching:${normal(activity.instruction)}|${activity.pairs.map((pair) => normal(pair.term)).sort().join(",")}`;
case "ordering":
return `ordering:${normal(activity.instruction)}|${activity.items.map(normal).sort().join(",")}`;
case "short_answer":
return `short_answer:${normal(activity.question)}`;
}
}
export async function generateActivities(input: { export async function generateActivities(input: {
model: string; model: string;
auth: OllamaAuth; auth: OllamaAuth;
@ -374,35 +427,72 @@ export async function generateActivities(input: {
deck: { title: string; subject?: string; summary?: string; language?: string; activities: GeneratedActivity[] }; deck: { title: string; subject?: string; summary?: string; language?: string; activities: GeneratedActivity[] };
requested: number; requested: number;
}> { }> {
const requested = Math.min(Math.max(input.count, 4), 18); const batches = activityBatches(input.count);
const requested = batches.reduce((total, size) => total + size, 0);
const activities: GeneratedActivity[] = [];
/** Cards already produced, by content — a later batch must add new ones. */
const seen = new Set<string>();
const types = new Set(input.existing?.types ?? []);
let deck: ActivityDeck | null = null;
const { value } = await runJsonStep<{ for (let index = 0; index < batches.length; index += 1) {
title: string; /* The first call continues an existing deck when the user asked for "more";
subject?: string; later calls already have this run's cards, so the prompt tells the model
summary?: string; which material and types are taken. */
language?: string; const previous =
activities: GeneratedActivity[]; index === 0
}>({ ? input.existing
model: input.model, : {
auth: input.auth, title: deck?.title ?? input.existing?.title ?? "Your study deck",
provider: input.provider, types: [...types],
system: GENERATION_SYSTEM_PROMPT, count: activities.length,
user: buildGenerationUserPrompt({ topics: [...new Set(activities.map((activity) => activity.topic).filter((topic): topic is string => Boolean(topic)))],
notes: input.notes, };
difficulty: input.difficulty, const step = await runJsonStep<ActivityDeck>({
count: requested, model: input.model,
language: input.language, auth: input.auth,
existing: input.existing, provider: input.provider,
focus: input.focus, system: GENERATION_SYSTEM_PROMPT,
}), user: buildGenerationUserPrompt({
validate: (parsed) => validateDeck(parsed, Math.max(4, Math.ceil(requested * 0.5)), requested), notes: input.notes,
shapeHint: GENERATION_SHAPE_HINT, difficulty: input.difficulty,
label: "Building your activities", count: batches[index],
signal: input.signal, language: input.language ?? deck?.language,
temperature: 0.7, existing: previous,
}); focus: input.focus,
}),
validate: (parsed) => validateDeck(parsed, Math.max(4, Math.ceil(batches[index] * 0.5)), batches[index]),
shapeHint: GENERATION_SHAPE_HINT,
label: "Building your activities",
signal: input.signal,
temperature: 0.7,
});
const value: ActivityDeck = step.value;
return { deck: value, requested }; if (!deck) deck = value;
for (const activity of value.activities) {
const signature = activitySignature(activity);
if (seen.has(signature)) continue;
seen.add(signature);
activities.push(activity);
types.add(activity.type);
}
if (activities.length >= requested) break;
/* Enough cards to stop early: a batch that repeats itself is not going to
invent new material, and every extra call costs the user time. */
if (value.activities.length === 0) break;
}
return {
deck: {
title: deck?.title ?? "Your study deck",
subject: deck?.subject,
summary: deck?.summary,
language: deck?.language,
activities: activities.slice(0, requested),
},
requested,
};
} }
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */

View file

@ -159,7 +159,7 @@ export function getServerConfig(): ServerConfig {
healthTimeoutMs: healthTimeoutMs:
Number.parseInt(process.env.OLLAMA_HEALTH_TIMEOUT_MS ?? "", 10) || 8_000, Number.parseInt(process.env.OLLAMA_HEALTH_TIMEOUT_MS ?? "", 10) || 8_000,
think, think,
defaultActivityCount: Number.isFinite(count) ? Math.min(Math.max(count, 6), 20) : 10, defaultActivityCount: Number.isFinite(count) ? Math.min(Math.max(count, 6), 30) : 10,
rateLimits: { rateLimits: {
windowMs: positiveInt(process.env.RATE_LIMIT_WINDOW_MS, 300_000), windowMs: positiveInt(process.env.RATE_LIMIT_WINDOW_MS, 300_000),
generate: positiveInt(process.env.RATE_LIMIT_GENERATE, 12), generate: positiveInt(process.env.RATE_LIMIT_GENERATE, 12),

View file

@ -502,7 +502,7 @@ const en: Record<string, string> = {
"upload.phone.summary": "Ready to freeze", "upload.phone.summary": "Ready to freeze",
"upload.phone.summaryDesc": "Check what goes in, then freeze.", "upload.phone.summaryDesc": "Check what goes in, then freeze.",
"upload.phone.difficultyHelp": "How deep the questions go.", "upload.phone.difficultyHelp": "How deep the questions go.",
"upload.phone.activitiesHelp": "How many activities to build.", "upload.phone.activitiesHelp": "How many activities to build — bigger decks take longer.",
"upload.phone.folderHelp": "Where the deck lands in your library.", "upload.phone.folderHelp": "Where the deck lands in your library.",
"upload.nothingAdded": "Nothing added yet", "upload.nothingAdded": "Nothing added yet",
"upload.summary.files": "Files", "upload.summary.files": "Files",
@ -1663,7 +1663,7 @@ const cs: Record<string, string> = {
"upload.phone.summary": "Připraveno ke zmrazení", "upload.phone.summary": "Připraveno ke zmrazení",
"upload.phone.summaryDesc": "Zkontroluj, co se použije, a zmraz.", "upload.phone.summaryDesc": "Zkontroluj, co se použije, a zmraz.",
"upload.phone.difficultyHelp": "Jak hluboko otázky jdou.", "upload.phone.difficultyHelp": "Jak hluboko otázky jdou.",
"upload.phone.activitiesHelp": "Kolik aktivit se vytvoří.", "upload.phone.activitiesHelp": "Kolik aktivit se vytvoří — větší sady trvají déle.",
"upload.phone.folderHelp": "Kam se sada v knihovně zařadí.", "upload.phone.folderHelp": "Kam se sada v knihovně zařadí.",
"upload.nothingAdded": "Zatím nic přidáno", "upload.nothingAdded": "Zatím nic přidáno",
"upload.summary.files": "Soubory", "upload.summary.files": "Soubory",

View file

@ -138,8 +138,9 @@ export type GenerationRequest = {
count: number; count: number;
/** Language the output must be written in (from the vision step or detected). */ /** Language the output must be written in (from the vision step or detected). */
language?: string; language?: string;
/** Set when adding more activities to an existing deck. */ /** Set when adding more activities to an existing deck (or to the part of a
existing?: { title: string; types: string[]; count: number }; * big deck the earlier model calls already built). */
existing?: { title: string; types: string[]; count: number; topics?: string[] };
/** Extra instructions from the student (topic hint). */ /** Extra instructions from the student (topic hint). */
focus?: string; focus?: string;
}; };
@ -159,7 +160,11 @@ export function buildGenerationUserPrompt({
: "LANGUAGE: detect the language of the notes below and write everything in that same language.", : "LANGUAGE: detect the language of the notes below and write everything in that same language.",
focus ? `The student especially wants to practise: ${focus}` : "", focus ? `The student especially wants to practise: ${focus}` : "",
existing existing
? `This is an addition to the existing deck "${existing.title}" which already has ${existing.count} activities (types used so far: ${existing.types.join(", ") || "none"}). Generate ${count} NEW activities on material that was not already covered, and prefer types that are underrepresented.` ? `This is an addition to the existing deck "${existing.title}" which already has ${existing.count} activities (types used so far: ${existing.types.join(", ") || "none"}).${
existing.topics?.length
? ` Topics already covered: ${existing.topics.slice(0, 12).join("; ")} — leave them alone.`
: ""
} Generate ${count} NEW activities on material that was not already covered, and prefer types that are underrepresented.`
: "", : "",
"", "",
"=== STUDENT NOTES (the only allowed source of facts) ===", "=== STUDENT NOTES (the only allowed source of facts) ===",

View file

@ -1,6 +1,6 @@
{ {
"name": "freezmo", "name": "freezmo",
"version": "0.8.3", "version": "0.8.4",
"private": true, "private": true,
"license": "GPL-3.0-only", "license": "GPL-3.0-only",
"scripts": { "scripts": {

View file

@ -9,7 +9,7 @@
* *
* Bumping CACHE_VERSION is how a new deploy invalidates old assets. * Bumping CACHE_VERSION is how a new deploy invalidates old assets.
*/ */
const CACHE_VERSION = "freezmo-v105"; const CACHE_VERSION = "freezmo-v106";
const SHELL_CACHE = `${CACHE_VERSION}-shell`; const SHELL_CACHE = `${CACHE_VERSION}-shell`;
const ASSET_CACHE = `${CACHE_VERSION}-assets`; const ASSET_CACHE = `${CACHE_VERSION}-assets`;

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 --------- */ /* 10. reports: appended as JSON lines, write-only over HTTP --------- */
{ {
check("reports: app port free", !(await portInUse(3211))); check("reports: app port free", !(await portInUse(3211)));

View file

@ -305,5 +305,70 @@ for (const viewport of [{ width: 390, height: 844 }, { width: 360, height: 740 }
await context.close(); 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(); await browser.close();
finish(); finish();