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,
|
||||
};
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue