Adaptive learning intelligence. Cogni quizzes you on any topic, measures where your understanding actually breaks down, and makes every next quiz harder exactly where you're strong and deeper exactly where you're not.
Every quiz produces a report with a knowledge-over-time graph and short, specific
commentary on what moved — never "great job", always "Lifetimes 0 → 50: you now
read 'a in signatures, but struct lifetimes still confuse."
flowchart LR
A[Playground<br/>topic + goals] --> B[Quiz<br/>7-10 questions]
B --> C[Grade<br/>local + Gemini]
C --> D[KnowledgeState<br/>mastery per subtopic]
D --> E[Report<br/>trend graph + notes]
E -->|Take next quiz| F[Adaptive directive]
F --> B
D --> G[Knowledge Map]
- Playground — you give a topic and 1–5 concrete goals (
MIN_GOALS/MAX_GOALSinsrc/types/schemas/session.schema.ts). - Quiz — Gemini writes 7–10 questions across subtopics it infers from your topic.
- Grading — multiple-choice and true/false are graded locally and deterministically; only free-text short answers go to Gemini.
- Mastery — a per-subtopic score is computed and stored as an append-only
KnowledgeStaterow, one per (loop, subtopic). - Report — the trend graph, per-subtopic deltas, explanations for every answer, and curated research links for your weakest areas.
- Adapt — the next quiz is steered by a directive derived from measured mastery: harder phrasing where you scored ≥75%, more questions where you scored <50%.
| Layer | Choice | Version |
|---|---|---|
| Framework | Next.js (App Router, Turbopack) | 16.3.2 |
| UI | React | 19.2.8 |
| Language | TypeScript | 6.0.3 |
| Styling | Tailwind CSS (CSS-first) | 4.3.3 |
| Animation | React Spring | 10.1.2 |
| Charts | Recharts | 3.10.1 |
| ORM | Prisma (+ @prisma/adapter-pg) |
7.9.1 |
| Database | PostgreSQL | 17 (Docker) |
| Auth | Auth.js / NextAuth | 5.0.0-beta.32 |
| AI | @google/genai, gemini-3.6-flash |
2.18.0 |
| Validation | Zod | 4.4.3 |
| Testing | Jest + React Testing Library | 30.4.2 / 16.3.2 |
Three choices worth knowing:
-
The model is
gemini-3.6-flash, not 3.7. At the time of writing, 3.7 returns 503 (UNAVAILABLE — high demand) for the majority of structured-output requests this size, while 3.6 serves the same schema reliably in ~20s. It's one constant (GEMINI_MODELinsrc/lib/ai/client.ts) — retry 3.7 later if capacity frees up. -
TypeScript is pinned to 6.0.3, not 7.x. TS 7 is released, but
typescript-eslint(viaeslint-config-next) refuses to load against the TS 7 API, which breaksnpm run lintentirely. 6.0.3 is the newest version the whole toolchain agrees on. Revisit when typescript-eslint ships TS 7 support. -
next-authis on the v5 beta line. Thelatesttag is v4, which does not properly support the App Router. The v5 beta is the only line declaringnext: ^16support and is the standard App Router choice today.
- Node.js 22+ (uses
node:crypto, ESM, and Next 16) - Docker — for the local Postgres container
- A Google AI Studio API key — https://aistudio.google.com/apikey
- A Google OAuth client — https://console.cloud.google.com/apis/credentials
# 1. Install dependencies (also runs `prisma generate` via postinstall)
npm install
# 2. Create your env file
cp .env.example .env
# then fill in the values — see the table below
# 3. Start Postgres
docker compose up -d
# 4. Create the schema
npx prisma migrate dev --name init
# 5. (optional) Load demo data so the UI has something to show
npx prisma db seed
# 6. Run it
npm run dev # http://localhost:3000| Variable | What it is | Where to get it |
|---|---|---|
DATABASE_URL |
Postgres connection string | Matches docker-compose.yml; defaults to port 5433 |
GEMINI_API_KEY |
Google AI Studio key for all generation | https://aistudio.google.com/apikey |
AUTH_SECRET |
Signs session cookies | npx auth secret, or openssl rand -base64 32 |
AUTH_URL |
App origin | http://localhost:3000 in dev |
AUTH_GOOGLE_ID |
Google OAuth client ID | Google Cloud Console |
AUTH_GOOGLE_SECRET |
Google OAuth client secret | Google Cloud Console |
Env vars are validated by Zod at first use (src/lib/utils/env.ts). A missing
value fails loudly with a list of what's missing — but lazily, so next build
still works in CI without production secrets.
Port note: Cogni's Postgres is mapped to host port 5433, not the default 5432, so it can coexist with another local Postgres. Change it in
docker-compose.ymland.envtogether if you'd rather use 5432.
- Go to https://aistudio.google.com/apikey and create a key.
- Put it in
.envasGEMINI_API_KEY.
The model is a single constant — GEMINI_MODEL in src/lib/ai/client.ts — so
switching Flash versions is a one-line change.
- Google Cloud Console → APIs & Services → Credentials → Create OAuth client ID.
- Application type: Web application.
- Add an authorized redirect URI:
http://localhost:3000/api/auth/callback/google - Copy the client ID and secret into
AUTH_GOOGLE_ID/AUTH_GOOGLE_SECRET.
Use the pooler connection string, not the direct one. This is the single most common way this deployment breaks.
Supabase's direct host, db.<ref>.supabase.co, publishes only an IPv6 (AAAA)
DNS record. Vercel's serverless runtime has no IPv6 egress, so every query
fails with:
Can't reach database server at db.<ref>.supabase.co
which Auth.js surfaces as an opaque AdapterError and a redirect to
/api/auth/error?error=Configuration — the underlying cause is purely network
reachability, not OAuth configuration.
You can verify the cause yourself:
dig +short A db.<ref>.supabase.co # empty — no IPv4
dig +short AAAA db.<ref>.supabase.co # returns an IPv6 addressCopy the exact strings from Supabase → Project Settings → Database → Connection string.
| Variable | Value | Notes |
|---|---|---|
DATABASE_URL |
Transaction mode, port 6543 | App runtime. Pooled, IPv4-reachable. |
DIRECT_URL |
Session mode, port 5432 | Migrations only. Optional but recommended. |
GEMINI_API_KEY |
Your key | |
AUTH_SECRET |
npx auth secret |
|
AUTH_GOOGLE_ID / AUTH_GOOGLE_SECRET |
From Google Console |
DATABASE_URL=postgresql://postgres.<ref>:<password>@aws-0-<region>.pooler.supabase.com:6543/postgres?sslmode=require
DIRECT_URL=postgresql://postgres.<ref>:<password>@aws-0-<region>.pooler.supabase.com:5432/postgres?sslmode=require
Note the username is postgres.<project-ref>, not plain postgres — Supavisor
routes on that suffix. env.ts will refuse to start on Vercel if DATABASE_URL
still points at the direct host, so this fails loudly at boot rather than
silently at login.
Do not set AUTH_URL on Vercel. Auth.js detects the deployment URL
automatically; a hardcoded value breaks preview deployments.
Add the production callback URL in Google Console:
https://<your-app>.vercel.app/api/auth/callback/google
?connection_limit=N and ?pgbouncer=true are Prisma-engine parameters.
This project uses the Prisma 7 driver adapter, which hands the connection
string to node-postgres — and node-postgres parses those keys, then ignores
them. Pool sizing is therefore set explicitly in src/lib/db/prisma.ts, with a
smaller max on serverless so warm instances don't collectively exhaust the
pooler's client limit.
DIRECT_URL="<session-mode-string>" npx prisma migrate deployprisma.config.ts prefers DIRECT_URL for migrations because Prisma Migrate
holds advisory locks and issues DDL that must stay on one backend — a
transaction pooler can reassign the connection mid-migration.
| Script | Does |
|---|---|
npm run dev |
Dev server on :3000 |
npm run build |
Production build |
npm start |
Serve the production build |
npm run lint |
ESLint |
npm run typecheck |
tsc --noEmit |
npm test |
Jest suite |
npm run test:watch |
Jest in watch mode |
npm run db:migrate |
prisma migrate dev |
npm run db:generate |
Regenerate the Prisma client |
npm run db:studio |
Prisma Studio |
npm run db:seed |
Load demo data |
npm run db:reset |
Drop, re-migrate, re-seed |
src/
├── app/
│ ├── actions/ "use server" — thin wrappers only
│ └── …routes
├── components/ ui/ · layout/ · modals/ · errors/ · quiz/ · report/ · charts/ · history/
├── hooks/ quiz machine, modal, reduced-motion, theme
├── lib/
│ ├── ai/ Gemini only — prompts + 3 composable calls
│ ├── db/ Prisma only — repositories
│ ├── domain/ pure logic, zero I/O
│ ├── services/ orchestration across the three above
│ └── design/ tokens + spring presets
└── types/schemas/ Zod — the single source of truth
The layering rule: lib/domain is pure and framework-free, so mastery
scoring, delta computation, adaptivity and objective grading are all unit-testable
with no mocking at all. lib/ai only talks to Gemini. lib/db only talks to
Prisma. lib/services composes them. Server actions do nothing but validate
input, call a service, revalidate, and return a typed ActionResult<T>.
Adding a question type: extend the QuestionType enum in schema.prisma and
QUESTION_TYPES in quizGeneration.schema.ts, then decide in
lib/domain/evaluateObjective.ts whether it grades locally or routes to Gemini,
and render it in components/quiz/QuestionStage.tsx.
Adding a chart: put it in components/charts/, read colors from
lib/design/tokens.ts (never hardcode hex), and use useIsDark() so it re-steps
for the dark surface.
Tailwind v4 is CSS-first — there is no tailwind.config.ts. Tokens live in
src/app/globals.css under @theme, mirrored in src/lib/design/tokens.ts for
the chart layer (Recharts needs literal values, not class names).
| Token | Light | Dark |
|---|---|---|
canvas |
#FFFFFF |
#18181B |
surface |
#F9FAFB |
#27272A |
ink |
#27272A |
#FFFFFF |
muted |
#A1A1AA |
#A1A1AA |
hairline |
#E4E4E7 |
#3F3F46 |
| brand gradient | #22C55E → #EAB308 |
same |
Dark mode follows the OS preference; an explicit .dark or .light class on
<html> overrides it in either direction.
Motion presets live in src/lib/design/springs.ts — snappy (card press),
smooth (progress, layout), gentle (stage transitions). Always route a
spring config through motionConfig(name, reduced) using useReducedMotion(),
so prefers-reduced-motion: reduce collapses every animation to zero duration.
Chart series colors are a validated categorical palette — fixed order, never cycled, so a subtopic keeps its color as coverage shifts between loops. Both the light and dark steps pass adjacent-pair colorblind separation, lightness-band and chroma gates. Don't add a 9th hue by hand; fold extra subtopics instead.
After each loop, lib/domain/adaptivity.ts reads the latest KnowledgeState per
subtopic and produces the directive fed to the next generation:
| Band | Score | Effect on the next quiz | Sampling weight |
|---|---|---|---|
| Strong | ≥ 75 | ADVANCED difficulty, technical vocabulary, edge cases | 1 |
| Moderate | 50–74 | INTERMEDIATE, probe the boundary | 2 |
| Weak | < 50 | More questions, plainer phrasing, smaller steps | 3 |
Thresholds are STRONG_THRESHOLD / WEAK_THRESHOLD in lib/domain/mastery.ts.
The directive is persisted on QuizLoop.adaptiveDirective so you can always see
why a given quiz looks the way it does.
All numbers on a report are computed, never generated. Gemini writes only
prose; previousScore, currentScore, delta and direction come from
computeProgressDeltas(). That's why the graph, the delta badges and the
commentary can never disagree.
npm test| Layer | What's covered | Mocking |
|---|---|---|
lib/domain |
mastery scoring, deltas, adaptivity bands, objective grading | none — pure functions |
lib/ai |
prompt construction, option repair, response merging, model-skip fallbacks | Gemini client mocked |
components |
QuizCard behavior and accessibility |
jsdom + RTL |
Jest runs through next/jest (SWC), so there's no ts-jest and no Babel config.
Cannot find module '@/generated/prisma/client'
Run npx prisma generate. Prisma 7 generates into src/generated/prisma (which
is gitignored), not into node_modules.
The datasource property 'url' is no longer supported in schema files
Prisma 7 moved the connection URL out of schema.prisma entirely — it lives in
prisma.config.ts only.
Prisma errors about a missing driver adapter
Prisma 7 requires one. The client is constructed with PrismaPg in
src/lib/db/prisma.ts; don't instantiate PrismaClient bare.
In production: Can't reach database server at db.<ref>.supabase.co, or
login redirects to /api/auth/error?error=Configuration
DATABASE_URL is using Supabase's direct host, which is IPv6-only and
unreachable from Vercel. Switch to the Supavisor pooler string (port 6543) —
see Deploying to Vercel with Supabase.
The error=Configuration is a red herring: Auth.js reports any adapter failure
that way, and the real error is in the function logs above it.
prepared statement "s0" already exists in production
A transaction pooler recycled the connection between statements. Confirm
DATABASE_URL is the pooler string and that migrations use DIRECT_URL, not
the 6543 port.
Bind for 0.0.0.0:5433 failed: port is already allocated
Something else holds the port. Change the host side of the mapping in
docker-compose.yml and the port in DATABASE_URL together.
Migrations hang or fail right after docker compose up
Postgres isn't accepting connections yet. Wait for the healthcheck:
docker exec cogni-postgres pg_isready -U cogni -d cogni.
Gemini 404 / model-not-found
Flash model IDs get retired. Update GEMINI_MODEL in src/lib/ai/client.ts to a
current one from https://ai.google.dev/gemini-api/docs/models.
"Gemini is busy right now" after a long pause
The model returned 503 on every attempt. Requests already retry with exponential
backoff (MAX_ATTEMPTS in src/lib/ai/client.ts); sustained failure means that
model is capacity-constrained, so switch GEMINI_MODEL to another Flash release.
GeminiError: response did not match the expected shape
Working as intended — the AI layer validates every response against its Zod
schema and fails at the boundary rather than corrupting the database.
npm audit reports a high-severity advisory in deepmerge-ts
It's a transitive dependency of the prisma CLI (a devDependency), reached
only when the CLI parses your own prisma.config.ts. It is not on any runtime
path. npm audit fix --force would downgrade Prisma to v6 and break the schema —
don't run it.
- Research-link URLs are not validated. Gemini is prompted toward canonical,
stable documentation, but a generated URL can still 404. A HEAD-check before
persisting
ResearchLinkrows is the obvious fast-follow. - Auth.js v5 is a beta. Stable when v5 ships; the API is not expected to move much before then.
- Middleware auth is a UX guard, not the security boundary. The Prisma adapter
can't run at the edge, so
middleware.tsonly checks for a session cookie. Real enforcement isrequireUserId()in every page and server action. - No streaming. Quiz and report generation are single blocking calls behind a transition screen. Streaming questions in would cut perceived latency.