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

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 } };
}
/** 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: {
model: string;
auth: OllamaAuth;
@ -374,35 +427,72 @@ export async function generateActivities(input: {
deck: { title: string; subject?: string; summary?: string; language?: string; activities: GeneratedActivity[] };
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<{
title: string;
subject?: string;
summary?: string;
language?: string;
activities: GeneratedActivity[];
}>({
model: input.model,
auth: input.auth,
provider: input.provider,
system: GENERATION_SYSTEM_PROMPT,
user: buildGenerationUserPrompt({
notes: input.notes,
difficulty: input.difficulty,
count: requested,
language: input.language,
existing: input.existing,
focus: input.focus,
}),
validate: (parsed) => validateDeck(parsed, Math.max(4, Math.ceil(requested * 0.5)), requested),
shapeHint: GENERATION_SHAPE_HINT,
label: "Building your activities",
signal: input.signal,
temperature: 0.7,
});
for (let index = 0; index < batches.length; index += 1) {
/* The first call continues an existing deck when the user asked for "more";
later calls already have this run's cards, so the prompt tells the model
which material and types are taken. */
const previous =
index === 0
? input.existing
: {
title: deck?.title ?? input.existing?.title ?? "Your study deck",
types: [...types],
count: activities.length,
topics: [...new Set(activities.map((activity) => activity.topic).filter((topic): topic is string => Boolean(topic)))],
};
const step = await runJsonStep<ActivityDeck>({
model: input.model,
auth: input.auth,
provider: input.provider,
system: GENERATION_SYSTEM_PROMPT,
user: buildGenerationUserPrompt({
notes: input.notes,
difficulty: input.difficulty,
count: batches[index],
language: input.language ?? deck?.language,
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,
};
}
/* ------------------------------------------------------------------ */