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
|
# 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
|
||||||
|
|
|
||||||
31
README.md
31
README.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
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 } };
|
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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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) ===",
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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`;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)));
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue