Add “Make my homework”, and finish the mobile polish pass

Homework (/homework — entry points in the drawer and on the create screen)

- Assignment intake reuses the study pipeline: pasted text, photos
  (camera/picker, compressed in the browser) and documents (PDF/DOCX/PPTX/
  ODT/text) through lib/image + lib/documents, the same vision step and the
  same language guard. Raw files never leave the device — only text and JPEG
  data URLs reach the server.
- Two large choice cards: “Just make it” returns the finished homework
  (answer only, plain text, copy + make it again); “Explain how to make this
  homework” builds an interactive guide.
- The guide is one step per screen in the study-feed snap layout:
  explanation, three progressive hints (nudge -> hint -> the answer) and an
  optional checkpoint rendered by the existing activity components (graded
  through /api/grade). The last screen assembles the solution from the step
  answers, with a copy button. Checkpoints carry xp 0 so the guide stays free
  of XP/streak chrome.
- Results live in freezmo.homework.v1 with a small history (open/delete);
  “Make it again” re-runs from the stored assignment text (the vision
  transcription when photos were used) — no original files are stored.
- The answer and the guide are written in the assignment's language, never
  translated into the UI language.

Server

- POST /api/homework: NDJSON stage events like /api/generate, the same
  http-guard, auth checked before parsing, and its own RATE_LIMIT_HOMEWORK
  (documented in .env.example + the README table).
- New prompts in lib/prompts.ts and zod schemas in lib/schemas.ts with the
  same single repair retry; TEXT_MODEL for content, VISION_MODEL only for
  photos. New shared client pieces: useNotesIntake, lib/homework store,
  lib/clipboard, and a shared NDJSON reader in lib/client-api.

Also finishing the mobile polish pass from the previous request

- Phone ice scene: the top-left orb moved behind the glass header (it sat
  behind the page heading/subtitle). The settings sheet's scroll cue is now a
  bare chevron inside a content fade (mask) instead of a chip over the status
  banner, and the API key card's note/disclosure/actions are tightened.
- theme-color now follows the in-app theme (SSR metas still fall back to the
  OS preference); apple-mobile-web-app-status-bar-style stays “default”
  because iOS fixes the status-bar text colour at launch.

Verified: 78/78 homework checks (text/photo/PDF, both modes, EN+CS, dark+light,
360/390px, copy, history persistence, storage privacy, API language, axe), plus
the existing suites re-run (mobile 47/47, pixel contrast 0 below AA, i18n 27/27,
desktop regression 72/72). Lint + build clean; service worker freezmo-v6.
This commit is contained in:
Martin Dvořák 2026-10-08 18:30:33 +00:00
commit ef2a6a3589
35 changed files with 2334 additions and 100 deletions

View file

@ -79,6 +79,21 @@ no database, no data leaving your machine except the requests you send to Ollama
and **Regenerate deck** — rebuilds the activities from the stored notes so you can keep drilling
until you're ready
**Make my homework**
- 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
- 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` with a small history you can reopen or delete; **Make it
again** re-runs from the stored assignment text — 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
**Activity types** (each one is its own React component, rendered from JSON by a `type` field)
| Type | Interaction |
@ -335,12 +350,13 @@ the two pipeline steps stay separate either way.
## Rate limits
Generation is expensive (two model calls per deck), so the API routes are limited per client IP with
a sliding window. Everything is configurable in `.env.local`:
Generation is expensive (one to two model calls per request), so the API routes are limited per
client IP with a sliding window. Everything is configurable in `.env.local`:
| Route | Default | Env var |
| --- | --- | --- |
| `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` |
| `GET /api/health` | 90 per 5 minutes | `RATE_LIMIT_HEALTH` |
| window for all of them | 5 minutes | `RATE_LIMIT_WINDOW_MS` |
@ -581,20 +597,23 @@ app/
page.tsx library ("Your icebox")
create/page.tsx upload screen
deck/[id]/page.tsx study feed for a saved deck
homework/page.tsx “Make my homework” (assignment in, homework or guide out)
api/generate/route.ts images/text in → NDJSON progress → deck JSON out
api/homework/route.ts assignment in → NDJSON progress → homework/guide JSON out
api/grade/route.ts short-answer grading
api/health/route.ts Ollama reachability + model availability
components/
activities/ one component per activity type + shared shell
Flashcard.tsx MultipleChoice.tsx TrueFalse.tsx FillBlank.tsx
Matching.tsx Ordering.tsx ShortAnswer.tsx shared.tsx index.tsx
homework/ make-my-homework screens (input, result, guide feed, history)
UploadScreen.tsx StudyFeed.tsx ResultsScreen.tsx DeckCard.tsx ApiKeyGate.tsx
InstallSheet.tsx TabBar.tsx ServiceWorkerRegistrar.tsx FolderBar.tsx
LibraryScreen.tsx SettingsDialog.tsx AppHeader.tsx ThemeToggle.tsx icons.tsx ui.tsx
lib/
ollama.ts server-only HTTP client (timeouts, auth, error mapping)
prompts.ts the vision, generation, grading and repair prompts
ai.ts the three pipeline steps with zod validation + retry
prompts.ts the vision, generation, grading, homework and repair prompts
ai.ts every pipeline step with zod validation + retry
schemas.ts zod schemas and shared types
config.ts env config, cloud/direct model-name normalisation
documents.ts client-side PDF/DOCX/PPTX/ODT/text extraction
@ -607,6 +626,9 @@ lib/
language.ts script/language checks that catch translated transcriptions
ui-events.ts tiny event bus (any screen can open the settings dialog)
storage.ts localStorage deck library, import/export
homework.ts localStorage homework results + solution assembly
use-notes-intake.ts shared client-side photo/document intake hook
clipboard.ts copy helper with a non-secure-context fallback
image.ts client-side validation, resize and compression
client-api.ts browser client for the API routes (NDJSON reader)
xp.ts utils.ts deck-ops.ts confetti.ts
@ -629,10 +651,11 @@ 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/grade` | `{ question, referenceAnswer, keyPoints, userAnswer, language? }` | `{ correct, score, feedback, missing }` |
| `GET /api/health` | – | `{ ok, reachable, mode, baseUrl, hasApiKey, vision, text, latencyMs, modelCount, error }` |
All three routes accept an optional **`x-ollama-key`** header (the browser's own key; required in
All four routes accept an optional **`x-ollama-key`** header (the browser's own key; required in
`user-key` auth mode). Missing keys answer **401** with `kind: "needs_key"`, a rejected key answers
`kind: "auth"` (or `kind: "no_credits"` for HTTP 402), and exceeding a rate limit answers **429**
with `Retry-After`. Keys are used for the single request and discarded.
@ -640,9 +663,9 @@ with `Retry-After`. Keys are used for the single request and discarded.
Images are sent as base64 data URLs; the server strips the prefix before handing them to Ollama.
Uploads are validated for type (photos and the document formats above), count (max 8 photos,
6 documents, 12 images) and size (max ~8 MB per image after compression, 40 MB per document) and the
client compresses aggressively, so a typical upload is 1–3 MB. `language` is optional on both routes:
the vision step fills it in automatically, and the UI passes the stored deck language for grading,
regeneration and “add more”.
client compresses aggressively, so a typical upload is 1–3 MB. `language` is optional wherever it
appears: the vision step fills it in automatically, and the UI passes the stored deck language for
grading, regeneration and “add more” (and the assignment language for homework).
---
@ -870,6 +893,17 @@ Things I chose while building, with the reasoning:
(hide the text → screenshot → worst-case background luminance per glyph rect): 0 runs below AA
across library and settings, both themes, both languages.
35. **“Make my homework” reuses the study pipeline instead of growing its own.** The intake is the
same client-side parsing as `/create` (lib/documents + lib/image, wrapped in `useNotesIntake`),
the photos go through the same `extractNotes` vision step and language guard, and the guide's
checkpoints are ordinary activity JSON rendered by the existing components — just a
`checkpoint` field on a step. The route streams NDJSON like `/api/generate` and has its own
rate limit. Two things are deliberately different: the finished answer and the guide are
written in the *assignment's* language (the interface language never leaks in), and the final
solution is assembled on the client from the step answers instead of asking the model for a
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.
14. **`gpt-oss:120b` does all content work — but it cannot read images** (`this model does not
support image input`), so `VISION_MODEL` has to stay a multimodal model (`gemma4:31b-cloud`).
Text-only inputs — pasted text, PDFs with a text layer, DOCX/PPTX/ODT — never touch the vision