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
144
lib/ai.ts
144
lib/ai.ts
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
|
|
|||
|
|
@ -159,7 +159,7 @@ export function getServerConfig(): ServerConfig {
|
|||
healthTimeoutMs:
|
||||
Number.parseInt(process.env.OLLAMA_HEALTH_TIMEOUT_MS ?? "", 10) || 8_000,
|
||||
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: {
|
||||
windowMs: positiveInt(process.env.RATE_LIMIT_WINDOW_MS, 300_000),
|
||||
generate: positiveInt(process.env.RATE_LIMIT_GENERATE, 12),
|
||||
|
|
|
|||
|
|
@ -502,7 +502,7 @@ const en: Record<string, string> = {
|
|||
"upload.phone.summary": "Ready to freeze",
|
||||
"upload.phone.summaryDesc": "Check what goes in, then freeze.",
|
||||
"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.nothingAdded": "Nothing added yet",
|
||||
"upload.summary.files": "Files",
|
||||
|
|
@ -1663,7 +1663,7 @@ const cs: Record<string, string> = {
|
|||
"upload.phone.summary": "Připraveno ke zmrazení",
|
||||
"upload.phone.summaryDesc": "Zkontroluj, co se použije, a zmraz.",
|
||||
"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.nothingAdded": "Zatím nic přidáno",
|
||||
"upload.summary.files": "Soubory",
|
||||
|
|
|
|||
|
|
@ -138,8 +138,9 @@ export type GenerationRequest = {
|
|||
count: number;
|
||||
/** Language the output must be written in (from the vision step or detected). */
|
||||
language?: string;
|
||||
/** Set when adding more activities to an existing deck. */
|
||||
existing?: { title: string; types: string[]; count: number };
|
||||
/** Set when adding more activities to an existing deck (or to the part of a
|
||||
* big deck the earlier model calls already built). */
|
||||
existing?: { title: string; types: string[]; count: number; topics?: string[] };
|
||||
/** Extra instructions from the student (topic hint). */
|
||||
focus?: string;
|
||||
};
|
||||
|
|
@ -159,7 +160,11 @@ export function buildGenerationUserPrompt({
|
|||
: "LANGUAGE: detect the language of the notes below and write everything in that same language.",
|
||||
focus ? `The student especially wants to practise: ${focus}` : "",
|
||||
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) ===",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue