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
|
|
@ -90,4 +90,5 @@ TEXT_MODEL=gpt-oss:120b-cloud
|
|||
# OLLAMA_HEALTH_TIMEOUT_MS=8000
|
||||
# Send `think: false` to thinking models for faster JSON output.
|
||||
# OLLAMA_THINK=false
|
||||
# Deck size when the request does not send one (6–30; the wizard offers 8–30).
|
||||
# FREEZMO_DEFAULT_ACTIVITY_COUNT=10
|
||||
|
|
|
|||
31
README.md
31
README.md
|
|
@ -3,7 +3,7 @@
|
|||
**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
|
||||
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
|
||||
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
|
||||
1600 px and re-encoded as JPEG before upload
|
||||
- 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…`)
|
||||
- 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 │
|
||||
│ 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`).
|
||||
- Individual broken activities are dropped rather than failing the whole deck; if fewer than half
|
||||
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
|
||||
(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.
|
||||
- `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.
|
||||
|
|
@ -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
|
||||
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
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
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 { INERT_OLLAMA_AUTH, resolveModelTarget } from "@/lib/providers-server";
|
||||
import { friendlyErrorMessage, OllamaError } from "@/lib/ollama";
|
||||
|
|
@ -26,7 +26,8 @@ const requestSchema = z.object({
|
|||
images: z.array(dataUrlSchema(MAX_IMAGE_CHARS)).max(16).optional(),
|
||||
text: z.string().max(120_000).optional(),
|
||||
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(),
|
||||
/** Language the deck must be written in (usually detected by the vision step). */
|
||||
language: z.string().max(60).optional(),
|
||||
|
|
@ -129,8 +130,10 @@ export async function POST(request: Request) {
|
|||
const requestedCount = count ?? config.defaultActivityCount;
|
||||
const encode = encoder();
|
||||
const abort = new AbortController();
|
||||
// A single hard deadline for the whole pipeline (two sequential model calls).
|
||||
const globalTimer = setTimeout(() => abort.abort(), config.timeoutMs * 2);
|
||||
// A single hard deadline for the whole pipeline: the vision step (when there
|
||||
// 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();
|
||||
if (request.signal.aborted) abort.abort();
|
||||
else request.signal.addEventListener("abort", onRequestAbort, { once: true });
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ import { Button, ProgressBar } from "@/components/ui";
|
|||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
];
|
||||
|
||||
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"];
|
||||
|
||||
|
|
|
|||
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) ===",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "freezmo",
|
||||
"version": "0.8.3",
|
||||
"version": "0.8.4",
|
||||
"private": true,
|
||||
"license": "GPL-3.0-only",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
*
|
||||
* 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 ASSET_CACHE = `${CACHE_VERSION}-assets`;
|
||||
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
|
|
|
|||
|
|
@ -305,5 +305,70 @@ for (const viewport of [{ width: 390, height: 844 }, { width: 360, height: 740 }
|
|||
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();
|
||||
finish();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue