Skip to content

Repository files navigation

Cogni

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."


How the loop works

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]
Loading
  1. Playground — you give a topic and 1–5 concrete goals (MIN_GOALS / MAX_GOALS in src/types/schemas/session.schema.ts).
  2. Quiz — Gemini writes 7–10 questions across subtopics it infers from your topic.
  3. Grading — multiple-choice and true/false are graded locally and deterministically; only free-text short answers go to Gemini.
  4. Mastery — a per-subtopic score is computed and stored as an append-only KnowledgeState row, one per (loop, subtopic).
  5. Report — the trend graph, per-subtopic deltas, explanations for every answer, and curated research links for your weakest areas.
  6. 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%.

Tech stack

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_MODEL in src/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 (via eslint-config-next) refuses to load against the TS 7 API, which breaks npm run lint entirely. 6.0.3 is the newest version the whole toolchain agrees on. Revisit when typescript-eslint ships TS 7 support.

  • next-auth is on the v5 beta line. The latest tag is v4, which does not properly support the App Router. The v5 beta is the only line declaring next: ^16 support and is the standard App Router choice today.


Prerequisites


Setup

# 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

Environment variables

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.yml and .env together if you'd rather use 5432.

Getting a Gemini API key

  1. Go to https://aistudio.google.com/apikey and create a key.
  2. Put it in .env as GEMINI_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.

Configuring Google OAuth

  1. Google Cloud Console → APIs & Services → Credentials → Create OAuth client ID.
  2. Application type: Web application.
  3. Add an authorized redirect URI:
    http://localhost:3000/api/auth/callback/google
    
  4. Copy the client ID and secret into AUTH_GOOGLE_ID / AUTH_GOOGLE_SECRET.

Deploying to Vercel with Supabase

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 address

Environment variables on Vercel

Copy 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

Why pool settings live in code, not the URL

?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.

Running migrations against production

DIRECT_URL="<session-mode-string>" npx prisma migrate deploy

prisma.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.


Scripts

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

Architecture

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.

Design system

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.tssnappy (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.

How adaptivity works

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.


Testing

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.


Troubleshooting

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.


Known limitations

  • 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 ResearchLink rows 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.ts only checks for a session cookie. Real enforcement is requireUserId() 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.

About

Adaptive learning intelligence

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages