Case study
Learner Memory System
An AI tutor that builds an inspectable model of how you learn - and adapts to it.
- Role
- Solo - architecture and implementation
- Year
- 2026
- Status
- Live

At a glance
- tables
- 5
- concept taxonomy
- 12
- LLM prompts
- 2
- mastery buckets
- 4
Overview
Learner Memory System is an AP Biology tutor built around a model of the person using it - a structured, inspectable record of what a learner understands and which misconceptions keep resurfacing.
The model is not a black box: every belief links to the message that produced it, the tutor reads a scoped slice before answering, and a dashboard shows beliefs forming and resolving next to the chat. One Next.js app over Postgres, two prompts total - a Memory Extractor and a Tutor. No vectors, no agent framework.
Why I Built It
A competent tutor is one prompt and a chat box - an afternoon of work, and not the interesting part. The interesting part is memory that visibly changes what the tutor says next and that the learner can open and read.
Most chat "memory" is the transcript, which you cannot point at or reason over. So the design question was what a learner model should be if it has to be shown on a screen and defended: typed records with provenance, not retrieved text.
The Approach
Memory is defined by what it is not: not chat history, but a small set of typed records - per-concept mastery signals and named misconceptions with a lifecycle - derived from the conversation and stored as queryable facts.
Two prompts, one per turn: an Extractor that returns a validated structured object, and a Tutor that reads a scoped slice of memory plus a short window of raw turns. Durable memory and the ephemeral window stay separate, which is what stops the design collapsing back into memory-as-transcript.
Deliberately not built, each for a stated reason:
- No vectors or embeddings - the concept space is closed, so extraction is classification into an enum.
- No BKT or IRT - mastery is a simple additive heuristic shown as buckets, not a decimal.
- No auth or multi-tenancy - one learner, with the seam named in future work rather than half-built.
Architecture
Five tables; deliberately no snapshot table for current mastery. memory_events is append-only and the source of truth - one row per observation, a signed delta on a concept. concepts is a fixed 12-slug taxonomy from the AP Biology units, and the Extractor may only emit those slugs, enforced as a Zod enum.
Mastery is computed on read as a clamped sum of a concept's deltas around a 0.5 baseline. Zero events reads as UNKNOWN, distinct from events that net to 0.5. The tutor and dashboard never see the raw number - only four ordinal buckets, a view over the score, so changing a threshold changes the view and never the data.
Every event and misconception carries the id of the message that produced it. That one column powers both the "recent updates" feed and the "why does the AI believe this?" view - no beliefs from nowhere. Vitest covers the pure functions - derive, apply, scope, the extractor schema, the tutor's prompt assembly - without a live provider.
Decisions & Tradeoffs
Derive mastery on read instead of storing a snapshot
Tradeoff accepted
Every read sums a concept's events - irrelevant at this scale, and it buys one source of truth with trivial provenance. Because it is a sum, a replayed extraction would double-count, so idempotency is schema-level: a unique constraint on (source_message_id, concept_id) with ON CONFLICT DO NOTHING.
Alternatives considered (2)
- A learner_memory table updated on every write
- A materialized view refreshed on a schedule
A closed 12-concept taxonomy enforced as a Zod enum - no embeddings, no vector store
Tradeoff accepted
The system cannot learn a concept it was not given, so a new subject means editing the taxonomy. In return extraction is classification into an enum - deterministic, debuggable, no index, and no fuzzy near-miss filing a belief under the wrong concept.
Alternatives considered (2)
- A vector database with semantic retrieval over free-form concepts
- Free-text concept labels emitted by the model
Tune the extractor for precision over recall
Tradeoff accepted
Most turns record nothing and real signals are sometimes missed - the cheap error, since the next turn can catch it. An invented belief renders on the dashboard for the learner to read, and a product claiming "here is what we know about you" falsifies itself the moment it is wrong on screen.
Alternatives considered (2)
- Capture every possible signal and let the dashboard filter
- Attach a confidence score to each belief and dim the weak ones
Make extraction non-fatal and keep it off the tutor's critical path
Tradeoff accepted
A failed extraction costs one observation. That is fine because extracting the current message cannot improve the current answer - the tutor already reads it directly; memory's value is historical. So a provider error or a bad concept logs, skips the write, and still answers.
Alternatives considered (2)
- Fail the turn when extraction fails
- Retry extraction inline before answering
At most one active misconception per concept, enforced by a partial unique index
Tradeoff accepted
Two distinct false beliefs on one concept cannot coexist (recorded as future work). In exchange resolution is unambiguous - one row to flip - and a repeated mistake cannot spawn duplicate beliefs on the dashboard.
Alternatives considered (1)
- Multiple active beliefs per concept, matched on resolution by text similarity
Engineering Challenges
Detecting that a learner has actually understood something.
How it was solved
Resolution is the headline behavior, and its trigger messages are the least informative - "got it", "I was wrong". The Extractor gets two reference-only inputs, the previous tutor turn and the active misconceptions, and precision holds by construction: a resolution may only name a concept on the active list, and the Extractor never derives a signal from the tutor's words.
Making a sum idempotent.
How it was solved
Derive-on-read means mastery is addition - exactly what a retry corrupts by silently double-counting. The guarantee lives in a unique constraint rather than application bookkeeping, so the pipeline can run any number of times over one message and produce the same events.
Behaving well with no memory at all.
How it was solved
With every concept UNKNOWN the tutor must be a good ordinary tutor and must not invent a history to sound personalized. A seed script populates one learner through the same tables and apply path as live data, so cold start is visible but never the reviewer's first impression.
Features
- Structured, inspectable learner model: per-concept mastery and named misconceptions
- Every belief traceable to the message that produced it
- Misconceptions with a lifecycle - active until the learner demonstrates understanding, then resolved on screen
- Tutor reads a scoped slice of memory, not the whole profile
- Dashboard feed of model changes, next to the chat
Tech Stack
- Next.js 16 (App Router)
- React 19
- TypeScript
- Tailwind CSS 4
- shadcn/ui
- Supabase (Postgres)
- Vercel AI SDK
- Google Gemini
- Zod
- Vitest
- Vercel
Lessons Learned
- The invariants that mattered ended up in the schema, not the prompt - a unique constraint made extraction idempotent, a partial index made resolution unambiguous.
- Provenance is a feature, not bookkeeping. Once every belief points at its cause, "why does the AI believe this?" becomes a query.
- Deciding what memory was not - transcript, embeddings, a snapshot - removed more work than any feature added.
Screenshots


Contact
If you need an engineer who can own it end to end, from the interface to the infrastructure, let's talk.
Email me