Add interactive learning modules MVP (Phases 1-3.1)
Some checks failed
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-school (push) Successful in 44s
CI / test-go-edu-search (push) Successful in 51s
CI / test-python-klausur (push) Failing after 2m44s
CI / test-python-agent-core (push) Successful in 33s
CI / test-nodejs-website (push) Successful in 34s

New feature: After OCR vocabulary extraction, users can generate interactive
learning modules (flashcards, quiz, type trainer) with one click.

Frontend (studio-v2):
- Fortune Sheet spreadsheet editor tab in vocab-worksheet
- "Lernmodule generieren" button in ExportTab
- /learn page with unit overview and exercise type cards
- /learn/[unitId]/flashcards — Flip-card trainer with Leitner spaced repetition
- /learn/[unitId]/quiz — Multiple choice quiz with explanations
- /learn/[unitId]/type — Type-in trainer with Levenshtein distance feedback
- AudioButton component using Web Speech API for EN+DE TTS

Backend (klausur-service):
- vocab_learn_bridge.py: Converts VocabularyEntry[] to analysis_data format
- POST /sessions/{id}/generate-learning-unit endpoint

Backend (backend-lehrer):
- generate-qa, generate-mc, generate-cloze endpoints on learning units
- get-qa/mc/cloze data retrieval endpoints
- Leitner progress update + next review items endpoints

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-04-16 07:13:23 +02:00
parent 4561320e0d
commit 20a0585eb1
17 changed files with 1991 additions and 40 deletions

View File

@@ -0,0 +1,75 @@
'use client'
import React, { useCallback, useState } from 'react'
interface AudioButtonProps {
text: string
lang: 'en' | 'de'
isDark: boolean
size?: 'sm' | 'md' | 'lg'
}
export function AudioButton({ text, lang, isDark, size = 'md' }: AudioButtonProps) {
const [isSpeaking, setIsSpeaking] = useState(false)
const speak = useCallback(() => {
if (!('speechSynthesis' in window)) return
if (isSpeaking) {
window.speechSynthesis.cancel()
setIsSpeaking(false)
return
}
const utterance = new SpeechSynthesisUtterance(text)
utterance.lang = lang === 'de' ? 'de-DE' : 'en-GB'
utterance.rate = 0.9
utterance.pitch = 1.0
// Try to find a good voice
const voices = window.speechSynthesis.getVoices()
const preferred = voices.find((v) =>
v.lang.startsWith(lang === 'de' ? 'de' : 'en') && v.localService
) || voices.find((v) => v.lang.startsWith(lang === 'de' ? 'de' : 'en'))
if (preferred) utterance.voice = preferred
utterance.onend = () => setIsSpeaking(false)
utterance.onerror = () => setIsSpeaking(false)
setIsSpeaking(true)
window.speechSynthesis.speak(utterance)
}, [text, lang, isSpeaking])
const sizeClasses = {
sm: 'w-7 h-7',
md: 'w-9 h-9',
lg: 'w-11 h-11',
}
const iconSizes = {
sm: 'w-3.5 h-3.5',
md: 'w-4 h-4',
lg: 'w-5 h-5',
}
return (
<button
onClick={speak}
className={`${sizeClasses[size]} rounded-full flex items-center justify-center transition-all ${
isSpeaking
? 'bg-blue-500 text-white animate-pulse'
: isDark
? 'bg-white/10 text-white/60 hover:bg-white/20 hover:text-white'
: 'bg-slate-100 text-slate-500 hover:bg-slate-200 hover:text-slate-700'
}`}
title={isSpeaking ? 'Stop' : `${lang === 'de' ? 'Deutsch' : 'Englisch'} vorlesen`}
>
<svg className={iconSizes[size]} fill="none" stroke="currentColor" viewBox="0 0 24 24">
{isSpeaking ? (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0zM10 9v6m4-6v6" />
) : (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z" />
)}
</svg>
</button>
)
}

View File

@@ -0,0 +1,136 @@
'use client'
import React, { useState, useCallback } from 'react'
interface FlashCardProps {
front: string
back: string
cardNumber: number
totalCards: number
leitnerBox: number
onCorrect: () => void
onIncorrect: () => void
isDark: boolean
}
const boxLabels = ['Neu', 'Gelernt', 'Gefestigt']
const boxColors = ['text-yellow-400', 'text-blue-400', 'text-green-400']
export function FlashCard({
front,
back,
cardNumber,
totalCards,
leitnerBox,
onCorrect,
onIncorrect,
isDark,
}: FlashCardProps) {
const [isFlipped, setIsFlipped] = useState(false)
const handleFlip = useCallback(() => {
setIsFlipped((f) => !f)
}, [])
const handleCorrect = useCallback(() => {
setIsFlipped(false)
onCorrect()
}, [onCorrect])
const handleIncorrect = useCallback(() => {
setIsFlipped(false)
onIncorrect()
}, [onIncorrect])
return (
<div className="flex flex-col items-center gap-6 w-full max-w-lg mx-auto">
{/* Card */}
<div
onClick={handleFlip}
className="w-full cursor-pointer select-none"
style={{ perspective: '1000px' }}
>
<div
className="relative w-full transition-transform duration-500"
style={{
transformStyle: 'preserve-3d',
transform: isFlipped ? 'rotateY(180deg)' : 'rotateY(0deg)',
}}
>
{/* Front */}
<div
className={`w-full min-h-[280px] rounded-3xl p-8 flex flex-col items-center justify-center ${
isDark
? 'bg-white/10 backdrop-blur-xl border border-white/20'
: 'bg-white shadow-xl border border-slate-200'
}`}
style={{ backfaceVisibility: 'hidden' }}
>
<span className={`text-xs font-medium mb-4 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
ENGLISCH
</span>
<span className={`text-3xl font-bold text-center ${isDark ? 'text-white' : 'text-slate-900'}`}>
{front}
</span>
<span className={`text-sm mt-6 ${isDark ? 'text-white/30' : 'text-slate-400'}`}>
Klick zum Umdrehen
</span>
</div>
{/* Back */}
<div
className={`w-full min-h-[280px] rounded-3xl p-8 flex flex-col items-center justify-center absolute inset-0 ${
isDark
? 'bg-gradient-to-br from-blue-500/20 to-cyan-500/20 backdrop-blur-xl border border-blue-400/30'
: 'bg-gradient-to-br from-blue-50 to-cyan-50 shadow-xl border border-blue-200'
}`}
style={{ backfaceVisibility: 'hidden', transform: 'rotateY(180deg)' }}
>
<span className={`text-xs font-medium mb-4 ${isDark ? 'text-blue-300/60' : 'text-blue-500'}`}>
DEUTSCH
</span>
<span className={`text-3xl font-bold text-center ${isDark ? 'text-white' : 'text-slate-900'}`}>
{back}
</span>
</div>
</div>
</div>
{/* Status Bar */}
<div className={`flex items-center justify-between w-full px-2 ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
<span className="text-sm">
Karte {cardNumber} von {totalCards}
</span>
<span className={`text-sm font-medium ${boxColors[leitnerBox] || boxColors[0]}`}>
Box: {boxLabels[leitnerBox] || boxLabels[0]}
</span>
</div>
{/* Progress */}
<div className="w-full h-1.5 rounded-full bg-white/10 overflow-hidden">
<div
className="h-full rounded-full bg-gradient-to-r from-blue-500 to-cyan-500 transition-all"
style={{ width: `${(cardNumber / totalCards) * 100}%` }}
/>
</div>
{/* Answer Buttons */}
{isFlipped && (
<div className="flex gap-4 w-full">
<button
onClick={handleIncorrect}
className="flex-1 py-4 rounded-2xl font-semibold text-lg transition-all bg-gradient-to-r from-red-500 to-rose-500 text-white hover:shadow-lg hover:shadow-red-500/25 hover:scale-[1.02]"
>
Falsch
</button>
<button
onClick={handleCorrect}
className="flex-1 py-4 rounded-2xl font-semibold text-lg transition-all bg-gradient-to-r from-green-500 to-emerald-500 text-white hover:shadow-lg hover:shadow-green-500/25 hover:scale-[1.02]"
>
Richtig
</button>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,126 @@
'use client'
import React, { useState, useCallback } from 'react'
interface Option {
id: string
text: string
}
interface QuizQuestionProps {
question: string
options: Option[]
correctAnswer: string
explanation?: string
questionNumber: number
totalQuestions: number
onAnswer: (correct: boolean) => void
isDark: boolean
}
export function QuizQuestion({
question,
options,
correctAnswer,
explanation,
questionNumber,
totalQuestions,
onAnswer,
isDark,
}: QuizQuestionProps) {
const [selected, setSelected] = useState<string | null>(null)
const [revealed, setRevealed] = useState(false)
const handleSelect = useCallback((optionId: string) => {
if (revealed) return
setSelected(optionId)
setRevealed(true)
const isCorrect = optionId === correctAnswer
setTimeout(() => {
onAnswer(isCorrect)
setSelected(null)
setRevealed(false)
}, isCorrect ? 1000 : 2500)
}, [revealed, correctAnswer, onAnswer])
const getOptionStyle = (optionId: string) => {
if (!revealed) {
return isDark
? 'bg-white/10 border-white/20 hover:bg-white/20 hover:border-white/30 text-white'
: 'bg-white border-slate-200 hover:bg-slate-50 hover:border-slate-300 text-slate-900'
}
if (optionId === correctAnswer) {
return isDark
? 'bg-green-500/20 border-green-400 text-green-200'
: 'bg-green-50 border-green-500 text-green-800'
}
if (optionId === selected && optionId !== correctAnswer) {
return isDark
? 'bg-red-500/20 border-red-400 text-red-200'
: 'bg-red-50 border-red-500 text-red-800'
}
return isDark
? 'bg-white/5 border-white/10 text-white/40'
: 'bg-slate-50 border-slate-200 text-slate-400'
}
return (
<div className="w-full max-w-lg mx-auto space-y-6">
{/* Progress */}
<div className="flex items-center justify-between">
<span className={`text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
Frage {questionNumber} / {totalQuestions}
</span>
<div className="w-32 h-1.5 rounded-full bg-white/10 overflow-hidden">
<div
className="h-full rounded-full bg-gradient-to-r from-purple-500 to-pink-500 transition-all"
style={{ width: `${(questionNumber / totalQuestions) * 100}%` }}
/>
</div>
</div>
{/* Question */}
<div className={`p-6 rounded-2xl ${isDark ? 'bg-white/10 backdrop-blur-xl border border-white/20' : 'bg-white shadow-lg border border-slate-200'}`}>
<p className={`text-xl font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
{question}
</p>
</div>
{/* Options */}
<div className="space-y-3">
{options.map((opt, idx) => (
<button
key={opt.id}
onClick={() => handleSelect(opt.id)}
disabled={revealed}
className={`w-full p-4 rounded-xl border-2 text-left transition-all flex items-center gap-3 ${getOptionStyle(opt.id)}`}
>
<span className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold flex-shrink-0 ${
revealed && opt.id === correctAnswer
? 'bg-green-500 text-white'
: revealed && opt.id === selected
? 'bg-red-500 text-white'
: isDark ? 'bg-white/10' : 'bg-slate-100'
}`}>
{String.fromCharCode(65 + idx)}
</span>
<span className="text-base">{opt.text}</span>
</button>
))}
</div>
{/* Explanation */}
{revealed && explanation && (
<div className={`p-4 rounded-xl ${isDark ? 'bg-blue-500/10 border border-blue-500/20' : 'bg-blue-50 border border-blue-200'}`}>
<p className={`text-sm ${isDark ? 'text-blue-200' : 'text-blue-700'}`}>
{explanation}
</p>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,149 @@
'use client'
import React, { useState, useRef, useEffect } from 'react'
interface TypeInputProps {
prompt: string
answer: string
onResult: (correct: boolean) => void
isDark: boolean
}
function levenshtein(a: string, b: string): number {
const m = a.length
const n = b.length
const dp: number[][] = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0))
for (let i = 0; i <= m; i++) dp[i][0] = i
for (let j = 0; j <= n; j++) dp[0][j] = j
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
dp[i][j] = Math.min(
dp[i - 1][j] + 1,
dp[i][j - 1] + 1,
dp[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1)
)
}
}
return dp[m][n]
}
export function TypeInput({ prompt, answer, onResult, isDark }: TypeInputProps) {
const [input, setInput] = useState('')
const [feedback, setFeedback] = useState<'correct' | 'almost' | 'wrong' | null>(null)
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
setInput('')
setFeedback(null)
inputRef.current?.focus()
}, [prompt, answer])
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
const userAnswer = input.trim().toLowerCase()
const correctAnswer = answer.trim().toLowerCase()
if (userAnswer === correctAnswer) {
setFeedback('correct')
setTimeout(() => onResult(true), 800)
} else if (levenshtein(userAnswer, correctAnswer) <= 2) {
setFeedback('almost')
setTimeout(() => {
setFeedback('wrong')
setTimeout(() => onResult(false), 2000)
}, 1500)
} else {
setFeedback('wrong')
setTimeout(() => onResult(false), 2500)
}
}
const feedbackColors = {
correct: isDark ? 'border-green-400 bg-green-500/20' : 'border-green-500 bg-green-50',
almost: isDark ? 'border-yellow-400 bg-yellow-500/20' : 'border-yellow-500 bg-yellow-50',
wrong: isDark ? 'border-red-400 bg-red-500/20' : 'border-red-500 bg-red-50',
}
return (
<div className="w-full max-w-lg mx-auto space-y-6">
{/* Prompt */}
<div className={`text-center p-8 rounded-3xl ${isDark ? 'bg-white/10 backdrop-blur-xl border border-white/20' : 'bg-white shadow-xl border border-slate-200'}`}>
<span className={`text-xs font-medium ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
UEBERSETZE
</span>
<p className={`text-3xl font-bold mt-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>
{prompt}
</p>
</div>
{/* Input */}
<form onSubmit={handleSubmit}>
<div className={`rounded-2xl overflow-hidden border-2 transition-colors ${
feedback ? feedbackColors[feedback] : (isDark ? 'border-white/20' : 'border-slate-200')
}`}>
<input
ref={inputRef}
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
disabled={feedback !== null}
placeholder="Antwort eintippen..."
autoComplete="off"
autoCorrect="off"
spellCheck={false}
className={`w-full px-6 py-4 text-xl text-center outline-none ${
isDark
? 'bg-transparent text-white placeholder-white/30'
: 'bg-transparent text-slate-900 placeholder-slate-400'
}`}
/>
</div>
{!feedback && (
<button
type="submit"
disabled={!input.trim()}
className={`w-full mt-4 py-3 rounded-xl font-medium transition-all ${
input.trim()
? 'bg-gradient-to-r from-blue-500 to-cyan-500 text-white hover:shadow-lg'
: isDark ? 'bg-white/5 text-white/30' : 'bg-slate-100 text-slate-400'
}`}
>
Pruefen
</button>
)}
</form>
{/* Feedback Message */}
{feedback === 'correct' && (
<div className="text-center">
<span className="text-2xl"></span>
<p className={`text-lg font-semibold mt-1 ${isDark ? 'text-green-300' : 'text-green-600'}`}>
Richtig!
</p>
</div>
)}
{feedback === 'almost' && (
<div className="text-center">
<span className="text-2xl">🤏</span>
<p className={`text-lg font-semibold mt-1 ${isDark ? 'text-yellow-300' : 'text-yellow-600'}`}>
Fast richtig! Meintest du: <span className="underline">{answer}</span>
</p>
</div>
)}
{feedback === 'wrong' && (
<div className="text-center">
<span className="text-2xl"></span>
<p className={`text-lg font-semibold mt-1 ${isDark ? 'text-red-300' : 'text-red-600'}`}>
Falsch. Richtige Antwort:
</p>
<p className={`text-xl font-bold mt-1 ${isDark ? 'text-white' : 'text-slate-900'}`}>
{answer}
</p>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,90 @@
'use client'
import React from 'react'
import Link from 'next/link'
interface LearningUnit {
id: string
label: string
meta: string
title: string
topic: string | null
grade_level: string | null
status: string
created_at: string
}
interface UnitCardProps {
unit: LearningUnit
isDark: boolean
glassCard: string
onDelete: (id: string) => void
}
const exerciseTypes = [
{ key: 'flashcards', label: 'Karteikarten', icon: 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10', color: 'from-amber-500 to-orange-500' },
{ key: 'quiz', label: 'Quiz', icon: 'M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z', color: 'from-purple-500 to-pink-500' },
{ key: 'type', label: 'Eintippen', icon: 'M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z', color: 'from-blue-500 to-cyan-500' },
]
export function UnitCard({ unit, isDark, glassCard, onDelete }: UnitCardProps) {
const createdDate = new Date(unit.created_at).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})
return (
<div className={`${glassCard} rounded-2xl p-6 transition-all hover:shadow-lg`}>
<div className="flex items-start justify-between mb-4">
<div>
<h3 className={`text-lg font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
{unit.label}
</h3>
<p className={`text-sm mt-1 ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
{unit.meta}
</p>
</div>
<button
onClick={() => onDelete(unit.id)}
className={`p-2 rounded-lg transition-colors ${isDark ? 'hover:bg-white/10 text-white/40 hover:text-red-400' : 'hover:bg-slate-100 text-slate-400 hover:text-red-500'}`}
title="Loeschen"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
{/* Exercise Type Buttons */}
<div className="flex flex-wrap gap-2">
{exerciseTypes.map((ex) => (
<Link
key={ex.key}
href={`/learn/${unit.id}/${ex.key}`}
className={`flex items-center gap-2 px-4 py-2.5 rounded-xl text-sm font-medium text-white bg-gradient-to-r ${ex.color} hover:shadow-lg hover:scale-[1.02] transition-all`}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d={ex.icon} />
</svg>
{ex.label}
</Link>
))}
</div>
{/* Status */}
<div className={`flex items-center gap-3 mt-4 pt-3 border-t ${isDark ? 'border-white/10' : 'border-black/5'}`}>
<span className={`text-xs ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
Erstellt: {createdDate}
</span>
<span className={`text-xs px-2 py-0.5 rounded-full ${
unit.status === 'qa_generated' || unit.status === 'mc_generated' || unit.status === 'cloze_generated'
? (isDark ? 'bg-green-500/20 text-green-300' : 'bg-green-100 text-green-700')
: (isDark ? 'bg-yellow-500/20 text-yellow-300' : 'bg-yellow-100 text-yellow-700')
}`}>
{unit.status === 'raw' ? 'Neu' : 'Module generiert'}
</span>
</div>
</div>
)
}