Formation DELF
June 2026 – PresentFrom a WordPress and LearnDash quiz site to a complete DELF B2 exam-preparation platform built on Next.js, with AI correction.
Project overviewFull-stack product development
From a WordPress and LearnDash quiz site to a complete DELF B2 exam-preparation platform built on Next.js, with AI correction.
Years of content, live customers, and one gap: production écrite and orale still needed a human examiner.
Now all four DELF B2 épreuves and a full examen blanc run in one app, desktop and phone, installable as a PWA: AI correction, a live oral examiner, payments, a back office, and the legacy library migrated intact.
- épreuves, plus a full examen blanc /100
- 4 + 1
- exam questions migrated, 9 wrong keys caught
- 680
- realtime voice engines kept for production, after evaluation
- 1 of 4
- automated test files: 342 unit, 106 end-to-end
- 448
- DELF-DALF centres mapped in 167 countries
- 1,092
The problemProduct discovery
From a quiz site that could score two papers to an app that trains all four.
Live customers, real content, real revenue. Replace the platform without losing any of it.
What already worked
Questions, answer keys, exam subjects, audio: years of content. Nothing could be lost.
What the old architecture could not support
Listening and reading score themselves. Writing and speaking need an examiner.
Before and after
- Compréhension orale
- Quiz attached to long audio files.
- Document-based audio, timed listening rules and exam-specific playback.
- Compréhension écrite
- Standard quiz access.
- 20-question timed examination flow with per-question scoring.
- Production écrite
- Requires a human examiner.
- AI correction against the official DELF grille.
- Production orale
- Requires a human examiner.
- A live AI examiner, then grading on the oral grille.
| Competency | Before | After |
|---|---|---|
| Compréhension orale | Quiz attached to long audio files. | Document-based audio, timed listening rules and exam-specific playback. |
| Compréhension écrite | Standard quiz access. | 20-question timed examination flow with per-question scoring. |
| Production écrite | Requires a human examiner. | AI correction against the official DELF grille. |
| Production orale | Requires a human examiner. | A live AI examiner, then grading on the oral grille. |
Architecture and stackSystem architecture and stack
From a WordPress theme and its plugins to six domains on Next.js and Postgres, each one replaceable.
No tangled monolith. Six clear domains, each free to change on its own.
Content
- Questions
- Séries
- Exam subjects
- Documents and audio
- Transcripts
- Grading criteria
Assessment
- Exam sessions
- Candidate answers
- Timers
- Question state
- Scoring
- Submission rules
AI
- Written correction
- Prompt versions
- Oral examination
- Transcription
- Pronunciation
- Evaluation harness
Commerce
- Stripe Checkout
- Passes
- Credits
- Refunds
- Entitlements
Candidate
- Progress
- Results
- Attempts
- Access
- Performance history
Operations
- Content admin
- AI test bench
- Examiner tools
- User management
- Business metrics
My scope, end to end
- Product architecture
- Frontend application
- Backend systems
- Database modelling
- Legacy migration
- AI correction
- Realtime AI
- Payments
- Access control
- Admin tooling
- Testing
- Deployment
Architecture principleSwap the interface, the AI provider or the pricing: grading, exams and progress stay untouched.
The stack, layer by layer
- Next.js
- React
- TypeScript
- Tailwind CSS
- shadcn/ui
- Vercel
- AI SDK + AI Gateway
- gpt-5.6-luna
- Gemini Live
- Gemini 2.5 Pro
- ElevenLabs
- GPT Realtime
- Grok Voice
- Neon Postgres
- Drizzle ORM
- Vercel Blob
- Clerk
- Stripe
- PostHog
- Resend
- Playwright
- node:test
- Husky
Mobile-first PWAProgressive web app · mobile first
From a desktop quiz site to a mobile-first PWA, installed on the home screen like a native app.
Candidates revise on the bus, not at a desk. So Formation DELF ships as a PWA, and every paper, timer and correction is drawn for a phone screen first.
- phone design decisions, written down
- 16
- commits shaping the phone layout
- 155
- landing JavaScript after the mobile speed pass
- -28%
Thumbs first
A tab bar on every browsable surface, the account in the top bar, the start button on the first screen.
Drawers, not columns
Questions and corrections rise from the bottom as drawers, so the text being read keeps the screen.
Fast on 4G
Below-the-fold code split, the auth origin preconnected, pricing streamed before the account loads.
Legacy migrationData migration
From a LearnDash library to Postgres, every question, key and audio clip intact.
First problem, no AI involved: no LearnDash export held everything. So I built an authenticated migration pipeline around a versioned snapshot.
- Extract
- Inspect
- Correct
- Re-import
- Verify
- Versioned JSON
Why an intermediate dataset
Nothing wrote straight to production. Versioned JSON made every pass reviewable, comparable and safe to rerun, with a source-level backup.
Idempotent imports
Run it ten times, get one copy. Extract, inspect, correct, rerun, verify.
Migration was only the beginning
Imported is not correct. Listening content audited against transcripts, long audio cut into clips with ffmpeg. 17 listening and 17 reading séries came across.
- questions migrated
- 680
- audio clips separated by document
- 85
- listening questions re-reviewed against transcripts
- 340
- wrong answer keys found in the legacy content
- 9
Every key fix is guarded: it writes only if the row still holds the audited value, otherwise the transaction rolls back. JSON backup first.
Guarded UPDATE: expected old key or abort
await client.query("begin"); for (const f of FIXES) { const row = qs.rows.find((r) => r.id === f.qid)!; const res = await client.query( `update questions set correct_answer_index = $1, correct_answer = $2, updated_at = now() where id = $3 and correct_answer_index = $4`, [f.to, row.options[f.to], f.qid, f.from], ); if (res.rowCount !== 1) throw new Error(`update guard failed for question ${f.qid}`); }The WHERE clause carries the expected old key: if the row changed since the audit, nothing is written.
PrincipleMigration is not copying rows. It is preserving meaning.
AI written correctionAI grading pipeline
From essays waiting for a human examiner to a structured /25 correction on the official DELF grille.
Feedback from an LLM is easy. A score that behaves like an exam is not. The model judges; the code enforces every rule.
Written production, end to end
- Pick a sujet
- Write under exam conditions
- Structured AI correction
- Progress across copies
How a correction works
- GuardA blank copy never reaches the model: zero words returns a deterministic 0/25 with no inference and no cost.
- Build the contextLevel, consigne, response, official criteria, bands and the expected output shape.
- GradeThe model scores the five criteria one by one and returns structure, not a paragraph.
- ValidateThe output schema accepts only the official bands 0, 1, 3 or 5, never a 2 or a 4.
- Apply the grille in codeLength is counted, not judged: under half the words, all five criteria are capped to 0, whatever the model says. Off-topic caps follow the grille.
- ReportCriterion scores, quoted evidence, the annotated text, strengths, priority fixes and a model answer.
Length anomalies decided in code from the word count
/** * The official PE anomalies, and the word-floor fraction under which a response * is treated as "manque de matière" (< 50 % of the expected words → 0 on every * criterion) per the grille. */export const MANQUE_MATIERE_FRACTION = 0.5; /** * Resolve the effective PE anomaly. The two *length-based* anomalies are decided * here from the word count — never trusted from the model (same rigor as * `total25`): a blank copy is `copie_blanche`, and a copy under half the word * floor is `manque_matiere`. A length anomaly the model emitted but the count * doesn't support is discarded (so a hallucinated `copie_blanche` can't wrongly * zero a full-length copy); content-based anomalies (`hors_sujet_*`) are kept as * the model reported them. */export function resolveEcriteAnomalie( modelAnomalie: string | null | undefined, wordCount: number, wordMin: number,): string | null { if (wordCount === 0) return "copie_blanche"; if (wordMin > 0 && wordCount < wordMin * MANQUE_MATIERE_FRACTION) return "manque_matiere"; if (modelAnomalie === "copie_blanche" || modelAnomalie === "manque_matiere") return null; return modelAnomalie ?? null;}Length is counted by code. Under half the required words, every criterion is capped to 0 after the model returns.
Prompt engineering
From prompts tuned by feel to versions fingerprinted and benchmarked against blind human marking.
A prompt moves scores, so it is treated like code: every version fingerprinted, none shipped on a hunch.
Role
What kind of evaluator the model behaves as.
Assessment framework
Which evaluation criteria apply.
Score constraints
Which values are valid and how to read them.
Evidence
Every judgment is justified with the candidate's own text.
Error routing
Lexical and grammatical spelling are never counted twice.
Output contract
Structured information, not arbitrary prose.
- Draft
- Fixtures
- Compare
- Gate
- Active
- Draft
- Fixtures
- Compare
- Gate
- Active
Prompt version fingerprint (sha256, 12 hex)
import { createHash } from "node:crypto"; /** * A short fingerprint of an écrite evaluation prompt template, stored on every * prompt version and every harness run. The exact mirror of the orale-eval hash. * * Scores drifting between two runs is only actionable if you can tell *why*. * Persisting the hash alongside the model id turns "lexique dropped a band" into * "lexique dropped a band and the prompt changed" — or, more usefully, "lexique * dropped a band and the prompt did NOT change", which points at the model. * * It is also how the page knows a stored result is stale: a run whose hash no * longer matches the current draft was produced by a prompt that no longer * exists, so its scores describe the past, not the present. * * Hashes the template itself, not a rendered prompt — the template *is* the * thing being versioned, and rendering would fold in an arbitrary fixture. */export function hashPromptTemplate(template: string): string { return createHash("sha256").update(template).digest("hex").slice(0, 12);Twelve hex characters identify a prompt version on every evaluation run, so stale results are flagged.
Criterion-level comparison
Two graders can share a total and disagree on every competency, so the harness compares criterion by criterion, not just the /25.
AI oral examinerVoice AI and realtime integration
From speaking practice that needed a person to a live AI examiner that runs the official protocol.
A real-time examiner that follows the exam state, not an open chat.
- Preparation
- Exposé, recorded
- Débat with the examiner
- Assessment /25
Realtime architecture
ElevenLabs, GPT Realtime, Grok Voice and Gemini Live, tested behind one examiner contract on latency, French, interruptions, stability and cost. Gemini Live won. Switching took zero exam rewrites.
Realtime examiner personas by engine choice
/** * The first names candidates know the live examiners by, keyed by the real * choice. The ONE place a persona is named: the candidate dropdown prints the * name alone, the admin dropdown and ledger print it after the engine * ("Gemini Live (Caroline)"), so when a candidate writes in about "Caroline" * the admin can tell which model they are talking about without a lookup. * * Insertion order is the candidate dropdown's display order, and the first * entry is the candidate's default. */export const PO_EXAMINER_PERSONAS = { "grok-voice-2.0": "Sara", [GEMINI_LIVE_EXAMINER_CHOICE]: "Caroline",} as const satisfies Partial<Record<PoExaminerChoice, string>>; /** The persona's first name for a choice, or null when the choice has no candidate-facing persona. */export function examinerPersonaName(choice: PoExaminerChoice): string | null { return choice in PO_EXAMINER_PERSONAS ? PO_EXAMINER_PERSONAS[choice as keyof typeof PO_EXAMINER_PERSONAS] : null;}The examiner contract keeps the catalogue of engines evaluated; production sittings run on Gemini Live.
Phonologie graded from audio with Gemini 2.5 Pro
export const PHONOLOGIE_AUDIO_MODEL = process.env.PO_PHONOLOGIE_AUDIO_MODEL || "google/gemini-2.5-pro"; const schema = z.object({ band: grilleBandSchema, justification: z.string().min(1), traits: z.array(z.string()).max(12), confidence: z.enum(["haute", "moyenne", "basse"]),});Only used when live phonology scoring fails: Gemini 2.5 Pro listens to a trimmed sample of the recordings.
PrincipleThe examination owns the rules; the engine only carries the voice. That is what made choosing one engine a measured decision instead of a rewrite.
Exam engine
From quizzes on long audio files to the official timing, phases and listening protocol.
Not generic quizzes: the real exam timing, order, playback and listening limits, with training and examen blanc behaving differently on purpose.
Compréhension écrite
- 20 questions, 60-minute session
- Several exercises, per-question scoring
- Document highlighting
- Desktop and mobile
Compréhension orale protocol
- Read the questions, audio signal
- First listening, pause, second listening
- Answering window
- Single listening for some exercises
The clock runs on wall time, so a sleeping phone buys no extra minutes. At zero, answers save once and the session closes.
Exam countdown force-finishes at zero
// Global countdown. Recomputes elapsed time from a Date.now() baseline on // each tick and on tab re-focus, so a throttled background tab can't drift // the clock — and so a tab that was frozen PAST zero still finishes the // moment it comes back. Hitting zero force-finishes the session: the exam is // over, the results screen is where the candidate belongs. useEffect(() => { if (finished || !hasStarted) return; if (!quizStartTimeRef.current) { // A resumed attempt adopts its ORIGINAL start stamp, so the countdown // continues where it left off; only a fresh attempt stamps now. Seeding // here rather than in a separate effect is deliberate — a separate effect // would run after this one, which would already have stamped `Date.now()` // and silently handed the candidate a new hour. quizStartTimeRef.current = resumeAt ?? Date.now(); } const timer = setInterval(() => { const remaining = calculateTimeRemaining(); setTimeRemaining(remaining); if (remaining <= 0) { clearInterval(timer); setIsFinished(true); } }, 1000);Remaining time comes from a wall-clock baseline, re-checked when the tab comes back into view.
What the candidate sees
A clean examination interface.
What the application manages
A state machine deciding which action is valid at every moment.
Full mock examExam orchestration
From separate quizzes to one examen blanc scored /100 on the real pass rule.
Four papers, one sitting, one score /100. Each competency keeps its own rules and feeds the same scoring everywhere.
DELF pass rule: 50/100 and 5/25 per competency
* Official DELF model (see docs/DELF_EXAM_REFERENCE.md section 2): * - Each of the 4 competencies is marked out of 25 → total out of 100. * - Pass = total ≥ 50/100 AND ≥ 5/25 in every competency (a score < 5 is * éliminatoire and cannot be rescued by a strong score elsewhere). * - The level (A1/A2/B1/B2) is the exam sat, not a derived band. */ export type DelfLevel = "A1" | "A2" | "B1" | "B2"; export const DELF_LEVELS: readonly DelfLevel[] = ["A1", "A2", "B1", "B2"] as const; export const COMPETENCY_MAX = 25;export const TOTAL_MAX = 100;export const PASS_TOTAL = 50;export const COMPETENCY_FLOOR = 5;One rule file feeds the dashboard, the examen blanc and every result screen.
Engineering challenge
Showing four scores is easy. Orchestrating four exams without welding them together is the work.
PrincipleEach competency owns its rules. The mock exam coordinates them.
Candidate progressLearning analytics
From isolated quiz scores to a dashboard that projects the DELF result.
Candidates see progress, not just scores: every paper projected against the DELF pass line.
Reset without destroying history
A reset archives previous data instead of deleting it, so history stays useful for analytics, support and the candidate.
PrincipleA reset changes the learning state. It does not erase the past.
Pricing and paymentsMonetisation and payments
From a WordPress shop to time-limited passes on Stripe, granted only by a signed webhook.
Priced the way candidates prepare: time-limited passes with an AI-correction quota, not a subscription.
- Bronze
- 7 days
- Silver, the most popular
- 30 days
- Gold, for a full preparation
- 60 days
After the money moves
Opening Checkout is easy. What happens after the money moves is the work.
The plan travels in session metadata; price IDs come from configuration, and a misconfigured plan shows as unavailable.
Checkout dedupe: same pack paid within 5 minutes
export function decideCheckoutDedupe(input: { sessions: readonly CheckoutSessionSummary[]; planKey: string; isDelfPlanKey: (key: string) => boolean; /** Unix seconds. */ now: number;}): CheckoutDedupeDecision { const { sessions, planKey, isDelfPlanKey, now } = input; const delf = sessions.filter((session) => { const key = session.metadata?.planKey; return typeof key === "string" && isDelfPlanKey(key); }); const recentPurchase = delf.find( (session) => session.status === "complete" && session.metadata?.planKey === planKey && now - session.created <= RECENT_PURCHASE_WINDOW_S, ); return { recentPurchaseSessionId: recentPurchase?.id ?? null, sessionIdsToExpire: delf .filter((session) => session.status === "open") .map((session) => session.id), };}A pure function decides; the Stripe calls around it stay thin and easy to test.
Pass creation idempotent on the checkout session id
const values = { clerkUserId: input.clerkUserId, planKey: plan.planKey, kind: plan.kind, source: input.source, stripeCheckoutSessionId: input.stripeCheckoutSessionId ?? null, stripePaymentIntentId: input.stripePaymentIntentId ?? null, amountPaidCents: input.amountPaidCents ?? null, currency: input.currency?.toLowerCase() ?? null, startsAt, expiresAt: expiresAtForPlan(plan, startsAt), ecriteTriesTotal: plan.ecriteTries, oraleTriesTotal: plan.oraleTries, grantedBy: input.grantedBy ?? null, }; if (values.stripeCheckoutSessionId) { const inserted = await tx .insert(userPlans) .values(values) .onConflictDoNothing({ target: userPlans.stripeCheckoutSessionId }) .returning(); return { created: inserted.length > 0, plan: inserted[0] ?? null }; } const [created] = await tx.insert(userPlans).values(values).returning(); return { created: true, plan: created ?? null }; });Stripe retries are harmless: the unique checkout session id makes the second insert a no-op.
Server-side lock decision for a compréhension série
/** * A compréhension série is locked for the current user when they lack premium * access AND an admin has not unlocked it (`series.is_free`), the one rule * shared with the séries listing and the examen blanc pool (lib/free-series.ts). */async function isSeriesLockedForUser(category: string, seriesId: number, hasFullAccess: boolean) { if (hasFullAccess) return false; if (!isComprehensionCategory(category)) return true; const freeIds = await getFreeSeriesIds(category); return !freeIds.has(seriesId);}The lock is decided on the server before the player renders, never in the browser.
Full refund revokes the pass
if (isFullRefund(charge)) { if (stripeSubscriptionId) { await cancelSubscriptionIfActive(stripeSubscriptionId); } if (paymentIntentId) { await revokeUserPlansByPaymentIntent(paymentIntentId); // A promo code used for this purchase can be used again. await releaseCouponForRefundedPayment(paymentIntentId); }A full refund takes the same webhook path as the purchase, in reverse.
Idempotent
One pass per checkout session, however many times Stripe retries.
Reconciled
Access is only ever granted from the signed webhook.
Reversible
A full refund follows the same path and revokes the pass.
Key principleCheckout is synchronous for the customer. Payment state is asynchronous for the application. That difference drives the architecture.
Operations and adminBack-office tooling
From the WordPress admin to 37 back-office pages built for how the team works.
A SaaS needs a cockpit too. The team runs everything from one back office: content, subjects, AI tests, examiners, users, passes and metrics.
Exam-centre database
Every DELF-DALF centre on one searchable map, with upcoming sessions and CSV export.
- exam centres
- 1,092
- countries
- 167
PrincipleThe product is not finished when the interface works. The business must be able to operate it.
Quality and deliveryDelivery, testing and decision records
From a WordPress site to 2,577 commits through pull requests, 448 test files and 401 written decisions.
16 weeks from first commit to pre-launch. Every figure is read from the repository, not estimated.
- commits on the staging branch since 29 June 2026
- 2,577
- pull requests merged, each through staging first
- 490
- lines of TypeScript across 1,831 files
- 289k
- pages: 68 for candidates, 37 for the back office
- 105
- React components on one design system
- 445
- Postgres tables, evolved through 56 migrations
- 57
- API routes, plus server actions for every form
- 28
- unit test files on node:test
- 342
- Playwright end-to-end specs
- 106
- written decisions: 195 engineering, 206 design
- 401
- voice engines evaluated, one kept for production
- 4 → 1
- failed source pages in the legacy migration
- 0
Testing
Each way to fail gets its own test: rules in unit tests, journeys in a real browser, models in an evaluation harness.
- Unit
- 342
- node:test files next to the rules they guard: scoring, bands, grille caps, access, timers, data transforms.
- End to end
- 106
- Playwright specs driving a real browser against the app and its database, desktop and phone.
- AI evaluation
- 2
- Harnesses, written and oral: fixtures, prompt fingerprints, criterion-level comparison with blind human marks.
- Gates
- 3
- Lint-staged on commit, typecheck before every push, the full suite on every pull request.
106 end-to-end specs, by area · pick one
Payments and access · 11 specs
Four journeys, tested from the first click to the database
Sit a reading exam
- Open a free série signed out
- Sign in from the modal, land back on the série
- Answer, highlight, move between exercises
- Let the clock run out: answers saved once
- Results, explanations, retry
Buy a pass and unlock
- Choose a pass on the pricing page
- Packs dialog and promo codes
- Return from checkout to the same page
- Pass shows in mon compte, receipt prints
- Paid série opens; admin can grant or revoke
Sit a production orale
- Microphone pre-flight with zero preparation
- Token gate before the live session
- Exposé then débat, two-part sitting
- Transcript of the passation
- Result stays on the last try; ungraded sittings handled
Run an examen blanc
- Public landing, first sitting offered
- Sign in, start, papers chained in exam order
- Dashboard projection updates
- Back office sees the sitting
- Reset archives, never deletes
PrincipleDifferent failure modes require different kinds of tests. A model is evaluated; a rule is asserted; a journey is clicked through.
Decisions on record
- Why passes, not subscriptions?
- Exam preparation is temporary and intensive; pricing should follow that.
- Why not let the LLM calculate everything?
- Deterministic rules belong in deterministic code. The model judges where judgment is needed.
- Why version prompts?
- Changing a grading prompt changes the product. It must be identifiable and testable.
- Why migrate through JSON?
- An intermediate format makes the migration inspectable, reproducible and recoverable.
- Why evaluate four voice engines?
- AI infrastructure moves fast. The exam stays the same while the engine is measured and chosen.
- Why separate training and exam modes?
- Training helps the candidate improve; exam mode reproduces the constraints.
- Why preserve old progress?
- History keeps its value even when the candidate restarts.
| Question | Decision |
|---|---|
| Why passes, not subscriptions? | Exam preparation is temporary and intensive; pricing should follow that. |
| Why not let the LLM calculate everything? | Deterministic rules belong in deterministic code. The model judges where judgment is needed. |
| Why version prompts? | Changing a grading prompt changes the product. It must be identifiable and testable. |
| Why migrate through JSON? | An intermediate format makes the migration inspectable, reproducible and recoverable. |
| Why evaluate four voice engines? | AI infrastructure moves fast. The exam stays the same while the engine is measured and chosen. |
| Why separate training and exam modes? | Training helps the candidate improve; exam mode reproduces the constraints. |
| Why preserve old progress? | History keeps its value even when the candidate restarts. |
How the platform was rebuilt
- MigrateAuthenticated scraper, versioned JSON, idempotent import of questions, answers, subjects and audio.
- RebuildExam players, written AI correction, the oral examiner, progress, payments, access and admin.
- Verify106 Playwright specs, 342 node:test files, AI evaluation harnesses, checks on every change.
- LaunchFinal pre-launch while the legacy site keeps serving users: no premature cutover.
The resultEnd-to-end delivery
From a legacy WordPress quiz site to a product ready for candidates.
A legacy quiz site turned into the full DELF B2 preparation journey, in final pre-launch while the business stays online.
Candidates can
- Train on listening and reading
- Take timed exam simulations
- Submit written productions
- Get structured AI correction
- Practise speaking with an AI examiner
- Track progress across competencies
- Sit full mock exams
- Buy access directly
The business can
- Manage educational content
- Test AI correction behaviour
- Configure examiners
- Manage paid access
- Monitor activity
- Run everything from one back office
What this project proves · each one links to the evidence
- Legacy migration
- Relational data modelling
- Exam state management
- Realtime AI
- Structured LLM output
- Prompt engineering
- AI evaluation
- Speech processing
- Payments
- Access control
- Internal tools
- Automated testing
- Production infrastructure
Every decision answered a business constraint
Customers
who could not be disrupted
Content
that could not be lost
Grading
that had to be auditable
Payments
that had to reconcile