diff --git a/.env.example b/.env.example index 21d4d3d..857ae98 100644 --- a/.env.example +++ b/.env.example @@ -42,6 +42,8 @@ TEXT_MODEL=gpt-oss:120b-cloud # RATE_LIMIT_GENERATE=12 # RATE_LIMIT_GRADE=40 # RATE_LIMIT_HOMEWORK=12 +# Homework follow-ups (short rewrites). +# RATE_LIMIT_REFINE=30 # RATE_LIMIT_HEALTH=90 # Encrypted sync (no model calls, but shouldn't be hammered). # RATE_LIMIT_SYNC=60 diff --git a/README.md b/README.md index 9b7cbeb..75c908f 100644 --- a/README.md +++ b/README.md @@ -123,20 +123,27 @@ no database, no data leaving your machine except the requests you send to Ollama - Same intake as a deck — paste the assignment, photograph it, or add a PDF/DOCX/PPTX/ODT/text file; photos go through the same vision model and language guard that notes do -- Two modes: **Just make it** returns the finished homework (the answer only, ready to copy), and - **Explain how to make this homework** turns it into a guided walkthrough +- Three modes: **Just make it** returns the finished homework (the answer only, ready to copy), + **Explain how to make this homework** turns it into a guided walkthrough, and **Check my work** + marks the student's own attempt — what's right, what's wrong and why, plus a corrected version + (the attempt is typed or pasted; photos of handwriting are not supported yet, and the UI says so) +- **Follow-ups on a result**: *Shorter*, *More detail* and back to the *Original* rewrite the + finished text in place; inside a guide every step has *Explain step N differently* +- **Turn into a study deck** on any result opens New deck with the content (the answer, the + corrected version or the assembled guide) and the options step pre-filled - On phones this is a **2-step flow**: the assignment first (same simplified input as the deck flow, with its own step headline, formats hint, big add area and empty placeholder), - then the two mode cards under a short preview — tapping a card starts it immediately, so - there is no separate button. Step 1 starts with *Cancel* and the flat action bar matches - the deck wizard. Desktop keeps the select-then-confirm layout. + then the mode cards under a short preview — tapping a card starts it immediately (the check + card first reveals the solution field), so there is no separate button. Step 1 starts with + *Cancel* and the flat action bar matches the deck wizard. Desktop keeps the select-then-confirm + layout. - The guide is one step per screen in the same snap-feed feel as the study feed: what to do and why, **three progressive hints** per step (nudge → hint → the full answer), and a **checkpoint** built from the normal activity components and graded through `/api/grade` - The last screen assembles the solution from the step answers, with a copy button - Results live in `freezmo.homework.v1` — the homework screen's history and the library's - **Homework** tab — where you can reopen or delete them; **Make it again** re-runs from the - stored assignment text, and original files are never saved + **Homework** tab — where you can reopen or delete them; **Redo** re-runs from the + stored assignment text (and the stored attempt for checks), and original files are never saved - The answer and the guide are always written in the **language of the assignment**, never translated into the interface language @@ -423,6 +430,7 @@ client IP with a sliding window. Everything is configurable in `.env.local`: | `POST /api/generate` | 12 per 5 minutes | `RATE_LIMIT_GENERATE` | | `POST /api/homework` | 12 per 5 minutes | `RATE_LIMIT_HOMEWORK` | | `POST /api/grade` | 40 per 5 minutes | `RATE_LIMIT_GRADE` | +| `POST /api/homework/refine` | 30 per 5 minutes | `RATE_LIMIT_REFINE` | | `GET /api/health` | 90 per 5 minutes | `RATE_LIMIT_HEALTH` | | `POST/GET/DELETE /api/sync` | 60 per 5 minutes | `RATE_LIMIT_SYNC` | | `GET/POST/DELETE /api/push`, `POST /api/push/test` | 30 per 5 minutes | `RATE_LIMIT_PUSH` | @@ -719,7 +727,8 @@ scripts/ | Route | Body | Returns | | --- | --- | --- | | `POST /api/generate` | `{ images?: string[], text?, difficulty, count?, focus?, language?, mode?, existing? }` | NDJSON stream: `{type:"stage"}`, `{type:"deck",deck,meta}`, `{type:"error"}` | -| `POST /api/homework` | `{ mode: "make" \| "guide", images?: string[], text?, language? }` | NDJSON stream: `{type:"stage"}`, `{type:"homework",homework,meta}`, `{type:"error"}` | +| `POST /api/homework` | `{ mode: "make" \| "guide" \| "check", images?: string[], text?, solution?, language? }` | NDJSON stream: `{type:"stage"}`, `{type:"homework",homework,meta}`, `{type:"error"}` | +| `POST /api/homework/refine` | `{ instruction: "shorter" \| "more" \| "explain", text, context?, language? }` | `{ text }` — one rewritten piece, plain text | | `POST /api/grade` | `{ question, referenceAnswer, keyPoints, userAnswer, language? }` | `{ correct, score, feedback, missing }` | | `GET /api/health` | – | `{ ok, reachable, mode, baseUrl, hasApiKey, vision, text, latencyMs, modelCount, error }` | | `POST /api/sync` | `{ id, envelope }` (opaque AES-GCM blob) | `{ updatedAt }` — 413 over `SYNC_MAX_BYTES`, 507 over `SYNC_TOTAL_BYTES` | @@ -993,6 +1002,22 @@ Things I chose while building, with the reasoning: second copy. Result records store the assignment text — the vision transcription when photos were used — so “Make it again” works without storing a single original file. +42. **Check my work is a checker, not a second writer.** The third homework mode sends the + assignment and the student's attempt to one model call with its own system prompt + (`HOMEWORK_CHECK_SYSTEM_PROMPT`): the output is a verdict, a list of what is right, a list of + issues with *why* and *fix*, and the corrected version. Unasked-for extras are explicitly + never errors, and the corrected version keeps everything the student got right as they wrote + it. The attempt is typed or pasted — supporting photos of handwriting would need a second + vision pass and a way to separate assignment from attempt in the same image, so the UI says + plainly that it isn't supported yet. Follow-ups are a separate, cheaper route + (`POST /api/homework/refine`, plain JSON, `RATE_LIMIT_REFINE`) shared by the result, the check + and each guide step; they rewrite one piece and never touch the stored record. “Turn into a + study deck” hands the content to the New deck flow through sessionStorage (one-shot, like the + share target), with the options step pre-filled. Verified by the phase-3 suite (20 checks + against a stub Ollama: the check payload, the missing-solution 400, refine validation, the + phone and desktop check flows, the shorter follow-up with restore, the guide's explain action, + and the deck hand-off). + 36. **The mobile declutter pass reshapes, it doesn't fork.** Phones (≤639px) get a 3-step deck flow and a 2-step homework flow that share the exact state and handlers with the desktop cards (one `UploadScreen` / one `HomeworkInput`, two layouts switched by `useIsPhone()`); diff --git a/app/api/homework/refine/route.ts b/app/api/homework/refine/route.ts new file mode 100644 index 0000000..8fc04ad --- /dev/null +++ b/app/api/homework/refine/route.ts @@ -0,0 +1,90 @@ +import { z } from "zod"; + +import { refineHomeworkText } from "@/lib/ai"; +import { authErrorKind, authErrorMessage, getServerConfig, resolveAuth, resolveModelName } from "@/lib/config"; +import { friendlyErrorMessage, OllamaError } from "@/lib/ollama"; +import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard"; +import { translate } from "@/lib/i18n-dict"; +import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +/** + * Homework follow-ups: rewrite one finished piece — shorter, more detail, or + * a step explained differently. One short model call, plain JSON back. + */ + +const requestSchema = z.object({ + instruction: z.enum(["shorter", "more", "explain"]), + text: z.string().min(1).max(20_000), + /** The assignment, for context (optional). */ + context: z.string().max(8_000).optional(), + language: z.string().max(60).optional(), +}); + +export async function POST(request: Request) { + const locale = requestLocale(request); + const crossOrigin = rejectCrossOrigin(request); + if (crossOrigin) return crossOrigin; + const oversized = rejectOversizedBody(request, 128 * 1024); + if (oversized) return oversized; + + const config = getServerConfig(); + const limit = rateLimit(`refine:${clientKey(request)}`, config.rateLimits.refine, config.rateLimits.windowMs); + if (!limit.allowed) return rateLimitResponse(limit, locale); + + const auth = resolveAuth(config, request); + if ("error" in auth) { + return Response.json( + { error: authErrorMessage(locale, auth.error), kind: authErrorKind(auth.error) }, + { status: 401 }, + ); + } + + let payload: unknown; + try { + payload = await request.json(); + } catch { + return Response.json({ error: translate(locale, "server.bodyNotJson") }, { status: 400 }); + } + + const parsed = requestSchema.safeParse(payload); + if (!parsed.success) { + return Response.json( + { + error: translate(locale, "server.invalidRequest", { + detail: parsed.error.issues + .slice(0, 3) + .map((issue) => `${issue.path.join(".") || "body"} ${issue.message}`) + .join("; "), + }), + }, + { status: 400 }, + ); + } + + try { + const text = await refineHomeworkText({ + model: config.textModel, + auth: auth.auth, + instruction: parsed.data.instruction, + text: parsed.data.text, + context: parsed.data.context, + }); + return Response.json( + { text, model: resolveModelName(config.textModel, auth.auth.directCloud) }, + { headers: { "Cache-Control": "no-store" } }, + ); + } catch (error) { + // Never log the text itself: it may contain the student's homework. + console.error("[api/homework/refine]", error instanceof OllamaError ? `${error.kind}: ${error.message}` : error); + return Response.json( + { + error: friendlyErrorMessage(error, auth.auth.baseUrl, locale), + kind: error instanceof OllamaError ? error.kind : "unknown", + }, + { status: 502 }, + ); + } +} diff --git a/app/api/homework/route.ts b/app/api/homework/route.ts index 6cb63ea..07f4086 100644 --- a/app/api/homework/route.ts +++ b/app/api/homework/route.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -import { buildHomeworkGuide, extractNotes, makeHomework, notesToText, type PipelineStage } from "@/lib/ai"; +import { buildHomeworkGuide, checkHomework, extractNotes, makeHomework, notesToText, type PipelineStage } from "@/lib/ai"; import { authErrorMessage, authErrorKind, getServerConfig, resolveAuth, resolveModelName } from "@/lib/config"; import { friendlyErrorMessage, OllamaError } from "@/lib/ollama"; import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard"; @@ -22,6 +22,8 @@ const requestSchema = z.object({ mode: homeworkModeSchema, images: z.array(z.string().min(32).max(MAX_IMAGE_CHARS)).max(16).optional(), text: z.string().max(120_000).optional(), + /** Mode "check": the student's own attempt at the assignment. */ + solution: z.string().max(120_000).optional(), /** Language of the assignment (usually detected by the vision step). */ language: z.string().max(60).optional(), }); @@ -80,12 +82,15 @@ export async function POST(request: Request) { ); } - const { mode, images, text, language } = parsed.data; + const { mode, images, text, solution, language } = parsed.data; const imageList = (images ?? []).map(stripDataUrlPrefix).filter(Boolean); if (imageList.length === 0 && !text?.trim()) { return Response.json({ error: translate(locale, "server.needAssignment") }, { status: 400 }); } + if (mode === "check" && !solution?.trim()) { + return Response.json({ error: translate(locale, "server.needSolution") }, { status: 400 }); + } const encode = encoder(); const abort = new AbortController(); @@ -128,7 +133,14 @@ export async function POST(request: Request) { send({ type: "stage", stage: "generation", - message: translate(locale, mode === "make" ? "homework.loading.writing" : "homework.loading.planning"), + message: translate( + locale, + mode === "make" + ? "homework.loading.writing" + : mode === "check" + ? "homework.loading.checking" + : "homework.loading.planning", + ), }); const meta = { @@ -138,7 +150,30 @@ export async function POST(request: Request) { textModel: resolveModelName(config.textModel, auth.auth.directCloud), }; - if (mode === "make") { + if (mode === "check") { + const result = await checkHomework({ + model: config.textModel, + auth: auth.auth, + assignment: assignmentText, + solution: solution!.trim(), + language: detectedLanguage, + signal: abort.signal, + }); + send({ + type: "homework", + homework: { + mode: "check", + title: result.title, + language: result.language || detectedLanguage, + verdict: result.verdict, + correct: result.correct, + wrong: result.wrong, + corrected: result.corrected, + sourceText: assignmentText, + }, + meta, + }); + } else if (mode === "make") { const result = await makeHomework({ model: config.textModel, auth: auth.auth, diff --git a/components/HomeworkCard.tsx b/components/HomeworkCard.tsx index 3cfdfa5..43019e7 100644 --- a/components/HomeworkCard.tsx +++ b/components/HomeworkCard.tsx @@ -41,10 +41,12 @@ export function HomeworkCard({ const preview = record.mode === "make" ? truncate(record.answer.replace(/\s+/g, " ").trim(), 150) - : truncate( - record.steps.map((step) => step.title).join(" · "), - 150, - ); + : record.mode === "check" + ? truncate(record.verdict.replace(/\s+/g, " ").trim(), 150) + : truncate( + record.steps.map((step) => step.title).join(" · "), + 150, + ); return ( 0) void addFiles(pending.files); }, [addFiles]); + /* Homework → "Turn into a study deck": content + options pre-filled. */ + useEffect(() => { + const seed = takeDeckSeed(); + if (!seed) return; + setText(seed.text); + if (seed.difficulty) setDifficulty(seed.difficulty); + if (seed.count) setCount(seed.count); + }, []); + /* Persist the draft while the user works (debounced, best-effort). */ useEffect(() => { if (phase === "loading") return; diff --git a/components/homework/FollowUpBar.tsx b/components/homework/FollowUpBar.tsx new file mode 100644 index 0000000..5d700df --- /dev/null +++ b/components/homework/FollowUpBar.tsx @@ -0,0 +1,76 @@ +"use client"; + +import { useState } from "react"; + +import { Button } from "@/components/ui"; +import { refineHomework } from "@/lib/client-api"; +import { useI18n } from "@/lib/i18n"; + +/** + * Follow-up actions for one finished piece of homework: shorter, more detail, + * or back to the original. Used on the make result and the corrected version + * of a check. + */ +export function FollowUpBar({ + text, + context, + onReplace, + onRestore, + canRestore, +}: { + text: string; + context?: string; + onReplace: (next: string) => void; + onRestore?: () => void; + canRestore?: boolean; +}) { + const { t } = useI18n(); + const [busy, setBusy] = useState<"shorter" | "more" | null>(null); + const [failed, setFailed] = useState(false); + + const run = async (instruction: "shorter" | "more") => { + if (busy) return; + setBusy(instruction); + setFailed(false); + try { + const next = await refineHomework({ instruction, text, context }); + onReplace(next); + } catch { + setFailed(true); + } finally { + setBusy(null); + } + }; + + return ( +
+ + + {busy ? {t("homework.followup.working")} : null} + {canRestore && onRestore ? ( + + ) : null} + {failed ? {t("homework.followup.failed")} : null} +
+ ); +} diff --git a/components/homework/HomeworkCheckResult.tsx b/components/homework/HomeworkCheckResult.tsx new file mode 100644 index 0000000..ad3ea2f --- /dev/null +++ b/components/homework/HomeworkCheckResult.tsx @@ -0,0 +1,113 @@ +"use client"; + +import { useState } from "react"; + +import { Button } from "@/components/ui"; +import { useI18n } from "@/lib/i18n"; +import type { HomeworkCheckRecord } from "@/lib/schemas"; + +import { CopyButton } from "./CopyButton"; +import { FollowUpBar } from "./FollowUpBar"; + +/** Mode 3 result: verdict, what's right, what needs fixing, corrected version. */ +export function HomeworkCheckResult({ + record, + onAgain, + onNew, + onToDeck, + againLoading, +}: { + record: HomeworkCheckRecord; + onAgain: () => void; + onNew: () => void; + onToDeck: () => void; + againLoading: boolean; +}) { + const { t } = useI18n(); + const [corrected, setCorrected] = useState(record.corrected); + + return ( +
+
+

+ {t("homework.check.title")} +

+

+ {record.title} +

+
+ +
+

+ {t("homework.check.verdict")} +

+

{record.verdict}

+
+ + {record.correct.length > 0 ? ( +
+

+ {t("homework.check.correct")} +

+
    + {record.correct.map((item, index) => ( +
  • {item}
  • + ))} +
+
+ ) : null} + + {record.wrong.length > 0 ? ( +
+

+ {t("homework.check.wrong")} +

+
    + {record.wrong.map((entry, index) => ( +
  • +

    {entry.issue}

    +

    {entry.why}

    + {entry.fix ? ( +

    + {t("homework.check.fix")}: + {entry.fix} +

    + ) : null} +
  • + ))} +
+
+ ) : null} + +
+

+ {t("homework.check.corrected")} +

+

{corrected}

+
+ + setCorrected(record.corrected)} + canRestore={corrected !== record.corrected} + /> +
+
+ +
+ + + +
+

{t("homework.toDeckNote")}

+
+ ); +} diff --git a/components/homework/HomeworkGuide.tsx b/components/homework/HomeworkGuide.tsx index 98dceda..0cc7996 100644 --- a/components/homework/HomeworkGuide.tsx +++ b/components/homework/HomeworkGuide.tsx @@ -11,6 +11,7 @@ import { assembleSolution } from "@/lib/homework"; import { useI18n } from "@/lib/i18n"; import type { HomeworkGuideRecord, HomeworkStep } from "@/lib/schemas"; import { useWakeLock } from "@/lib/use-wake-lock"; +import { refineHomework } from "@/lib/client-api"; import { CopyButton } from "./CopyButton"; @@ -25,12 +26,14 @@ export function HomeworkGuide({ onClose, onAgain, onNew, + onToDeck, againLoading, }: { record: HomeworkGuideRecord; onClose: () => void; onAgain: () => void; onNew: () => void; + onToDeck: () => void; againLoading: boolean; }) { const { t } = useI18n(); @@ -40,8 +43,30 @@ export function HomeworkGuide({ const slideCount = steps.length + 1; // + the solution slide const [index, setIndex] = useState(0); const [revealed, setRevealed] = useState>({}); + /** "Explain this step differently" overrides, per step index. */ + const [explained, setExplained] = useState>({}); + const [explaining, setExplaining] = useState(null); + const [explainFailed, setExplainFailed] = useState(null); const scrollRef = useRef(null); + const explainStep = async (stepIndex: number) => { + if (explaining !== null) return; + setExplaining(stepIndex); + setExplainFailed(null); + try { + const next = await refineHomework({ + instruction: "explain", + text: steps[stepIndex].explanation, + context: record.sourceText, + }); + setExplained((current) => ({ ...current, [stepIndex]: next })); + } catch { + setExplainFailed(stepIndex); + } finally { + setExplaining(null); + } + }; + const goTo = useCallback( (target: number) => { const container = scrollRef.current; @@ -164,8 +189,25 @@ export function HomeworkGuide({ {step.title}

- {step.explanation} + {explained[stepIndex] ?? step.explanation}

+
+ + {explainFailed === stepIndex ? ( + + {t("homework.followup.failed")} + + ) : null} +
{/* progressive hints */} @@ -242,12 +284,16 @@ export function HomeworkGuide({
+
+

{t("homework.toDeckNote")}

diff --git a/components/homework/HomeworkInput.tsx b/components/homework/HomeworkInput.tsx index db52f9a..a2c5e4a 100644 --- a/components/homework/HomeworkInput.tsx +++ b/components/homework/HomeworkInput.tsx @@ -4,7 +4,7 @@ import { motion, useReducedMotion } from "framer-motion"; import { useRouter } from "next/navigation"; import { useEffect, useRef, useState } from "react"; -import { CameraIcon, FolderIcon, PlayIcon, PlusIcon, SparklesIcon } from "@/components/icons"; +import { CameraIcon, CheckCircleIcon, FolderIcon, PlayIcon, PlusIcon, SparklesIcon } from "@/components/icons"; import { Button } from "@/components/ui"; import { WizardActionBar } from "@/components/WizardActionBar"; import { @@ -47,6 +47,7 @@ function describeExtraction( const MODES: Array<{ id: HomeworkMode; icon: React.ReactNode; titleKey: string; subtitleKey: string }> = [ { id: "make", icon: , titleKey: "homework.mode.make.title", subtitleKey: "homework.mode.make.subtitle" }, { id: "guide", icon: , titleKey: "homework.mode.guide.title", subtitleKey: "homework.mode.guide.subtitle" }, + { id: "check", icon: , titleKey: "homework.mode.check", subtitleKey: "homework.mode.checkDesc" }, ]; export function HomeworkInput({ @@ -54,6 +55,8 @@ export function HomeworkInput({ onModeChange, text, onTextChange, + solution, + onSolutionChange, intake, onSubmit, error, @@ -65,6 +68,9 @@ export function HomeworkInput({ onModeChange: (mode: HomeworkMode) => void; text: string; onTextChange: (text: string) => void; + /** Mode "check": the student's own attempt. */ + solution: string; + onSolutionChange: (solution: string) => void; intake: ReturnType; /** `modeOverride` lets the phone mode cards start the run they tapped. */ onSubmit: (modeOverride?: HomeworkMode) => void; @@ -80,6 +86,8 @@ export function HomeworkInput({ const [step, setStep] = useState(1); const [stepDir, setStepDir] = useState<1 | -1>(1); const [pasteOpen, setPasteOpen] = useState(false); + /** Phones: the check card reveals the solution field instead of starting. */ + const [checkOpen, setCheckOpen] = useState(false); const isPhone = useIsPhone(); const reduceMotion = useReducedMotion(); const online = useOnline(); @@ -176,6 +184,29 @@ export function HomeworkInput({ ) : null; + /* Shared solution field: typed/pasted only (documented in the note). */ + const solutionField = ( +
+ +