J'apprends l'allemand depuis deux ans. Pas sérieusement — par à-coups, entre deux projets, avec des applications qui me donnent des points pour avoir traduit « le chat boit du lait ». À un moment, j'ai décidé de passer l'examen ÖSD B1. J'ai acheté un Modellsatz en PDF. Et là, j'ai réalisé qu'aucun outil existant ne savait quoi faire avec ce fichier.

C'est comme ça que DeutschFlow a commencé.

Le problème avec les outils existants

Les plateformes d'apprentissage des langues ont toutes le même défaut : elles t'apprennent une langue générique. Duolingo ne sait pas que tu passes le B1 ÖSD dans trois mois. Anki ne génère pas d'exercices à partir de ton manuel de grammaire. Et aucune d'elles ne comprend que tu es francophone — ce qui change radicalement la façon dont tu dois aborder certaines structures grammaticales allemandes.

J'avais besoin d'un outil qui parte de mes documents, s'adapte à mon niveau, et me prépare à un format d'examen précis. Comme ça n'existait pas, je l'ai construit.

L'import PDF comme point de départ

La première décision technique a été la plus importante : comment extraire le contenu d'un PDF de façon fiable ?

L'approche classique — extraire le texte avec pdf-parse ou équivalent — échoue sur les documents scannés, perd la mise en page des tableaux, et ne comprend pas la structure des exercices ÖSD. J'ai choisi une approche différente : envoyer le PDF directement à Claude en base64, via l'API vision.

TYPESCRIPT
// Le PDF est converti en base64 et envoyé tel quel à Claude
const response = await anthropic.messages.create({
  model: 'claude-opus-4-5',
  messages: [{
    role: 'user',
    content: [{
      type: 'document',
      source: { type: 'base64', media_type: 'application/pdf', data: base64 }
    }, {
      type: 'text',
      text: systemPrompt
    }]
  }]
})

Claude lit le document comme un humain le lirait — il voit les colonnes, les tableaux, les numéros d'exercices. Pour un Modellsatz ÖSD, il préserve la structure complète : Lesen, Schreiben, Hören, Sprechen, Grammatik, avec les minuteries par section.

Le traitement tourne en arrière-plan via Inngest, parce qu'extraire et générer des exercices à partir d'un PDF de 40 pages peut prendre 60 à 90 secondes — bien au-delà des limites des fonctions serverless.

L'algorithme SM-2 et la détection des lacunes

Le cœur du système d'apprentissage adaptatif repose sur SM-2, l'algorithme de répétition espacée popularisé par SuperMemo. Chaque exercice a un état SM-2 : intervalle, facilité, nombre de répétitions. Après chaque session, les intervalles sont recalculés selon la performance.

Mais SM-2 seul ne suffit pas. J'ai ajouté une couche de détection des lacunes : pour chaque compétence (lecture, écriture, grammaire, vocabulaire, expression orale), le système maintient un score moyen. Les compétences faibles reçoivent plus de poids dans la génération de la prochaine session.

TYPESCRIPT
// Les compétences faibles ont plus de poids dans la session suivante
const weakSkills = skillPerformance
  .filter(s => s.avgScore < 0.65)
  .sort((a, b) => a.avgScore - b.avgScore)

const sessionPrompt = buildSessionPrompt({
  level: user.cefrLevel,
  sector: user.sector,
  weakSkills: weakSkills.slice(0, 3),
  targetExerciseCount: 15
})

Claude génère alors une session personnalisée — pas les mêmes exercices réchauffés, mais du contenu nouveau adapté à tes lacunes du moment.

20+ types d'exercices, un seul moteur

L'un des défis les plus techniques du projet a été de gérer la diversité des types d'exercices. L'ÖSD utilise des formats très spécifiques : MatchingHeadlines, OsdLueckentext, GrammatikTransformation, Fehlerkorrektur... Auxquels s'ajoutent les types classiques : QCM, vrai/faux, texte à trous, flashcards, construction de phrases.

Le problème : Claude ne retourne pas toujours exactement le même nom de type. Il peut écrire multiple_choice, MultipleChoice, mcq, ou choix_multiple pour désigner la même chose. J'ai écrit une fonction normalizeType() qui mappe plus de 40 alias vers des valeurs canoniques.

TYPESCRIPT
const TYPE_ALIASES: Record<string, ExerciseType> = {
  'multiple_choice': 'MultipleChoice',
  'mcq': 'MultipleChoice',
  'choix_multiple': 'MultipleChoice',
  'qcm': 'MultipleChoice',
  'fill_in_the_blank': 'FillInTheBlank',
  'lueckentext': 'FillInTheBlank',
  'texte_a_trous': 'FillInTheBlank',
  // ... 35 autres alias
}

Même logique pour sanitizeContent() : Claude retourne parfois des objets bilingues { DE: "...", FR: "..." } là où on attend une chaîne. Plutôt que de corriger chaque prompt, un sanitizer récursif normalise tout au moment du rendu.

La zone de parole

La fonctionnalité dont je suis le plus fier est la Zone de Parole — un module de pratique orale avec des scénarios de conversation générés par IA.

Le principe : Claude génère des scénarios adaptés à ton niveau et ton secteur professionnel (médical, juridique, technique...). Tu parles via l'API Web Speech (de-DE), ta réponse est transcrite, envoyée à Claude qui joue l'interlocuteur et évalue ta production. À la fin du scénario, tu reçois un score, des corrections, et des formules utiles.

Les scénarios sont mis en cache par utilisateur dans la table speak_scenario — pas de génération redondante, et tu peux reprendre une conversation interrompue.

Ce que j'ai appris

L'IA comme couche de transformation, pas comme fonctionnalité principale. DeutschFlow n'est pas « une app avec de l'IA ». C'est une app d'apprentissage dont l'IA résout des problèmes précis : extraction de documents, génération de contenu adaptatif, évaluation de l'expression écrite et orale. Chaque appel à Claude a un rôle défini.

La robustesse du JSON est un vrai sujet. Claude produit parfois du JSON légèrement malformé, surtout pour les longs tableaux d'exercices. jsonrepair via mon package @edwinfom/ai-guard sauve la mise à chaque fois. Ne jamais faire confiance à JSON.parse() directement sur une réponse d'IA.

Les tâches longues ont besoin d'une file d'attente. Inngest a été le bon choix pour le traitement des PDF. La gestion des retries, des timeouts, et du statut de traitement aurait été un cauchemar à implémenter manuellement.

Séparer les exercices importés des exercices de session. C'est une décision d'architecture que j'aurais pu rater. Les exercices extraits de PDF vivent dans leurs propres tables et ne contaminent pas la boucle SM-2. Ça garde le système adaptatif propre.

---

DeutschFlow est open source. Le code est sur GitHub, la stack complète est dans le README. Si tu apprends l'allemand — ou si tu construis quelque chose de similaire — je suis curieux de savoir ce que tu en penses.