5bf6013460
Progressive lesson streaming via onSegment callback (fixes SSE for non-English users — locale was shadowed in lesson-reader useEffect). Adds: BullMQ workers, Redis stream buffer, token budget enforcement, Langfuse tracing, golden-eval runner, Playwright e2e scaffolding, lesson depth/locale/preferences schema, mastery map UI, admin panel (blueprints/users/reports/quality/misconceptions), image queries, source citations, view transitions, reading animations, i18n (next-intl), PDF export, surprise endpoint, and 402 passing unit tests. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
330 lines
36 KiB
Markdown
330 lines
36 KiB
Markdown
# Curio — Implementation Plan (12 features)
|
|
|
|
Audience: **Claude Sonnet**, executing one feature at a time. Each section is self-contained: goal, verified current state (with file refs), changes, tests, and acceptance criteria.
|
|
|
|
This plan was written after mapping the codebase on 2026-06-23. **Verify file:line refs before editing — code may have moved.**
|
|
|
|
## Global conventions (apply to every feature — non-negotiable)
|
|
|
|
These come from `CLAUDE.md` and the spec. Violating them is a bug:
|
|
|
|
1. **All LLM calls go through `src/lib/llm/client.ts`.** Never call a provider/AI-SDK model directly. Generator and grader must differ.
|
|
2. **Prompts live in the versioned registry** `src/lib/llm/prompts/index.ts` (`{ id, version, template }`). Never inline a prompt. Bump `version` on any semantic change.
|
|
3. **Contracts are Zod schemas** in `src/schemas/`, shared by LLM ↔ API ↔ UI. Validate LLM structured output against them.
|
|
4. **Serve path reads the buffer; jobs generate.** No synchronous generation in request handlers except the documented cold-start gate. Postgres is source of truth.
|
|
5. **Paid background jobs are idempotent** (idempotency key + durable `jobs` ledger row). A retry must never regenerate-and-rebill.
|
|
6. **Per-session token budget is enforced in code** (`src/lib/budget/index.ts`) — call `checkBudget` before any new paid LLM call, `recordUsage` after.
|
|
7. **Migrations are append-only.** Never edit an applied migration. Add a new one via `pnpm db:generate` then `pnpm db:migrate`. (Note: the migrations dir already contains a few accidental duplicate migrations — do not "fix" them, just add new ones.)
|
|
8. **i18n: every user-facing string goes in BOTH `messages/en.json` and `messages/fr.json`** under the right namespace, read via `useTranslations` (client) or `getTranslations` (server). No hardcoded copy.
|
|
9. **Tests with every change.** Unit tests mock the LLM (Vitest, deterministic fixtures). Real-model calls only in `tests/golden/`. Run `pnpm test` before declaring done.
|
|
10. **Design quality floor:** responsive, visible keyboard focus, reduced-motion respected, WCAG-AA contrast, dark-mode tokens. Reading surface stays calm/editorial; no gamification.
|
|
|
|
Stack reference: Next.js 15 App Router + React 19, Drizzle + Postgres/pgvector, Redis (ioredis + Upstash ratelimit), BullMQ workers (`jobs/`, run via `pnpm workers`), Vercel AI SDK, next-auth v5 (JWT), next-intl, nodemailer (SMTP), Langfuse. Vitest + Playwright.
|
|
|
|
---
|
|
|
|
## Suggested execution order
|
|
|
|
Dependency-aware. Do them in this order unless told otherwise:
|
|
|
|
1. **#7 (audit migration)** — small, de-risks the account story others build on.
|
|
2. **#10 (dark-mode polish)** — small, isolated; extracts the `setTheme` helper #13 reuses.
|
|
3. **#13 (settings upgrade)** — needs #10's `setTheme` helper; otherwise self-contained UI.
|
|
4. **#12 (review upgrade)** — extracts the shared grading hook from the lesson reader.
|
|
5. **#2 (content versioning)** — foundational; #1 and #3 lean on version/provenance.
|
|
6. **#1 (novel-misconception harvest loop)** — moat; table already exists.
|
|
7. **#3 (false-fail dashboard)** — consumes #1's labeled data + grades.
|
|
8. **#6 (multi-lesson journey)** — product; #5 and #11's due-zone build on journey/mastery state.
|
|
9. **#11 (mastery upgrade)** — surfaces due reviews; pairs with #6/#5.
|
|
10. **#5 (returning-learner home)** — product; needs #6's journey state.
|
|
11. **#9 (streaming generation)** — infra polish.
|
|
12. **#8 (due-review email digest)** — retention; independent, do last.
|
|
|
|
UI features (#11/#12/#13) are loosely ordered — they can move earlier if a UI pass is wanted sooner; only #13→#10 (theme helper) and #12→lesson-reader (shared hook) are hard dependencies.
|
|
|
|
Each feature is one focused PR/commit. Keep Route Handlers thin; logic in `src/lib/`.
|
|
|
|
---
|
|
|
|
## #7 — Anonymous → account progress migration (AUDIT + HARDEN)
|
|
|
|
**Status: already implemented.** This is an audit/harden task, NOT a build.
|
|
|
|
**Current state (verified):**
|
|
- `src/app/api/auth/migrate-session/route.ts` exists. In a transaction it reassigns `responses`, `mastery` (with on-conflict skip when the auth user already has that concept), and `journeyInstances` from `anonUserId` → `authUserId`, then renames the Redis `budget:{id}` key. Has `__tests__/`.
|
|
- Called from `src/app/auth/login/login-client.tsx` and `src/app/auth/register/page.tsx` after auth success; clears `sessionStorage` `curio_uid`.
|
|
|
|
**Goal:** make it correct and complete, not bigger.
|
|
|
|
**Changes / checks:**
|
|
1. **Mastery conflict policy.** Today conflicting concepts are *skipped* (anon progress dropped). Decide intended behavior: keep the higher `score` / more-recent `lastSeen` instead of silently dropping. If changing, do it in the `onConflictDoUpdate` and document why.
|
|
2. **Auth coverage.** Confirm migration fires for ALL sign-in paths: credentials login, register, AND OAuth (Google/GitHub/OIDC) first sign-in. OAuth users never hit `login-client.tsx`. Add a migration trigger on the OAuth callback path (e.g. a client effect on first authenticated landing, or a `signIn` event in `src/auth.ts` events) — without it, OAuth users lose anon progress.
|
|
3. **Idempotency / double-call.** Calling migrate twice (or with no anon data) must be a no-op, not an error. Verify.
|
|
4. **Validation & authz.** The route must take `anonUserId` from the request but `authUserId` strictly from the server session (never trust a client-supplied authUserId). Confirm a malicious `anonUserId` can only move data INTO the caller's own account.
|
|
5. **`accounts`/`sessions` tables** must not be touched by migration (those are auth-adapter owned).
|
|
|
|
**Tests:** extend `migrate-session/__tests__`: (a) conflict resolution keeps best mastery; (b) OAuth-path migration; (c) double-call no-op; (d) authz — session id always wins over body.
|
|
|
|
**Acceptance:** A user who learns anonymously, then signs up OR logs in via any provider, keeps all mastery/responses/journey. No data loss on concept conflicts. `pnpm test` green.
|
|
|
|
---
|
|
|
|
## #10 — Dark mode (AUDIT + POLISH)
|
|
|
|
**Status: already implemented.** Audit/polish task.
|
|
|
|
**Current state (verified):**
|
|
- `src/styles/tokens.css` has a full dark token block under `:root[data-theme='dark']` plus a `@media (prefers-color-scheme: dark)` fallback for `:root:not([data-theme='light'])`.
|
|
- `src/app/layout.tsx` runs a no-flash theme bootstrap (reads `localStorage('curio-theme')`, sets `data-theme` on `<html>`).
|
|
- `src/components/site-header.tsx` has a `ThemeToggle` cycling system → dark → light.
|
|
|
|
**Goal:** verified-correct dark mode on every surface + a settings entry.
|
|
|
|
**Changes:**
|
|
1. **Settings selector.** Add a theme control to `src/app/settings/settings-client.tsx` (System / Light / Dark) that writes the same `localStorage('curio-theme')` key + `data-theme` the header toggle uses (share one helper — extract `setTheme()` to e.g. `src/lib/theme.ts`). Add i18n keys under `settings.appearance.*` in both locales.
|
|
2. **Contrast audit (WCAG-AA, spec §7.5 non-negotiable).** Verify every diagnosis-state color in dark mode meets AA against its background: `--color-mastered/partial/misconception/uncertain` and their `-subtle` pairs, plus `--color-tutor` marginalia and accent on `--color-surface`. Fix any token that fails. This is the highest-risk part — the diagnosis colors carry meaning.
|
|
3. **Surface sweep.** Manually verify dark rendering on: home, learn/reading surface + marginalia, checkpoint input, review, mastery map, auth pages, admin, settings. Look for hardcoded colors (e.g. `#fff`) that ignore tokens — `grep -rn "#fff\|#000\|rgb(" src/app src/components src/styles` and replace with tokens.
|
|
4. **Persisted preference (optional).** If a user is logged in, persist theme in `users.preferences` (jsonb already exists) so it follows them across devices; localStorage remains the anon/no-flash source.
|
|
|
|
**Tests:** unit test the `setTheme` helper. Playwright: toggle to dark, assert `data-theme="dark"` on `<html>` and a known token's computed value.
|
|
|
|
**Acceptance:** Theme switchable from header AND settings; persists; no flash on reload; all diagnosis colors pass AA in both themes; no hardcoded non-token colors remain on core surfaces.
|
|
|
|
---
|
|
|
|
## #2 — Content versioning + lazy invalidation
|
|
|
|
**Goal:** when a blueprint's generator model or prompt version changes, mark its content stale and regenerate lazily (on next serve or via a job), without regenerate-and-rebill races. Spec §10 / invariant #10.
|
|
|
|
**Current state (verified):**
|
|
- `blueprints.modelVersion` (text) + `blueprints.contentVersion` (int, default 1). `segments.contentVersion` + `checkpoints.contentVersion` exist but are **never incremented** and **never read** (`generate-blueprint.ts:~85` writes `modelVersion = process.env.LLM_GENERATOR_MODEL`, `contentVersion = 1`).
|
|
- Prompt registry has versions (e.g. `GENERATE_LESSON` is v5) but nothing ties a blueprint to the prompt version it was built with.
|
|
- `jobs/promote-blueprint-job.ts` already invalidates the Redis cache after promotion — reuse that invalidation primitive.
|
|
|
|
**Define "current content signature":** a tuple `(generatorModel, generateLessonPromptVersion)`. Persist what each blueprint was built with; compare against current at serve/audit time.
|
|
|
|
**Changes:**
|
|
1. **Schema migration:** add `blueprints.promptVersion` (int) and `blueprints.staleAt` (timestamp, nullable). Optionally `blueprints.lastBuiltModel` if `modelVersion` semantics are ambiguous. Generate via `pnpm db:generate`.
|
|
2. **Stamp on generation:** in `src/lib/generation/generate-lesson.ts` / `generate-blueprint.ts`, write the current `prompts.GENERATE_LESSON.version` and generator model id at build time.
|
|
3. **Staleness check:** add `src/lib/generation/staleness.ts` exporting `isBlueprintStale(blueprint): boolean` (compares stored model+promptVersion to current config) and `markStale(blueprintId)`.
|
|
4. **Invalidation trigger:** add an admin action (button on `src/app/admin/blueprints/[id]`) + an internal function to mark a blueprint stale and bump `contentVersion`. On the serve path (`getLessonResponse` in `src/lib/db/queries.ts` / `src/app/learn/[key]/page.tsx`), if stale, enqueue a regeneration job (do NOT regenerate synchronously) and keep serving the old version until the new one is ready (lazy invalidation — spec invariant #10).
|
|
5. **Regeneration job:** add `jobs/regenerate-blueprint-job.ts` (or extend `generate-lesson-job`) that regenerates segments/checkpoints at a new `contentVersion`, re-runs T0/T1 (and T2 at promotion), and swaps the cache. **Idempotency key must include the target `contentVersion`** so a retry never double-bills. Reuse the `jobs` ledger pattern from `generate-lesson-job.ts`.
|
|
6. **Flagged-item regeneration:** `autoFlagCheckpointIfNeeded` (already in `queries.ts`) sets a checkpoint to `verifyStatus='fail'`. Add an endpoint/admin action to regenerate just the flagged checkpoint at a bumped `contentVersion` (cheaper than whole-blueprint).
|
|
|
|
**Tests:** unit (LLM mocked): `isBlueprintStale` truth table; regeneration job bumps `contentVersion` and is idempotent under retry (same key → no second generation); serve path enqueues but does not block on stale.
|
|
|
|
**Acceptance:** Changing `LLM_GENERATOR_MODEL` or bumping the lesson prompt version causes new serves to enqueue regeneration; learners keep seeing valid old content until the new version verifies; retried jobs never double-generate; admin can force-regenerate a blueprint or a single flagged checkpoint.
|
|
|
|
---
|
|
|
|
## #1 — Novel-misconception harvest loop
|
|
|
|
**Goal:** close the moat flywheel — turn `novel`-tagged grades into labeled misconceptions that grow the misconception library AND the golden set. Spec §10.2 / §10.4 / §15.
|
|
|
|
**Current state (verified):**
|
|
- `grades.misconceptionTag` can be `'novel'`. `src/lib/verification/grader.ts:~183` already enqueues novel cases into the `novelMisconductQueue` table (`{ gradeId, checkpointId, responseText, evidenceSpan, processed:boolean default false, createdAt }`, schema `~line 268`).
|
|
- `getPersonalizationContext` (`queries.ts:~394`) already excludes `'novel'` from personalization (only labeled misconceptions are used). So once a novel case is *labeled* into `misconceptions`, it automatically starts being used.
|
|
- There is **no UI to review the queue, no labeling action, and no path from a labeled item into `tests/golden/misconception-cases.ts`.** That's the gap.
|
|
|
|
**Changes:**
|
|
1. **Queries** (`src/lib/db/queries.ts`): `getNovelQueue({ processed:false, limit })` returning queue rows joined with checkpoint + concept context; `labelNovelMisconception({ queueId, conceptId, tag, signature, description, correction })` which (a) inserts a row into `misconceptions` with `verifyStatus='pending'`, (b) marks the queue row `processed=true`, in one transaction; `dismissNovelQueueItem(queueId)` for false positives.
|
|
2. **Admin page** `src/app/admin/misconceptions/` (new): list unprocessed queue items showing the learner response with the `evidenceSpan` highlighted (reuse the marginalia highlight idiom from `grade-display.tsx`), the checkpoint prompt, reference answer, and existing misconceptions for that concept. Provide a label form (tag, signature, description, correction) and a dismiss button. Add to admin nav (`src/app/admin/layout.tsx`).
|
|
3. **Verification hook:** after labeling, the new misconception is `verifyStatus='pending'`. Run it through the existing `verifyMisconceptions` (`src/lib/verification/verify-misconceptions.ts`) — either inline on label or via a small job — so only verified misconceptions go live.
|
|
4. **Golden-set growth:** add `appendMisconceptionGoldenCase()` that writes a new case to `tests/golden/misconception-cases.ts` (or a sibling JSON the runner reads). At minimum, the admin label action should offer "also add as golden case" producing `{ response, expectedTag, conceptId }`. Keep the golden file the source of truth for the eval.
|
|
5. **Optional clustering:** if the queue is large, add a cheap-model job to cluster similar novel responses (group by embedding similarity of `responseText`) so a human labels a cluster once. Use `client.cheapGrader` / `embedText`. Defer if time-boxed.
|
|
|
|
**Tests:** unit (LLM mocked): labeling inserts misconception + flips `processed` atomically; dismiss leaves no misconception; labeled+verified misconception appears in `getPersonalizationContext` for that concept. Add ≥2 new cases to `misconception-cases.ts` and confirm `pnpm test:golden:misconceptions` still passes thresholds.
|
|
|
|
**Acceptance:** An admin can see novel responses, label or dismiss them; labeled items become verified misconceptions used in future grading/personalization; the golden set grows; the queue drains (`processed=true`). No new uncontrolled LLM cost on the serve path.
|
|
|
|
---
|
|
|
|
## #3 — False-fail monitoring dashboard
|
|
|
|
**Goal:** make the spec's core quality metric — false-fail rate (telling a correct learner they're wrong) — continuously visible, not just a CI gate. Spec §15.
|
|
|
|
**Current state (verified):**
|
|
- `tests/golden/run-grading-eval.ts` computes false-fail rate and verdict accuracy against frozen cases; CI-gated (`pnpm test:golden`, fails if false-fail > 10% or accuracy < 70%). **Writes no persistent results** (console only).
|
|
- Live signal exists but isn't aggregated: `grades` (verdict, confidence, payloadJson), `contentReports` with `reason='grade_wrong'` (learner-disputed grades), `getCheckpointFailureRate` + `autoFlagCheckpointIfNeeded` in `queries.ts`.
|
|
- `src/app/admin/reports/` lists raw content reports; no metrics/aggregation.
|
|
|
|
**Two data sources, show both:**
|
|
- **Offline (golden):** trend of false-fail rate / accuracy per eval run.
|
|
- **Online (production):** `grade_wrong` report rate vs total grades; misconception/uncertain verdict distribution; checkpoints auto-flagged.
|
|
|
|
**Changes:**
|
|
1. **Persist golden runs:** new table `eval_runs` (`id, suite, falseFailRate, accuracy, total, model, promptVersion, gitSha nullable, createdAt`). Make `run-grading-eval.ts` (and the content/misconception runners) insert a row at the end (guard behind an env flag so local runs can skip DB writes). Keep console output.
|
|
2. **Aggregation queries** (`src/lib/db/queries.ts` or `src/lib/metrics.ts`): `getEvalTrend(suite, limit)`; `getOnlineGradeMetrics(window)` → counts by verdict, `grade_wrong` report rate, # auto-flagged checkpoints, optionally per-blueprint.
|
|
3. **Admin dashboard** `src/app/admin/quality/` (new; link in admin nav): show latest false-fail rate with the 10% threshold marked (red if breached), a small trend (sparkline/table) from `eval_runs`, the online verdict distribution, and a list of checkpoints with the highest dispute/failure rate (drill into `/admin/blueprints/[id]`). Keep it calm and tabular — admin tool, not the learner surface.
|
|
4. **Alerting (optional):** if the latest persisted false-fail rate breaches threshold, surface a banner on `/admin`.
|
|
|
|
**Tests:** unit: aggregation queries over seeded grades/reports return correct counts and rates; `eval_runs` insert is gated by env flag. No need to run real models in unit tests.
|
|
|
|
**Acceptance:** After a golden run, the admin quality page shows the latest + historical false-fail rate against the threshold; production grade/dispute metrics are visible; high-dispute checkpoints are findable. False-fail is now observable continuously.
|
|
|
|
---
|
|
|
|
## #6 — Multi-lesson journey continuity
|
|
|
|
**Goal:** a Journey is a *sequence* of bounded lessons through a blueprint, not a single lesson. Let a learner finish lesson N and continue to N+1, with visible progress. Spec §1, §3.
|
|
|
|
**Current state (verified):**
|
|
- `blueprints` → `concepts` (ord) → `lessons` (ord, depth, locale) → `segments` → `checkpoints`. `journeyInstances.position` is incremented by `advanceJourneyPosition` (`queries.ts:~675`) on lesson complete, but **nothing reads position** and there is **no next-lesson chaining**. `/learn/[key]` serves one lesson per intent (`getLessonResponse`).
|
|
- `LessonComplete` in `lesson-reader.tsx` calls `POST /api/advance` then shows "Progress saved" — a dead end.
|
|
|
|
**Decision to confirm with product:** does one blueprint contain multiple `lessons` (multi-lesson per intent), or is each intent one lesson and a "journey" spans related blueprints? The schema supports multiple `lessons` per blueprint (`lessons.ord`), so **default assumption: multiple lessons per blueprint, ordered by `lessons.ord`.** Generation currently makes one lesson — extending generation to produce a multi-lesson arc is a larger content change; for this feature, support *serving and navigating* multiple lessons and generate subsequent lessons lazily on advance.
|
|
|
|
**Changes:**
|
|
1. **Serve by position:** extend `getLessonResponse` (or add `getLessonAtPosition(intentKey, userId, position, ...)`) to return the lesson at the learner's current `journeyInstances.position` (default 0 → first lesson by `lessons.ord`). `/learn/[key]/page.tsx` reads the user's position to pick the lesson.
|
|
2. **Advance → next:** on lesson complete, after `advanceJourneyPosition`, if a next lesson exists (or can be generated), navigate to it (e.g. `/learn/[key]?pos=N` or just re-fetch since position advanced). If the next lesson isn't generated yet, enqueue `generate-lesson-job` for `(blueprintId, ord=position)` and show the outline/poll state (reuse existing polling). If no next lesson and the blueprint is complete, show a real "Journey complete" terminal state (not just "saved").
|
|
3. **Lesson generation for position N:** `generate-lesson.ts` / `generate-lesson-job.ts` currently assume ord 0. Parameterize by `ord`/`position` and pass the concept(s) for that position. Idempotency key must include `ord`.
|
|
4. **Progress UI:** add a journey progress indicator to the reading surface header (e.g. "Lesson 2 of 5") sourced from `lessons` count + position. Calm, non-gamified (spec §7). i18n both locales.
|
|
5. **Cross-check #5:** the returning-home "continue" surface will read the same `journeyInstances` + position.
|
|
|
|
**Tests:** unit (LLM mocked): position increments serve the next lesson; terminal state when position ≥ lesson count; advance enqueues generation for an ungenerated next lesson with an ord-scoped idempotency key. Playwright: complete a lesson → land on the next or a real completion screen.
|
|
|
|
**Acceptance:** Completing a lesson advances to the next lesson in the blueprint (generating it lazily if needed) with a visible "Lesson N of M"; finishing the last lesson shows a Journey-complete state; position persists per user. No synchronous generation on the serve path.
|
|
|
|
---
|
|
|
|
## #5 — Returning-learner home
|
|
|
|
**Goal:** give the second visit a story — resume in-progress journeys, see due reviews, glance at mastery — instead of only an intent box. Spec §16 (mastery map, Daily Review).
|
|
|
|
**Current state (verified):**
|
|
- `src/app/page.tsx` is intent entry only (title, lede, input, depth/mode, example chips, "spark"). No resume surface.
|
|
- Data already available: `journeyInstances` (position) — after #6 this is meaningful; `getMasteryMap(userId)`; `getReviewCheckpoints(userId)` / `getDueReviews`. Anonymous users have a `curio_uid`; logged-in users have `session.user.id`.
|
|
|
|
**Changes:**
|
|
1. **Detect "returning":** a learner is "returning" if they have any `journeyInstances`, `mastery`, or due reviews. For anon users this requires the client `curio_uid`; the home page is a client component already, so fetch on mount (or add a `GET /api/home-summary?userId=` that returns `{ inProgress: [{intentKey, title, position, total}], dueReviewCount, recentMastery: [...] }`). For logged-in users, prefer the session id.
|
|
2. **New endpoint** `src/app/api/home-summary/route.ts`: aggregates in-progress journeys (join `journeyInstances` → `blueprints`, compute position/total), `dueReviewCount` (from `getReviewCheckpoints`), and a small recent-mastery slice. Reuse existing queries; add a `getInProgressJourneys(userId)` query.
|
|
3. **Home UI:** when summary is non-empty, render (above or beside the intent box) a calm "Continue" section: resume cards linking to `/learn/[intentKey]` (which #6 resolves to the right lesson via position), a "Review (N due)" link to `/review` when `dueReviewCount > 0`, and a "Mastery" link. When empty (first visit), show today's intent-first layout unchanged. Keep editorial calm; no streaks/XP (spec anti-pattern).
|
|
4. **i18n:** `home.continue.*`, `home.dueReviews`, etc., both locales.
|
|
5. **Loading/empty states:** skeleton while summary loads; never block the intent box on the summary fetch (intent entry must always be instant).
|
|
|
|
**Tests:** unit: `getInProgressJourneys` + home-summary aggregation over seeded data; empty summary → first-visit layout. Playwright: a user with progress sees Continue cards + due-review link; a fresh user sees only the intent entry.
|
|
|
|
**Acceptance:** Returning learners (anon or logged-in) land on a home that surfaces in-progress journeys, due-review count, and mastery, each one click away; first-time visitors see the existing clean intent entry; the intent box is never delayed by the summary.
|
|
|
|
---
|
|
|
|
## #9 — Streaming generation (skeleton-first)
|
|
|
|
**Goal:** replace the 4-second poll with true streamed delivery so a cold-start lesson appears progressively. Spec §8.3 (skeleton-first + buffer).
|
|
|
|
**Current state (verified):**
|
|
- Cold start returns an `outline` state; `src/components/lesson-reader.tsx:~97` polls `router.refresh()` every 4000ms until the lesson is `ready`. Generation runs in `jobs/generate-lesson-job.ts` (BullMQ), warming the Redis buffer.
|
|
- Vercel AI SDK supports streaming; `client.ts` uses structured output (`generateObject`-style). Lesson generation is schema-constrained, which complicates token-streaming of structured output.
|
|
|
|
**Approach (pick the lower-risk one that fits):**
|
|
- **Option A — Stream segment-by-segment from the buffer (recommended).** Keep generation in the job, but as each segment is generated + T0/T1-verified, push it to the Redis buffer (`buffer:{...}` per spec §17) and expose `GET /api/lessons/stream?key=...` as an SSE endpoint that emits segments as they land. `LessonReader` consumes the SSE and appends segments, replacing the poll. This honors invariant #4 (serve reads buffer, jobs generate) and degrades to the existing poll if SSE drops.
|
|
- **Option B — Stream the model directly** via `streamObject` for the reading text only (checkpoints still structured). More premium feel but riskier with schema validation + provenance (`source_chunk_ids`) + verification. Only if Option A is insufficient.
|
|
|
|
**Changes (Option A):**
|
|
1. **Buffer incrementally:** in `generate-lesson-job.ts`, write each verified segment to the Redis buffer as it completes (not only the whole lesson at the end). Keep the final whole-lesson cache write for the fast path.
|
|
2. **SSE route** `src/app/api/lessons/stream/route.ts`: Node runtime (not edge — needs timeout headroom), reads the buffer, emits `segment` events as they appear, a `ready` event when complete, and closes. Respect the per-session budget only for the generation job, not the read.
|
|
3. **Client:** in `lesson-reader.tsx`, when in outline/cold-start state, open an `EventSource` to the stream and append segments with the existing View-Transition reveal; fall back to the 4s poll if `EventSource` errors or is unsupported. Remove the poll once `ready`.
|
|
4. **Reduced motion:** segment-in animation must respect `prefers-reduced-motion` (already handled in `reading.css`).
|
|
|
|
**Tests:** unit: buffer write-per-segment ordering; SSE route emits segments then `ready` (mock the buffer). Playwright: cold-start lesson shows segments appearing without a full reload.
|
|
|
|
**Acceptance:** A cold-start lesson streams in segment-by-segment instead of popping after a 4s poll; falls back gracefully to polling; no synchronous generation on the request path; reduced-motion respected.
|
|
|
|
---
|
|
|
|
## #8 — Due-review email digest
|
|
|
|
**Goal:** bring learners back when reviews are due — the spaced-repetition retention loop. Email infra already exists; only transactional sends today.
|
|
|
|
**Current state (verified):**
|
|
- `src/lib/email/index.ts` `sendEmail({to,subject,html,text?})` over nodemailer SMTP (dev: jsonTransport→console). `src/lib/email/templates.ts` has verification/invite/reset templates. **No scheduled sending.**
|
|
- `src/lib/memory/spaced-rep.ts` (`isDue`, `scheduleNextReview`) + `getReviewCheckpoints(userId)` / `getDueReviews` give due items. `mastery.nextReview` is the schedule. Only logged-in users have an email (`users.email`); anon users can't be emailed.
|
|
|
|
**Changes:**
|
|
1. **Digest template:** add `reviewDigestEmail({ name, dueCount, topConcepts, reviewUrl })` to `templates.ts` (+ both-locale subject/body via the user's `users.locale`). Calm, on the learner's side; one clear CTA to `/review`. No gamified streak language.
|
|
2. **Recipient query** (`src/lib/db/queries.ts`): `getUsersWithDueReviews(now)` → users with `email IS NOT NULL`, `emailVerified`, ≥1 mastery row where `nextReview <= now`, with a due count + a few concept names. Respect an opt-out preference (see 4).
|
|
3. **Scheduled job:** add a BullMQ repeatable job (or a cron-invoked Route Handler protected by a secret header, e.g. for Vercel Cron) `jobs/review-digest-job.ts` that runs daily, fetches due users, and sends at most one digest per user per day. **Idempotency:** key on `(userId, yyyy-mm-dd)` via the `jobs` ledger so a re-run never double-sends. Throttle/batch sends.
|
|
4. **Opt-out:** add `emailDigest: boolean` (default true) to `users.preferences` (jsonb exists) with a toggle in `settings-client.tsx` (+ i18n). Include an unsubscribe link (signed token route) in the email — required for good email hygiene.
|
|
5. **Config:** guard the whole feature behind an env flag (e.g. `REVIEW_DIGEST_ENABLED`) and document SMTP + cron setup in `.env.example` / `docs`.
|
|
|
|
**Tests:** unit (mock `sendEmail`): `getUsersWithDueReviews` filters correctly (no email / unverified / opted-out excluded); digest job sends once per user per day (idempotency key blocks re-send); unsubscribe flips the preference. Do not send real email in tests.
|
|
|
|
**Acceptance:** A daily job emails verified, opted-in users who have reviews due, at most once/day, idempotently, with a working unsubscribe and locale-correct copy; feature is env-gated and documented; anon users are never emailed.
|
|
|
|
---
|
|
|
|
## #11 — Mastery page upgrade
|
|
|
|
**Goal:** turn the mastery page from a flat card grid into a calm "state of your learning" dashboard, and surface the actionable thing (due reviews). Spec §16 (mastery map). Stays editorial, non-gamified (spec §7).
|
|
|
|
**Current state (verified):**
|
|
- `src/components/mastery-map.tsx` — title + per-blueprint groups of `ConceptCard`s (name, score bar, %, a small "due" tag). Data from `GET /api/mastery?userId=`. `src/app/mastery/page.tsx` just renders `<MasteryMap/>`. Styles live in `src/styles/reading.css` under `.mastery-*`.
|
|
- **BUG (fix as part of this):** `mastery-map.tsx:39-43` `scoreLabel()` returns **hardcoded French** ("Maîtrisé/En cours/À revoir") in an i18n app — EN users see French. Also "Mastery Map", "Loading…", "due", and the empty-state string are hardcoded English. None go through `useTranslations`.
|
|
|
|
**Changes:**
|
|
1. **Localize everything first (bug fix).** Move all strings to `messages/en.json` + `fr.json` under a `mastery.*` namespace (`title`, `loading`, `empty`, `due`, `status.mastered|learning|needsWork`, overview labels). Read via `useTranslations('mastery')`. No hardcoded copy.
|
|
2. **Overview header.** Above the groups, a summary strip: counts of `mastered / learning / needs-work` and one progress meter (mastered ÷ total). Derive client-side from the fetched groups. Calm, tabular — not a gamified dashboard.
|
|
3. **Due-for-review zone.** Pull concepts where `nextReview <= now` into a top "Due for review (N)" section with a direct **Start review →** link to `/review`. Today "due" is a buried tag; it's the actionable item.
|
|
4. **Sort + optional filter.** Within each blueprint, order concepts needs-work → learning → mastered so weak spots rise. Optional filter chips (All / Due / Needs work) reusing the `.home-chip` pill idiom from `app.css`.
|
|
5. **Card polish.** Add marginalia status border-left using existing `--color-mastered / --color-tutor / --color-misconception`; demote the raw `%` to secondary; let the bar carry the signal. Keep the existing `role="meter"` a11y.
|
|
6. **Empty state with CTA** → link to home, not a dead sentence.
|
|
|
|
**Tests:** unit: `scoreStatus`/label mapping localized (no French leakage in `en`); overview counts correct over seeded groups; due-zone includes only `nextReview <= now`. Playwright: EN locale shows English status labels; due concept shows in the due zone with a working review link.
|
|
|
|
**Acceptance:** Mastery page opens with a counts overview + a meter, a prominent due-review zone linking to `/review`, status-ordered cards, and fully localized copy (no French for EN users). Passes the design floor in both themes.
|
|
|
|
---
|
|
|
|
## #12 — Review page upgrade
|
|
|
|
**Goal:** make Daily Review diagnose as richly as a lesson and make the mastery movement visible — it's the whole point of reviewing. Spec §11 (memory engine), §4 (tutoring loop).
|
|
|
|
**Current state (verified):**
|
|
- `src/components/review-reader.tsx` — sequential carousel (`currentIdx`), per-item `ScoreBar`, `CheckpointInput`, `GradeDisplay`, Next/Finish. Data from `GET /api/review?userId=`.
|
|
- **Degraded vs lessons:** `review-reader.tsx:~162` renders `GradeDisplay` WITHOUT `onSocraticSubmit`, `onExplain`, or the `document.startViewTransition` considering→reveal beat that `lesson-reader.tsx` uses. So partial verdicts in review have no Socratic probe, no re-explain, no motion.
|
|
- Completion is a flat "Session complete / scores updated".
|
|
|
|
**Changes:**
|
|
1. **Restore full diagnosis (parity with lesson).** Wire the same handlers `lesson-reader.tsx` uses: Socratic follow-up (`/api/socratic`), re-explain (`/api/explain`), and wrap the grade reveal in `document.startViewTransition` with the considering beat. Factor the shared grade-handling out of `lesson-reader.tsx` into a hook (e.g. `src/components/use-checkpoint-grading.ts`) so both readers share one implementation instead of diverging again.
|
|
2. **Visible mastery delta.** After grading, animate the `ScoreBar` from the pre-grade score to the new score (fetch/return updated mastery from the grade response or recompute) — e.g. "62% → 71% ↑". This is the payoff of review.
|
|
3. **Session summary.** Replace the terminal state with a real recap: concepts reviewed, net mastery change, count still due / next-due hint. Quiet, not a score screen.
|
|
4. **Progress bar.** Thin top bar with one segment per item filled as you go, replacing/augmenting the "N of M" text.
|
|
5. **Keyboard flow.** Cmd/Ctrl+Enter to submit, Enter to advance to next. Review is repetitive — keyboard makes it fast. Respect existing a11y/focus.
|
|
|
|
**Tests:** unit: shared grading hook drives both readers (partial verdict in review now exposes Socratic probe); delta computed correctly; summary aggregates per session. Playwright: complete a review item with a partial verdict → Socratic probe appears; finishing shows the recap with a net delta.
|
|
|
|
**Acceptance:** Review supports Socratic probe + re-explain + the reveal beat exactly like lessons (shared code path), shows the score moving per item, and ends on a meaningful session recap. Reduced-motion respected.
|
|
|
|
---
|
|
|
|
## #13 — Settings page upgrade
|
|
|
|
**Goal:** bring Settings in line with the app's design system (tokens + CSS file, not inline styles), make it navigable, and reuse existing control idioms.
|
|
|
|
**Current state (verified):**
|
|
- `src/app/settings/settings-client.tsx` — one long stacked column: Profile, Language, Learning (age/difficulty/depth/hint), Security, Danger.
|
|
- **DEBT (fix as part of this):** the entire page is **inline-styled** via a `const s = {}` object (`~lines 281-372`), with hardcoded `#fff` on buttons (`~354`, `~366`) and hardcoded error strings ("Failed to save.", "Linked: …", "Failed."). Violates the token-centralization convention (CLAUDE.md design invariants) and diverges from the home/reading idiom. Uses raw OS radios where home has polished segmented controls.
|
|
|
|
**Changes:**
|
|
1. **De-inline (debt fix).** Move all styles to a stylesheet (`src/styles/settings.css`, imported in `globals.css`, or extend `app.css`) using design tokens. Remove the `s` object and every `#fff` (→ `--color-surface`). This also makes the page dark-mode-correct.
|
|
2. **Localize stray strings.** "Failed to save.", "Linked:", "Failed." → `settings.*` i18n keys in both locales.
|
|
3. **Two-column layout (desktop).** Left = section nav (Profile / Learning / Language / Appearance / Security / Danger) as anchor links or tabs; right = panels. Single column on mobile. The page is long enough to warrant structure.
|
|
4. **Reuse the segmented-control idiom.** Replace the raw radio groups (age / difficulty / depth) with the home `.home-depth-options`-style pill segments for visual consistency. Keep them real `<fieldset>`/radio semantics for a11y.
|
|
5. **Add the Appearance section (ties to #10).** Theme selector (System / Light / Dark) sharing the extracted `setTheme` helper from `src/lib/theme.ts`. i18n `settings.appearance.*`.
|
|
6. **Tighten the danger zone.** Keep the type-your-email confirm; make the destructive button visually distinct (already uses `--color-misconception`) and the warning unmissable.
|
|
|
|
**Tests:** unit: locale change / preference save handlers unchanged and still call the right endpoints; no inline-style regressions (snapshot or lint rule if one exists). Playwright: section nav scrolls/switches panels; theme selector flips `data-theme`; settings render correctly in dark mode.
|
|
|
|
**Acceptance:** Settings uses tokens via a CSS file (no inline `s` object, no `#fff`), is navigable on desktop and stacks on mobile, reuses the segmented-control look, includes a working theme selector, and is fully localized — visually consistent with home and the reading surface in both themes.
|
|
|
|
---
|
|
|
|
## Notes for the executor
|
|
|
|
- **Confirm before large content changes.** #6's "generate a multi-lesson arc" and #2's regeneration touch paid generation — keep them idempotent and budget-checked, and surface the design choice (multi-lesson-per-blueprint assumption) before building if anything contradicts it.
|
|
- **Don't regress golden evals.** #1 and #3 touch the grading/eval path. Run `pnpm test:golden` before/after; add cases, never weaken thresholds.
|
|
- **Each feature: update `CLAUDE.md` if a convention changes, in the same commit.**
|
|
- **Keep the reading surface calm.** Every UI addition (#3 dashboard, #5 home, #6 progress) must pass the design floor: responsive, AA contrast in both themes, visible focus, reduced-motion, no gamification.
|
|
</content>
|
|
</invoke>
|