Phase 3: Check my work, follow-ups and turn-into-deck

Check my work (third homework mode)
- One model call with its own system prompt marks the student's own
  attempt: verdict, what's right, each issue with why + fix, and the
  corrected version that keeps everything the student got right.
  Unasked-for extras are never errors. The attempt is typed/pasted
  (photos of handwriting are documented as unsupported) and stored in
  the record so Redo re-checks the same work.
- Server: mode "check" + solution on POST /api/homework (zod, 400
  server.needSolution without one) and the same NDJSON stream; new
  checkHomework() pipeline with repair-once validation.

Follow-ups
- New POST /api/homework/refine (plain JSON, RATE_LIMIT_REFINE, zod,
  auth before body): shorter / more detail / explain, plain text back,
  language preserved.
- Shorter + More detail + Original rewrite the finished answer and the
  corrected version in place (FollowUpBar); every guide step has
  Explain step N differently; failed calls say so without losing text.

Turn into a study deck
- Any result hands its content (answer, corrected version or assembled
  guide) to New deck through a one-shot sessionStorage seed with the
  options step pre-filled.

UI
- Phone step 2 gains the third card: tapping it reveals the solution
  field + submit; desktop has three cards and the solution field shows
  with the mode. Homework history labels and share/card previews cover
  the new mode.

Verified: new phase-3 suite 20/20 against a stub Ollama (check payload,
missing-solution 400, refine validation, phone + desktop check flows,
shorter follow-up with restore, guide explain, deck hand-off) plus
regression 74/74, mobile 52/52, declutter 116/116, wizard 80/80,
animation 8/8, native 35/35, phase1 38/38, phase2 43/43, i18n 25/25.
Key parity 734/734. Lint + build clean. SW freezmo-v27.
This commit is contained in:
Martin Dvořák 2026-10-10 14:36:04 +00:00
commit 4513364019
22 changed files with 947 additions and 56 deletions

View file

@ -9,13 +9,17 @@ import { needsLanguageRepair } from "./language";
import {
buildGenerationUserPrompt,
buildGradingUserPrompt,
buildHomeworkCheckUserPrompt,
buildHomeworkGuideUserPrompt,
buildHomeworkMakeUserPrompt,
buildRefineUserPrompt,
buildRepairPrompt,
GENERATION_SYSTEM_PROMPT,
GRADING_SYSTEM_PROMPT,
HOMEWORK_CHECK_SYSTEM_PROMPT,
HOMEWORK_GUIDE_SYSTEM_PROMPT,
HOMEWORK_MAKE_SYSTEM_PROMPT,
REFINE_SYSTEM_PROMPT,
VISION_SYSTEM_PROMPT,
VISION_USER_INSTRUCTION,
} from "./prompts";
@ -23,12 +27,14 @@ import {
extractedNotesSchema,
generatedActivitySchema,
gradeResultSchema,
homeworkCheckSchema,
homeworkMakeSchema,
homeworkStepSchema,
type Difficulty,
type ExtractedNotes,
type GeneratedActivity,
type GradeResult,
type HomeworkCheck,
type HomeworkGuide,
type HomeworkMake,
} from "./schemas";
@ -502,6 +508,74 @@ export async function buildHomeworkGuide(input: {
return value;
}
/* ------------------------------------------------------------------ */
/* Check my work */
/* ------------------------------------------------------------------ */
const HOMEWORK_CHECK_SHAPE_HINT = `{"title":"...","language":"English name of the language used","verdict":"1-2 sentences","correct":["what is right"],"wrong":[{"issue":"...","why":"...","fix":"..."}],"corrected":"the student's solution with every mistake fixed"}`;
/** Mode 3: mark the student's own attempt and hand back a corrected version. */
export async function checkHomework(input: {
model: string;
auth: OllamaAuth;
assignment: string;
solution: string;
language?: string;
signal?: AbortSignal;
}): Promise<HomeworkCheck> {
const { value } = await runJsonStep<HomeworkCheck>({
model: input.model,
auth: input.auth,
system: HOMEWORK_CHECK_SYSTEM_PROMPT,
user: buildHomeworkCheckUserPrompt({
assignment: input.assignment,
solution: input.solution,
language: input.language,
}),
validate: (parsed) => {
const result = homeworkCheckSchema.safeParse(parsed);
if (!result.success) return { ok: false, error: summariseZodError(result.error) };
if (!result.data.corrected.trim()) return { ok: false, error: "The corrected field is empty." };
return { ok: true, value: result.data };
},
shapeHint: HOMEWORK_CHECK_SHAPE_HINT,
label: "Checking the attempt",
signal: input.signal,
temperature: 0.2,
});
return value;
}
/** Follow-ups: rewrite one finished piece (shorter / more detail / explain). */
export async function refineHomeworkText(input: {
model: string;
auth: OllamaAuth;
instruction: "shorter" | "more" | "explain";
text: string;
context?: string;
signal?: AbortSignal;
}): Promise<string> {
const result = await ollamaChat({
model: input.model,
auth: input.auth,
json: false,
signal: input.signal,
temperature: 0.4,
messages: [
{ role: "system", content: REFINE_SYSTEM_PROMPT },
{
role: "user",
content: buildRefineUserPrompt({
instruction: input.instruction,
text: input.text,
context: input.context,
}),
},
],
});
return result.content.trim();
}
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */

View file

@ -154,6 +154,8 @@ export type HomeworkInput = {
mode: HomeworkMode;
images?: string[];
text?: string;
/** Mode "check": the student's own attempt at the assignment. */
solution?: string;
language?: string;
};
@ -164,7 +166,17 @@ export type HomeworkProgress = {
export type StreamedHomework =
| { mode: "make"; title: string; language?: string; answer: string; sourceText?: string }
| { mode: "guide"; title: string; language?: string; steps: HomeworkStep[]; sourceText?: string };
| { mode: "guide"; title: string; language?: string; steps: HomeworkStep[]; sourceText?: string }
| {
mode: "check";
title: string;
language?: string;
verdict: string;
correct: string[];
wrong: Array<{ issue: string; why: string; fix: string }>;
corrected: string;
sourceText?: string;
};
type HomeworkStreamEvent =
| { type: "stage"; stage: "vision" | "generation" | "done"; message: string }
@ -224,6 +236,10 @@ export async function generateHomework(
const result = homework as StreamedHomework;
if (result.mode === "make") {
if (!result.answer?.trim()) throw new ApiError(t("api.noHomework"), "bad_response");
} else if (result.mode === "check") {
if (!result.corrected?.trim() || !result.verdict?.trim()) {
throw new ApiError(t("api.noHomework"), "bad_response");
}
} else {
const steps = result.steps?.filter((step) => step && typeof step.title === "string");
if (!steps || steps.length < 2) throw new ApiError(t("api.noHomework"), "bad_response");
@ -242,6 +258,35 @@ export async function generateHomework(
return { homework: result, meta };
}
/** Follow-up on a finished result: shorter, more detail, or explain again. */
export async function refineHomework(input: {
instruction: "shorter" | "more" | "explain";
text: string;
context?: string;
signal?: AbortSignal;
}): Promise<string> {
let response: Response;
try {
response = await fetch("/api/homework/refine", {
method: "POST",
headers: { "Content-Type": "application/json", ...authHeaders(), ...localeHeader() },
body: JSON.stringify({ instruction: input.instruction, text: input.text, context: input.context }),
signal: input.signal,
});
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
throw new ApiError(t("api.cancelled"), "aborted");
}
throw new ApiError(t("api.unreachable"), "network");
}
const body = (await response.json().catch(() => null)) as { text?: string; error?: string; kind?: string } | null;
if (!response.ok) {
throw new ApiError(body?.error ?? t("api.httpError", { status: response.status }), body?.kind);
}
if (!body?.text?.trim()) throw new ApiError(t("api.noHomework"), "bad_response");
return body.text.trim();
}
export async function gradeAnswer(input: {
question: string;
referenceAnswer: string;

View file

@ -30,6 +30,7 @@ export type ServerConfig = {
grade: number;
homework: number;
health: number;
refine: number;
sync: number;
push: number;
};
@ -118,6 +119,8 @@ export function getServerConfig(): ServerConfig {
// Homework is one to two model calls, like a deck.
homework: positiveInt(process.env.RATE_LIMIT_HOMEWORK, 12),
health: positiveInt(process.env.RATE_LIMIT_HEALTH, 90),
// Homework follow-ups (short rewrites) can be used more freely.
refine: positiveInt(process.env.RATE_LIMIT_REFINE, 30),
// Sync is cheap (no model calls) but shouldn't be hammered.
sync: positiveInt(process.env.RATE_LIMIT_SYNC, 60),
// Push subscription changes and test sends.

36
lib/deck-seed.ts Normal file
View file

@ -0,0 +1,36 @@
"use client";
/**
* Hand-off from a homework result to the New deck flow: the content travels in
* sessionStorage and the wizard picks it up on mount, with the options step
* pre-filled. One-shot, like the share-target payload.
*/
export type DeckSeed = {
text: string;
difficulty?: "easy" | "medium" | "hard";
count?: number;
};
const KEY = "freezmo.deck.seed.v1";
export function setDeckSeed(seed: DeckSeed): void {
try {
window.sessionStorage.setItem(KEY, JSON.stringify(seed));
} catch {
/* private mode */
}
}
export function takeDeckSeed(): DeckSeed | null {
try {
const raw = window.sessionStorage.getItem(KEY);
if (!raw) return null;
window.sessionStorage.removeItem(KEY);
const parsed = JSON.parse(raw) as DeckSeed;
if (typeof parsed.text !== "string" || !parsed.text.trim()) return null;
return parsed;
} catch {
return null;
}
}

View file

@ -117,7 +117,18 @@ export function getHomework(id: string): HomeworkRecord | undefined {
/** Everything needed to store a result (the route's payload plus the source). */
export type NewHomeworkResult =
| { mode: "make"; title: string; language?: string; sourceText: string; answer: string }
| { mode: "guide"; title: string; language?: string; sourceText: string; steps: HomeworkStep[] };
| { mode: "guide"; title: string; language?: string; sourceText: string; steps: HomeworkStep[] }
| {
mode: "check";
title: string;
language?: string;
sourceText: string;
verdict: string;
correct: string[];
wrong: Array<{ issue: string; why: string; fix: string }>;
corrected: string;
attempt?: string;
};
function buildRecord(
base: { id: string; createdAt: number; updatedAt: number },
@ -130,6 +141,17 @@ function buildRecord(
sourceText: input.sourceText.slice(0, MAX_STORED_SOURCE),
};
if (input.mode === "make") return { ...shared, mode: "make", answer: input.answer };
if (input.mode === "check") {
return {
...shared,
mode: "check",
verdict: input.verdict,
correct: input.correct,
wrong: input.wrong,
corrected: input.corrected,
attempt: input.attempt?.slice(0, 20_000),
};
}
return { ...shared, mode: "guide", steps: input.steps };
}
@ -167,7 +189,9 @@ export function assembleSolution(record: HomeworkGuideRecord): string {
/** Label for a row in the history list. */
export function homeworkModeLabelKey(mode: HomeworkMode): string {
return mode === "make" ? "homework.history.made" : "homework.history.guide";
if (mode === "make") return "homework.history.made";
if (mode === "check") return "homework.history.checked";
return "homework.history.guide";
}
export type { HomeworkGuideRecord };

View file

@ -856,6 +856,33 @@ const en: Record<string, string> = {
"settings.reminderDenied": "Notifications are blocked in your browser settings.",
"settings.reminderError": "Could not set up reminders.",
"library.learningToday": "Today: {n} / {goal}",
/* homework: check my work + follow-ups ----------------------------- */
"homework.mode.check": "Check my work",
"homework.mode.checkDesc": "Mark your own attempt: what's right, what's wrong, and the corrected version.",
"homework.solution.label": "Your solution",
"homework.solution.placeholder": "Paste your own attempt here — the assignment is above.",
"homework.solution.note": "Type or paste your solution as text (photos of handwritten work aren't supported yet).",
"homework.solution.required": "Add your solution so there is something to check.",
"homework.check.title": "Checked",
"homework.check.verdict": "Verdict",
"homework.check.correct": "What's right",
"homework.check.wrong": "What needs fixing",
"homework.check.fix": "Fix",
"homework.check.corrected": "Corrected version",
"homework.history.checked": "Checked",
"homework.loading.checking": "Checking your attempt…",
"homework.followup.shorter": "Shorter",
"homework.followup.more": "More detail",
"homework.followup.explain": "Explain differently",
"homework.followup.redo": "Redo",
"homework.followup.step": "Explain step {n} differently",
"homework.followup.working": "Rewriting…",
"homework.followup.failed": "That didn't work. Try again.",
"homework.followup.restore": "Original",
"homework.toDeck": "Turn into a study deck",
"homework.toDeckNote": "Opens New deck with this content and the options pre-filled.",
"server.needSolution": "Add your own solution so it can be checked.",
"doc.readingPage": "Reading page {n} of {total}…",
"doc.readingWord": "Reading the Word document…",
"doc.readingSlides": "Reading the slides…",
@ -1658,6 +1685,33 @@ const cs: Record<string, string> = {
"settings.reminderDenied": "Oznámení jsou v nastavení prohlížeče zablokovaná.",
"settings.reminderError": "Připomínky se nepodařilo nastavit.",
"library.learningToday": "Dnes: {n} / {goal}",
/* homework: check my work + follow-ups ----------------------------- */
"homework.mode.check": "Zkontrolovat moje řešení",
"homework.mode.checkDesc": "Oznámkuj svůj postup: co je správně, co špatně a opravená verze.",
"homework.solution.label": "Tvoje řešení",
"homework.solution.placeholder": "Sem vlož svůj postup — zadání je nahoře.",
"homework.solution.note": "Řešení napiš nebo vlož jako text (fotky ručně psaného postupu zatím nejdou).",
"homework.solution.required": "Přidej svoje řešení, ať je co zkontrolovat.",
"homework.check.title": "Zkontrolováno",
"homework.check.verdict": "Hodnocení",
"homework.check.correct": "Co je správně",
"homework.check.wrong": "Co opravit",
"homework.check.fix": "Oprava",
"homework.check.corrected": "Opravená verze",
"homework.history.checked": "Zkontrolováno",
"homework.loading.checking": "Kontroluji tvůj postup…",
"homework.followup.shorter": "Kratší",
"homework.followup.more": "Více detailů",
"homework.followup.explain": "Vysvětlit jinak",
"homework.followup.redo": "Znovu",
"homework.followup.step": "Vysvětlit krok {n} jinak",
"homework.followup.working": "Přepisuji…",
"homework.followup.failed": "Nepovedlo se. Zkus to znovu.",
"homework.followup.restore": "Původní",
"homework.toDeck": "Převést na studijní sadu",
"homework.toDeckNote": "Otevře Novou sadu s tímto obsahem a předvyplněnými možnostmi.",
"server.needSolution": "Přidej svoje řešení, ať se dá zkontrolovat.",
"doc.readingPage": "Čtu stranu {n} z {total}…",
"doc.readingWord": "Čtu dokument Word…",
"doc.readingSlides": "Čtu snímky…",

View file

@ -287,6 +287,86 @@ export function buildHomeworkGuideUserPrompt(input: { assignment: string; langua
].join("\n");
}
export const HOMEWORK_CHECK_SYSTEM_PROMPT = `You are Freezmo's homework checker. The student gives you an assignment and their OWN attempt at solving it. You mark it like a fair, encouraging teacher: say what is right, what is wrong and why, and give the corrected version.
${HOMEWORK_BASE_RULES}
Checking rules:
- Judge ONLY against the assignment. An unasked-for extra (a date, a comment, an alternative method) is never an error — leave it alone.
- "verdict": 1-2 sentences, the overall picture (e.g. "Two of the three equations are right; the sign flips in the second one.").
- "correct": short bullet points for what is genuinely right, each naming the part of the assignment it refers to. Empty array when nothing is right.
- "wrong": one entry per actual mistake: "issue" names the mistake briefly, "why" explains what goes wrong, "fix" says what to do instead. Do not repeat the same mistake twice.
- "corrected": the student's solution with every mistake fixed, as the finished homework (plain text, numbered like the assignment). Keep everything the student got right as they wrote it.
- If the attempt is empty or does not address the assignment at all, say that plainly in the verdict, leave "wrong" empty and put the full solution in "corrected".
${LANGUAGE_RULES}
Required JSON shape:
{
"title": "short title of the assignment, in the assignment's language, max 10 words",
"language": "English name of the language you wrote in, e.g. Czech",
"verdict": "1-2 sentences",
"correct": ["what is right"],
"wrong": [{ "issue": "the mistake", "why": "why it is wrong", "fix": "what to do instead" }],
"corrected": "the student's solution with every mistake fixed"
}`;
export function buildHomeworkCheckUserPrompt(input: {
assignment: string;
solution: string;
language?: string;
}): string {
return [
"Check this attempt against the assignment.",
input.language
? `LANGUAGE: write everything in ${input.language}. Do not translate into English.`
: "LANGUAGE: detect the language of the assignment below and write everything in that same language.",
"",
"=== THE ASSIGNMENT (the only source of what has to be done) ===",
input.assignment.slice(0, 32_000),
"=== END OF ASSIGNMENT ===",
"",
"=== THE STUDENT'S ATTEMPT (this is what you are marking) ===",
input.solution.slice(0, 32_000),
"=== END OF ATTEMPT ===",
"",
"Respond with the JSON object only.",
].join("\n");
}
export const REFINE_SYSTEM_PROMPT = `You are Freezmo's homework assistant. You rewrite one piece of already-finished homework exactly as asked — shorter, more detailed, or a step explained differently.
Rules:
- Keep the language of the text you are given. Never translate.
- Keep every fact, number and answer correct and consistent with the text; never invent new content that changes the answer.
- Plain text only (short lines, numbered or dashed lists; no markdown symbols).
- Output the rewritten text only: no greeting, no commentary, no "here is", no mention of these instructions.`;
export function buildRefineUserPrompt(input: {
instruction: "shorter" | "more" | "explain";
text: string;
context?: string;
}): string {
const task =
input.instruction === "shorter"
? "Rewrite it SHORTER: keep every answer but cut the wording to the essentials."
: input.instruction === "more"
? "Rewrite it with MORE DETAIL: keep the same answers but explain the reasoning a little more, so the student understands why."
: "Explain this step DIFFERENTLY: a fresh, simpler explanation of the same step, with a small concrete example, so it clicks this time.";
return [
task,
input.context ? `CONTEXT (the assignment): ${input.context.slice(0, 2000)}` : "",
"",
"=== THE TEXT ===",
input.text.slice(0, 16_000),
"=== END OF TEXT ===",
"",
"Reply with the rewritten text only.",
]
.filter(Boolean)
.join("\n");
}
/* ------------------------------------------------------------------ */
/* Retry helper */
/* ------------------------------------------------------------------ */

View file

@ -139,9 +139,27 @@ export const gradeResultSchema = z.object({
/* Make my homework */
/* ------------------------------------------------------------------ */
export const homeworkModeSchema = z.enum(["make", "guide"]);
export const homeworkModeSchema = z.enum(["make", "guide", "check"]);
export type HomeworkMode = z.infer<typeof homeworkModeSchema>;
export const checkIssueSchema = z.object({
issue: z.string().min(1).max(600),
why: z.string().min(1).max(600),
fix: z.string().max(600).default(""),
});
export type CheckIssue = z.infer<typeof checkIssueSchema>;
/** Mode 3: feedback on the student's own solution. */
export const homeworkCheckSchema = z.object({
title: z.string().min(1).max(200),
language: z.string().max(60).optional(),
verdict: z.string().min(1).max(600),
correct: z.array(z.string().max(600)).max(12).default([]),
wrong: z.array(checkIssueSchema).max(12).default([]),
corrected: z.string().min(1).max(20_000),
});
export type HomeworkCheck = z.infer<typeof homeworkCheckSchema>;
/** Mode 1 — the finished homework, nothing else. */
export const homeworkMakeSchema = z.object({
title: z.string().min(1).max(200),
@ -219,10 +237,21 @@ export const homeworkRecordSchema = z.discriminatedUnion("mode", [
mode: z.literal("guide"),
steps: z.array(homeworkStepStoredSchema).min(2).max(10),
}),
z.object({
...homeworkBaseFields,
mode: z.literal("check"),
verdict: z.string().min(1).max(600),
correct: z.array(z.string().max(600)).max(12),
wrong: z.array(checkIssueSchema).max(12),
corrected: z.string().min(1).max(20_000),
/** The student's own attempt, so "Redo" can re-check the same work. */
attempt: z.string().max(20_000).optional(),
}),
]);
export type HomeworkRecord = z.infer<typeof homeworkRecordSchema>;
export type HomeworkMakeRecord = Extract<HomeworkRecord, { mode: "make" }>;
export type HomeworkGuideRecord = Extract<HomeworkRecord, { mode: "guide" }>;
export type HomeworkCheckRecord = Extract<HomeworkRecord, { mode: "check" }>;
/* ------------------------------------------------------------------ */
/* Client-side deck types (activity + id/xp, plus storage metadata) */

View file

@ -58,7 +58,9 @@ export async function shareHomework(record: HomeworkRecord): Promise<void> {
const text =
record.mode === "make"
? record.answer
: record.steps.map((step, index) => `${index + 1}. ${step.title}`).join("\n");
: record.mode === "check"
? record.corrected
: record.steps.map((step, index) => `${index + 1}. ${step.title}`).join("\n");
try {
if (nav.share) {
await nav.share({ title: record.title, text });