Phase 2: learning engine — history, SRS review, mistakes, stats, exam, reminders

Answer history and schedules
- Every answer is recorded (deck, activity, topic, correct/wrong, time)
  in a versioned IndexedDB log (freezmo-learning v1, stores answers +
  srs), mirrored in memory for synchronous React reads and capped at
  20k answers (oldest pruned). StudyFeed records as it grades.
- Light SM-2 schedule per activity: 1 day, 3 days, then interval x
  ease; wrong answers return in ten minutes with the ease nudged down.

Review, mistakes, stats
- Library 'Smart review' card: due count, mistakes count (latest
  answer wrong — clears itself when answered correctly), today's goal
  progress. Review sessions freeze their item list at start.
- New /review (due across decks, ?mistakes=1 global or ?deck= per
  deck) built on a shared SessionFeed with the study feed's snap/keyboard
  mechanics, wake lock and a results slide.
- New /stats: day streak, five-week calendar, accuracy per subject and
  per deck, weakest topics (>=3 answers), daily-goal progress.
- Daily goal configurable in Settings (10/20/30/50).

Exam mode
- New /exam: pick decks, question count, optional timer; no hints
  (a NoHints context hides hint rows and the self-check escape);
  results with score, per-deck and per-topic breakdown; wrong answers
  land in the mistakes set through the normal history.

Reminders (conditional)
- VAPID-gated: GET/POST/DELETE /api/push and POST /api/push/test with
  the standard route pattern, RATE_LIMIT_PUSH, zod, auth before body.
  Subscriptions stored server-side as endpoint + keys only (PUSH_DIR).
  UI hidden entirely without keys; iOS install note; service worker
  push/notificationclick handlers; scripts/send-reminders.mjs for a
  cron/systemd timer; web-push added as the one server dependency.

Verified: new phase-2 suite 42/42 (history/SRS shapes from a real study
run, mistakes clearing, review session, stats math, exam timer and
breakdowns, VAPID-less hidden state plus the configured
subscribe/validate/test/delete path against a second instance) plus
regression 74/74, mobile 52/52, wizard 80/80, declutter 116/116,
animation 8/8, native 35/35, phase1 38/38, i18n 25/25. Key parity
709/709. Lint + build clean. SW freezmo-v26.
This commit is contained in:
Martin Dvořák 2026-10-10 14:15:21 +00:00
commit e4a8e0ba01
33 changed files with 2504 additions and 47 deletions

View file

@ -62,6 +62,17 @@ no database, no data leaving your machine except the requests you send to Ollama
study sessions and homework guides; wizard drafts survive a reload or an OS kill; re-tapping
the active tab scrolls to the top; an offline pill appears and generation is disabled with
a reason until the connection is back.
- **Learning engine**: every answer is recorded (deck, activity, topic, correct/wrong, time) in an
IndexedDB log, and each activity gets a light **SM-2 schedule** — correct answers come back after
1 day, then 3, then interval × ease; wrong ones after ten minutes. The Library shows a **Smart
review** card with the due count, a **Mistakes** count (activities whose latest answer was wrong —
they clear themselves once answered correctly) and today's **daily goal** progress. A **Progress**
screen (drawer) has the day streak, a five-week calendar, accuracy per subject and per deck and
the weakest topics. **Exam mode** (drawer) takes one or more decks, a question count and an
optional timer, hides hints and shows a per-deck/per-topic breakdown at the end. **Daily
reminders** appear in Settings only when the server has VAPID keys configured; subscriptions are
stored as endpoint + keys only, and `node scripts/send-reminders.mjs` (cron/systemd timer) does
the sending.
- **Backup, sync and limits**: *Settings → Data & backup* exports **everything** (decks, folders,
homework, progress, safe settings — never the API key) to one JSON file and imports a backup with
a preview and a merge/replace choice; a gentle, dismissible nudge appears after 5 new decks or 14
@ -414,6 +425,7 @@ client IP with a sliding window. Everything is configurable in `.env.local`:
| `POST /api/grade` | 40 per 5 minutes | `RATE_LIMIT_GRADE` |
| `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` |
| window for all of them | 5 minutes | `RATE_LIMIT_WINDOW_MS` |
When a limit is hit the route answers **429** with a `Retry-After` header and a friendly message
@ -713,6 +725,10 @@ scripts/
| `POST /api/sync` | `{ id, envelope }` (opaque AES-GCM blob) | `{ updatedAt }` — 413 over `SYNC_MAX_BYTES`, 507 over `SYNC_TOTAL_BYTES` |
| `GET /api/sync?id=` | – | `{ updatedAt, envelope }` or 404 |
| `DELETE /api/sync?id=` | – | `{ ok: true }` (revokes the synced copy) |
| `GET /api/push` | – | `{ enabled, publicKey? }` — reminders are hidden when `enabled` is false |
| `POST /api/push` | `{ endpoint, keys }` | `{ ok, count }` (stored server-side, endpoint + keys only) |
| `DELETE /api/push?endpoint=` | – | `{ ok, count }` |
| `POST /api/push/test` | `{ endpoint }` | `{ ok: true }` or 502 when the push service refuses |
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
@ -766,6 +782,12 @@ grading, regeneration and “add more” (and the assignment language for homewo
`SYNC_DIR` (default `.freezmo/sync`), can't decrypt it, and never receives the passphrase or the
key. Deleting the synced copy is one button in Settings (and works even on a device that can't
unlock the data).
- **Answer history lives in IndexedDB** (`freezmo-learning`, versioned stores `answers` + `srs`),
because an event log would eventually fill `localStorage`; the log is capped at 20 000 answers
(oldest pruned). Backups and sync include it, so progress survives a device move. Push
subscriptions are the only reminder state on the server (`PUSH_DIR`, endpoint + keys only) and
are removed when you turn reminders off — or automatically when the push service reports them
gone.
---
@ -778,6 +800,7 @@ grading, regeneration and “add more” (and the assignment language for homewo
| PDF uploaded but no activities about its content | The PDF is probably image-only (a scan). Freezmo renders such pages and sends them to the vision model — check the card note (“N scanned pages were turned into images”); if it says “No readable text”, the scan may be too low quality. |
| Deck came back in English even though the notes are not | Make sure both model calls succeed (a failed vision step falls back to raw text). The prompts force the notes' language; if you still see translation, switch `TEXT_MODEL` to a stronger instruction-follower such as `gpt-oss:120b-cloud`. |
| “You've run out of your free monthly Ollama usage” | The account's free monthly usage is used up — this is not a short-term rate limit. Upgrade the account at `ollama.com/settings`, or create a new account and paste its key in Settings (the button jumps straight to the field). “Ollama is busy” is the short-term variant: just press Retry. |
| Reminders are missing from Settings | They only appear when `VAPID_PUBLIC_KEY` and `VAPID_PRIVATE_KEY` are set in `.env.local` (generate with `npx web-push generate-vapid-keys`). On iPhone, add Freezmo to the Home Screen first — Safari tabs can't receive push. |
| “Add your Ollama API key first” | Bring-your-own-key mode is active. Paste a key from <https://ollama.com/settings/keys> on the key gate (or in the ⚙️ dialog) — it is stored only in your browser. |
| “Ollama rejected this API key” | The key is invalid or revoked. Create a new one and paste it again; the check uses a real 1-token request, so a green tick means it truly works. |
| “this model is not included in your free usage” | The key works, but the model is outside your plan. Use one of the ✅ models in the table above, or add credits at <https://ollama.com/settings>. |
@ -1036,6 +1059,21 @@ Things I chose while building, with the reasoning:
phase-1 suite (38 checks, including the real route mapping against a stub Ollama returning a
monthly-limit body, and a two-device sync round-trip).
41. **The learning log is an event log, so it lives in IndexedDB.** Decks and homework stay in
`localStorage` (small, always loaded), but answers + SRS schedules go to a versioned
`freezmo-learning` database with an in-memory mirror so React can read them synchronously via
`useSyncExternalStore`; writes update the mirror first and persist asynchronously, and the log
is capped at 20 000 answers. The schedule is deliberately simple (1 day → 3 days → interval ×
ease; wrong = ten minutes, ease −0.2): predictable beats clever, and the mistakes set needs no
storage at all — it is *the latest answer per activity being wrong*, so it clears itself the
moment the activity is answered correctly. Review and exam sessions **freeze their item list at
start** (a bug the phase-2 suite caught: answering a mistake removed it from the live list and
collapsed the session mid-run). Push reminders are all-or-nothing: no VAPID keys → no UI, no
endpoints, nothing to misconfigure; `web-push` is the one added server dependency and the daily
send is an explicit cron/timer script. Verified by the phase-2 suite (42 checks: history and
SRS shapes, the review/mistakes sessions, stats math, exam with timer and breakdowns, the
VAPID-less hidden state and the configured subscribe/validate/test/delete path).
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