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

@@ -1,57 +1,153 @@
'use client'
import React from 'react'
import React, { useState } from 'react'
import { useRouter } from 'next/navigation'
import type { VocabWorksheetHook } from '../types'
import { getApiBase } from '../constants'
export function ExportTab({ h }: { h: VocabWorksheetHook }) {
const { isDark, glassCard } = h
const router = useRouter()
const [isGeneratingLearning, setIsGeneratingLearning] = useState(false)
const [learningUnitId, setLearningUnitId] = useState<string | null>(null)
const [learningError, setLearningError] = useState<string | null>(null)
const handleGenerateLearningUnit = async () => {
if (!h.session) return
setIsGeneratingLearning(true)
setLearningError(null)
try {
const apiBase = getApiBase()
const resp = await fetch(`${apiBase}/api/v1/vocab/sessions/${h.session.id}/generate-learning-unit`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ generate_modules: true }),
})
if (!resp.ok) {
const err = await resp.json().catch(() => ({}))
throw new Error(err.detail || `HTTP ${resp.status}`)
}
const result = await resp.json()
setLearningUnitId(result.unit_id)
} catch (err: any) {
setLearningError(err.message || 'Fehler bei der Generierung')
} finally {
setIsGeneratingLearning(false)
}
}
return (
<div className={`${glassCard} rounded-2xl p-6`}>
<h2 className={`text-lg font-semibold mb-4 ${isDark ? 'text-white' : 'text-slate-900'}`}>PDF herunterladen</h2>
<div className="space-y-6">
{/* PDF Download Section */}
<div className={`${glassCard} rounded-2xl p-6`}>
<h2 className={`text-lg font-semibold mb-4 ${isDark ? 'text-white' : 'text-slate-900'}`}>PDF herunterladen</h2>
{h.worksheetId ? (
<div className="space-y-4">
<div className={`p-4 rounded-xl ${isDark ? 'bg-green-500/20 border border-green-500/30' : 'bg-green-100 border border-green-200'}`}>
<div className="flex items-center gap-3">
<svg className="w-6 h-6 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span className={`font-medium ${isDark ? 'text-green-200' : 'text-green-700'}`}>Arbeitsblatt erfolgreich generiert!</span>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<button onClick={() => h.downloadPDF('worksheet')} className={`${glassCard} p-6 rounded-xl text-left transition-all hover:shadow-lg ${isDark ? 'hover:border-purple-400/50' : 'hover:border-purple-500'}`}>
<div className={`w-12 h-12 mb-3 rounded-xl flex items-center justify-center ${isDark ? 'bg-purple-500/30' : 'bg-purple-100'}`}>
<svg className={`w-6 h-6 ${isDark ? 'text-purple-300' : 'text-purple-600'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
{h.worksheetId ? (
<div className="space-y-4">
<div className={`p-4 rounded-xl ${isDark ? 'bg-green-500/20 border border-green-500/30' : 'bg-green-100 border border-green-200'}`}>
<div className="flex items-center gap-3">
<svg className="w-6 h-6 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span className={`font-medium ${isDark ? 'text-green-200' : 'text-green-700'}`}>Arbeitsblatt erfolgreich generiert!</span>
</div>
<h3 className={`font-semibold mb-1 ${isDark ? 'text-white' : 'text-slate-900'}`}>Arbeitsblatt</h3>
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>PDF zum Ausdrucken</p>
</button>
</div>
{h.includeSolutions && (
<button onClick={() => h.downloadPDF('solution')} className={`${glassCard} p-6 rounded-xl text-left transition-all hover:shadow-lg ${isDark ? 'hover:border-green-400/50' : 'hover:border-green-500'}`}>
<div className={`w-12 h-12 mb-3 rounded-xl flex items-center justify-center ${isDark ? 'bg-green-500/30' : 'bg-green-100'}`}>
<svg className={`w-6 h-6 ${isDark ? 'text-green-300' : 'text-green-600'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
<div className="grid grid-cols-2 gap-4">
<button onClick={() => h.downloadPDF('worksheet')} className={`${glassCard} p-6 rounded-xl text-left transition-all hover:shadow-lg ${isDark ? 'hover:border-purple-400/50' : 'hover:border-purple-500'}`}>
<div className={`w-12 h-12 mb-3 rounded-xl flex items-center justify-center ${isDark ? 'bg-purple-500/30' : 'bg-purple-100'}`}>
<svg className={`w-6 h-6 ${isDark ? 'text-purple-300' : 'text-purple-600'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<h3 className={`font-semibold mb-1 ${isDark ? 'text-white' : 'text-slate-900'}`}>Loesungsblatt</h3>
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>PDF mit Loesungen</p>
<h3 className={`font-semibold mb-1 ${isDark ? 'text-white' : 'text-slate-900'}`}>Arbeitsblatt</h3>
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>PDF zum Ausdrucken</p>
</button>
)}
</div>
<button onClick={h.resetSession} className={`w-full py-3 rounded-xl border font-medium transition-colors ${isDark ? 'border-white/20 text-white/80 hover:bg-white/10' : 'border-slate-300 text-slate-700 hover:bg-slate-50'}`}>
Neues Arbeitsblatt erstellen
{h.includeSolutions && (
<button onClick={() => h.downloadPDF('solution')} className={`${glassCard} p-6 rounded-xl text-left transition-all hover:shadow-lg ${isDark ? 'hover:border-green-400/50' : 'hover:border-green-500'}`}>
<div className={`w-12 h-12 mb-3 rounded-xl flex items-center justify-center ${isDark ? 'bg-green-500/30' : 'bg-green-100'}`}>
<svg className={`w-6 h-6 ${isDark ? 'text-green-300' : 'text-green-600'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<h3 className={`font-semibold mb-1 ${isDark ? 'text-white' : 'text-slate-900'}`}>Loesungsblatt</h3>
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>PDF mit Loesungen</p>
</button>
)}
</div>
</div>
) : (
<p className={`text-center py-8 ${isDark ? 'text-white/60' : 'text-slate-500'}`}>Noch kein Arbeitsblatt generiert.</p>
)}
</div>
{/* Learning Module Generation Section */}
<div className={`${glassCard} rounded-2xl p-6`}>
<h2 className={`text-lg font-semibold mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>Interaktive Lernmodule</h2>
<p className={`text-sm mb-4 ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
Aus den Vokabeln automatisch Karteikarten, Quiz und Lueckentexte erstellen.
</p>
{learningError && (
<div className={`p-3 rounded-xl mb-4 ${isDark ? 'bg-red-500/20 border border-red-500/30' : 'bg-red-100 border border-red-200'}`}>
<p className={`text-sm ${isDark ? 'text-red-200' : 'text-red-700'}`}>{learningError}</p>
</div>
)}
{learningUnitId ? (
<div className="space-y-4">
<div className={`p-4 rounded-xl ${isDark ? 'bg-blue-500/20 border border-blue-500/30' : 'bg-blue-100 border border-blue-200'}`}>
<div className="flex items-center gap-3">
<svg className="w-6 h-6 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span className={`font-medium ${isDark ? 'text-blue-200' : 'text-blue-700'}`}>Lernmodule wurden generiert!</span>
</div>
</div>
<button
onClick={() => router.push(`/learn/${learningUnitId}`)}
className="w-full py-3 rounded-xl font-medium bg-gradient-to-r from-blue-500 to-cyan-500 text-white hover:shadow-lg transition-all"
>
Lernmodule oeffnen
</button>
</div>
) : (
<button
onClick={handleGenerateLearningUnit}
disabled={isGeneratingLearning || h.vocabulary.length === 0}
className={`w-full py-4 rounded-xl font-medium transition-all ${
isGeneratingLearning || h.vocabulary.length === 0
? (isDark ? 'bg-white/5 text-white/30 cursor-not-allowed' : 'bg-slate-100 text-slate-400 cursor-not-allowed')
: 'bg-gradient-to-r from-blue-500 to-cyan-500 text-white hover:shadow-lg hover:shadow-blue-500/25'
}`}
>
{isGeneratingLearning ? (
<span className="flex items-center justify-center gap-3">
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
Lernmodule werden generiert...
</span>
) : (
<span className="flex items-center justify-center gap-2">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
Lernmodule generieren ({h.vocabulary.length} Vokabeln)
</span>
)}
</button>
</div>
) : (
<p className={`text-center py-12 ${isDark ? 'text-white/60' : 'text-slate-500'}`}>Noch kein Arbeitsblatt generiert.</p>
)}
)}
</div>
{/* Reset Button */}
<button onClick={h.resetSession} className={`w-full py-3 rounded-xl border font-medium transition-colors ${isDark ? 'border-white/20 text-white/80 hover:bg-white/10' : 'border-slate-300 text-slate-700 hover:bg-slate-50'}`}>
Neues Arbeitsblatt erstellen
</button>
</div>
)
}

View File

@@ -0,0 +1,157 @@
'use client'
/**
* SpreadsheetTab — Fortune Sheet editor for vocabulary data.
*
* Converts VocabularyEntry[] into a Fortune Sheet workbook
* where users can edit vocabulary in a familiar Excel-like UI.
*/
import React, { useMemo, useCallback } from 'react'
import dynamic from 'next/dynamic'
import type { VocabWorksheetHook } from '../types'
const Workbook = dynamic(
() => import('@fortune-sheet/react').then((m) => m.Workbook),
{ ssr: false, loading: () => <div className="py-8 text-center text-sm text-gray-400">Spreadsheet wird geladen...</div> },
)
import '@fortune-sheet/react/dist/index.css'
/** Convert VocabularyEntry[] to Fortune Sheet sheet data */
function vocabToSheet(vocabulary: VocabWorksheetHook['vocabulary']) {
const headers = ['Englisch', 'Deutsch', 'Beispielsatz', 'Wortart', 'Seite']
const numCols = headers.length
const numRows = vocabulary.length + 1 // +1 for header
const celldata: any[] = []
// Header row
headers.forEach((label, c) => {
celldata.push({
r: 0,
c,
v: { v: label, m: label, bl: 1, bg: '#f0f4ff', fc: '#1e293b' },
})
})
// Data rows
vocabulary.forEach((entry, idx) => {
const r = idx + 1
celldata.push({ r, c: 0, v: { v: entry.english, m: entry.english } })
celldata.push({ r, c: 1, v: { v: entry.german, m: entry.german } })
celldata.push({ r, c: 2, v: { v: entry.example_sentence || '', m: entry.example_sentence || '' } })
celldata.push({ r, c: 3, v: { v: entry.word_type || '', m: entry.word_type || '' } })
celldata.push({ r, c: 4, v: { v: entry.source_page != null ? String(entry.source_page) : '', m: entry.source_page != null ? String(entry.source_page) : '' } })
})
// Column widths
const columnlen: Record<string, number> = {
'0': 180, // Englisch
'1': 180, // Deutsch
'2': 280, // Beispielsatz
'3': 100, // Wortart
'4': 60, // Seite
}
// Row heights
const rowlen: Record<string, number> = {}
rowlen['0'] = 28 // header
// Borders: light grid
const borderInfo = numRows > 0 && numCols > 0 ? [{
rangeType: 'range',
borderType: 'border-all',
color: '#e5e7eb',
style: 1,
range: [{ row: [0, numRows - 1], column: [0, numCols - 1] }],
}] : []
return {
name: 'Vokabeln',
id: 'vocab_sheet',
celldata,
row: numRows,
column: numCols,
status: 1,
config: {
columnlen,
rowlen,
borderInfo,
},
}
}
export function SpreadsheetTab({ h }: { h: VocabWorksheetHook }) {
const { isDark, glassCard, vocabulary } = h
const sheets = useMemo(() => {
if (!vocabulary || vocabulary.length === 0) return []
return [vocabToSheet(vocabulary)]
}, [vocabulary])
const estimatedHeight = Math.max(500, (vocabulary.length + 2) * 26 + 80)
const handleSaveFromSheet = useCallback(async () => {
await h.saveVocabulary()
}, [h])
if (vocabulary.length === 0) {
return (
<div className={`${glassCard} rounded-2xl p-6`}>
<p className={`text-center py-12 ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
Keine Vokabeln vorhanden. Bitte zuerst Seiten verarbeiten.
</p>
</div>
)
}
return (
<div className={`${glassCard} rounded-2xl p-4`}>
<div className="flex items-center justify-between mb-4">
<h2 className={`text-lg font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
Spreadsheet-Editor
</h2>
<div className="flex items-center gap-3">
<span className={`text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
{vocabulary.length} Vokabeln
</span>
<button
onClick={handleSaveFromSheet}
className="px-4 py-2 rounded-xl text-sm font-medium bg-gradient-to-r from-purple-500 to-pink-500 text-white hover:shadow-lg transition-all"
>
Speichern
</button>
</div>
</div>
<div
className="rounded-xl overflow-hidden border"
style={{
borderColor: isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)',
}}
>
{sheets.length > 0 && (
<div style={{ width: '100%', height: `${estimatedHeight}px` }}>
<Workbook
data={sheets}
lang="en"
showToolbar
showFormulaBar={false}
showSheetTabs={false}
toolbarItems={[
'undo', 'redo', '|',
'font-bold', 'font-italic', 'font-strikethrough', '|',
'font-color', 'background', '|',
'font-size', '|',
'horizontal-align', 'vertical-align', '|',
'text-wrap', '|',
'border',
]}
/>
</div>
)}
</div>
</div>
)
}