Report question: skip a broken card and tell the server

A generated matching card arrived on a phone with three identical
definitions (the generation guards now drop those), but there was no way
to get past a bad card from inside a session. The feed chrome (deck
study, review, mistakes and exam) now has the red "!" control: one tap
records the question, skips to the next card and shows an Undo toast.

- lib/question-reports.ts: the local list (freezmo.reports.v1, zod on
  read, capped 200) is the skip state — reported cards leave every
  session type, count as "not attempted" in the results instead of
  wrong, and Undo takes a report back. A deck where everything is
  reported stays studiable so it can be regenerated.
- POST /api/reports appends one JSON line per report to
  REPORTS_DIR/reports.jsonl (shared/freezmo/reports in production, the
  dev data dir in dev). Deliberately write-only: the question text is
  readable on the server (that is the point — prompt tuning) but never
  over HTTP, since the app has no auth. Best effort: offline reports stay
  local.
- The button: a red outlined rounded square with "!", the chrome's
  control size, in both feed headers (hidden on the results slide).
- config: REPORTS_DIR + RATE_LIMIT_REPORTS (20); the deploy creates the
  shared reports dir; the dev unit and AGENTS.md note the new server
  data location.
- Suites: phase-1 (append + validation + no GET), phase-2 (button, toast,
  server POST, local copy, skip counter, hidden next session, Undo, exam
  pool). SW -> freezmo-v38.
This commit is contained in:
Martin Dvořák 2026-10-12 07:12:55 +00:00
commit 9cea13496a
16 changed files with 496 additions and 15 deletions

View file

@ -867,6 +867,7 @@ scripts/
| `POST /api/grade` | `{ question, referenceAnswer, keyPoints, userAnswer, language? }` | `{ correct, score, feedback, missing }` |
| `POST /api/chat` | `{ model?, messages: [{role,content}], context?: [{deckId,deckTitle,section,text}] }` | NDJSON stream: `{type:"token"}`, `{type:"thinking"}`, `{type:"done",model}`, `{type:"error"}` |
| `POST /api/chat/extract` | `{ images?: string[], text? }` | `{ text }` — attachments reduced to plain text via the vision step |
| `POST /api/reports` | `{ deckId, deckTitle?, activityId, type, question, at }` | `{ ok: true }` — appended as one JSON line to `REPORTS_DIR/reports.jsonl` (write-only; see decision 52) |
| `GET /api/health` | – | `{ ok, reachable, mode, baseUrl, hasApiKey, vision, text, chatModels, latencyMs, modelCount, error }` |
| `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 |
@ -928,6 +929,11 @@ 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).
- **Question reports** (the red **!** in the study feed) keep the skip state in this browser
(`freezmo.reports.v1`) and append one JSON line per report to `REPORTS_DIR/reports.jsonl`
(default `.freezmo/reports`, `shared/freezmo/reports` in production). Question text is part of
the report on purpose — it is what makes the file useful for tuning the generation prompts — and
the route is deliberately write-only over HTTP.
- **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
@ -1349,6 +1355,17 @@ Things I chose while building, with the reasoning:
phase-1 suite (43 checks, including the real route mapping against a stub Ollama returning a
monthly-limit body, a two-device sync round-trip, and the generation guards below).
52. **Reporting a question is local-first, with a server copy for the operator.** The red "!" in
the feed chrome records the question in this browser — that local list is the skip state, so the
card leaves study, review, mistakes and exam sessions, and Undo in the toast brings it back
(the server file is append-only). It also POSTs a copy to `/api/reports`, which appends one JSON
line to `shared/freezmo/reports/reports.jsonl`. The app has no auth, so the route is write-only:
the question text is readable on the server (that is the point — prompt tuning) but never over
HTTP. A reported card counts as "not attempted" in the results rather than wrong, and a deck
where everything is reported stays studiable so it can be regenerated. Verified by the phase-1
suite (append, validation, no GET) and the phase-2 suite (button, toast, server POST, local
copy, skip, hidden next session, Undo, exam pool).
51. **Cards with repeated wording never ship.** The model sometimes pads a matching card with one
definition copied across several terms (a Czech literature deck arrived on a phone with three
identical definitions, so the card could not be solved by knowing the material), or repeats an