A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.
This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).
Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1042 lines
31 KiB
Python
1042 lines
31 KiB
Python
from datetime import date
|
|
from typing import List
|
|
|
|
from fastapi import APIRouter, UploadFile, File
|
|
from fastapi.responses import HTMLResponse
|
|
import shutil
|
|
|
|
from config import (
|
|
BASE_DIR,
|
|
EINGANG_DIR,
|
|
BEREINIGT_DIR,
|
|
EDITIERBAR_DIR,
|
|
NEU_GENERIERT_DIR,
|
|
is_valid_input_file,
|
|
)
|
|
from ai_processor import (
|
|
dummy_process_scan,
|
|
describe_scan_with_ai,
|
|
analyze_scan_structure_with_ai,
|
|
build_clean_html_from_analysis,
|
|
remove_handwriting_from_scan,
|
|
generate_mc_from_analysis,
|
|
generate_cloze_from_analysis,
|
|
generate_qa_from_analysis,
|
|
update_leitner_progress,
|
|
get_next_review_items,
|
|
generate_print_version_qa,
|
|
generate_print_version_cloze,
|
|
generate_print_version_mc,
|
|
generate_print_version_worksheet,
|
|
generate_mindmap_data,
|
|
generate_mindmap_html,
|
|
save_mindmap_for_worksheet,
|
|
)
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
@router.get("/")
|
|
def home():
|
|
"""Basis-Info des Backends (unter /api/ erreichbar)."""
|
|
return {
|
|
"status": "OK",
|
|
"message": "BreakPilot Backend läuft.",
|
|
"base_dir": str(BASE_DIR),
|
|
}
|
|
|
|
|
|
# === UPLOAD ===
|
|
|
|
|
|
@router.post("/upload-scan")
|
|
async def upload_scan(file: UploadFile = File(...)):
|
|
"""
|
|
Einzelnen Scan hochladen.
|
|
Dateiname bekommt automatisch ein Datum vorne dran: YYYY-MM-DD_originalname.ext
|
|
"""
|
|
today = date.today().isoformat()
|
|
safe_name = file.filename.replace("/", "_")
|
|
target_name = f"{today}_{safe_name}"
|
|
target_path = EINGANG_DIR / target_name
|
|
with target_path.open("wb") as buffer:
|
|
shutil.copyfileobj(file.file, buffer)
|
|
return {
|
|
"status": "OK",
|
|
"message": "Scan gespeichert",
|
|
"saved_as": str(target_path),
|
|
}
|
|
|
|
|
|
@router.get("/upload-form", response_class=HTMLResponse)
|
|
def upload_form():
|
|
"""Einfache HTML-Upload-Seite (für schnelle Tests im Browser)."""
|
|
return """
|
|
<html>
|
|
<body>
|
|
<h1>Arbeitsblätter hochladen</h1>
|
|
<form action="/upload-multi" method="post" enctype="multipart/form-data">
|
|
<input type="file" name="files" multiple required>
|
|
<button type="submit">Hochladen</button>
|
|
</form>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
|
|
@router.post("/upload-multi")
|
|
async def upload_multi(files: List[UploadFile] = File(...)):
|
|
"""
|
|
Mehrere Dateien per Stapel hochladen.
|
|
Alle Dateien landen im Ordner Eingang mit Datumspräfix.
|
|
"""
|
|
today = date.today().isoformat()
|
|
saved = []
|
|
for file in files:
|
|
safe_name = file.filename.replace("/", "_")
|
|
target_name = f"{today}_{safe_name}"
|
|
target_path = EINGANG_DIR / target_name
|
|
with target_path.open("wb") as buffer:
|
|
shutil.copyfileobj(file.file, buffer)
|
|
saved.append(str(target_path))
|
|
return {"status": "OK", "message": "Dateien gespeichert", "saved_as": saved}
|
|
|
|
|
|
# === LISTEN & LÖSCHEN ===
|
|
|
|
|
|
@router.get("/eingang-dateien")
|
|
def list_eingang_files():
|
|
files = [f.name for f in EINGANG_DIR.iterdir() if is_valid_input_file(f)]
|
|
return {"eingang": files}
|
|
|
|
|
|
@router.delete("/eingang-dateien/{filename}")
|
|
def delete_eingang_file(filename: str):
|
|
"""
|
|
Löscht eine Datei komplett und entfernt sie aus allen Lerneinheiten.
|
|
"""
|
|
from learning_units import get_all_units, update_unit
|
|
|
|
path = EINGANG_DIR / filename
|
|
if not path.exists() or not path.is_file():
|
|
return {"status": "ERROR", "message": "Datei nicht gefunden", "filename": filename}
|
|
|
|
# Datei aus allen Lerneinheiten entfernen
|
|
units = get_all_units()
|
|
units_updated = 0
|
|
for unit in units:
|
|
if filename in unit.get("worksheet_files", []):
|
|
# Datei aus der Liste entfernen
|
|
unit["worksheet_files"].remove(filename)
|
|
# Lerneinheit aktualisieren
|
|
update_unit(unit["id"], unit)
|
|
units_updated += 1
|
|
|
|
# Physische Datei löschen
|
|
path.unlink()
|
|
|
|
return {
|
|
"status": "OK",
|
|
"message": "Datei gelöscht",
|
|
"filename": filename,
|
|
"units_updated": units_updated
|
|
}
|
|
|
|
|
|
@router.get("/bereinigt-dateien")
|
|
def list_bereinigt_files():
|
|
files = [f.name for f in BEREINIGT_DIR.iterdir() if f.is_file()]
|
|
return {"bereinigt": files}
|
|
|
|
|
|
# === VERARBEITUNG: Dummy / Beschreiben / Analysieren / HTML erzeugen ===
|
|
|
|
|
|
@router.post("/process-all")
|
|
def process_all_scans():
|
|
"""
|
|
Einfache Dummy-Verarbeitung aller Eingangsdateien.
|
|
Dient vor allem als Fallback / Debug.
|
|
"""
|
|
processed = []
|
|
skipped = []
|
|
for f in EINGANG_DIR.iterdir():
|
|
if is_valid_input_file(f):
|
|
out = dummy_process_scan(f)
|
|
processed.append(out.name)
|
|
else:
|
|
skipped.append(f.name)
|
|
return {
|
|
"status": "OK",
|
|
"processed_files": processed,
|
|
"skipped": skipped,
|
|
}
|
|
|
|
|
|
@router.post("/remove-handwriting-all")
|
|
def remove_handwriting_all():
|
|
"""
|
|
MVP-Version: erzeugt *_clean-Bilder im Ordner Bereinigt.
|
|
Die eigentliche inhaltliche „Bereinigung“ (keine Handschrift, keine durchgestrichenen Wörter)
|
|
passiert später beim HTML-Neuaufbau.
|
|
"""
|
|
cleaned = []
|
|
errors = []
|
|
skipped = []
|
|
for f in EINGANG_DIR.iterdir():
|
|
if not is_valid_input_file(f):
|
|
skipped.append(f.name)
|
|
continue
|
|
if f.suffix.lower() not in {".jpg", ".jpeg", ".png"}:
|
|
skipped.append(f.name)
|
|
continue
|
|
try:
|
|
out = remove_handwriting_from_scan(f)
|
|
cleaned.append(out.name)
|
|
except Exception as e:
|
|
errors.append({"file": f.name, "error": str(e)})
|
|
return {
|
|
"status": "OK" if cleaned else "ERROR",
|
|
"cleaned": cleaned,
|
|
"errors": errors,
|
|
"skipped": skipped,
|
|
}
|
|
|
|
|
|
@router.post("/describe-all")
|
|
def describe_all_scans():
|
|
"""
|
|
Vision-Modell beschreibt alle gültigen Eingangsdateien kurz.
|
|
Ergebnis: *_beschreibung.txt in Bereinigt.
|
|
"""
|
|
described = []
|
|
errors = []
|
|
skipped = []
|
|
for f in EINGANG_DIR.iterdir():
|
|
if not is_valid_input_file(f):
|
|
skipped.append(f.name)
|
|
continue
|
|
try:
|
|
out = describe_scan_with_ai(f)
|
|
described.append(out.name)
|
|
except Exception as e:
|
|
errors.append({"file": f.name, "error": str(e)})
|
|
return {"status": "OK", "described": described, "errors": errors, "skipped": skipped}
|
|
|
|
|
|
@router.post("/analyze-all")
|
|
def analyze_all_scans():
|
|
"""
|
|
Vision-Modell analysiert Struktur aller Eingangsdateien.
|
|
Ergebnis: *_analyse.json in Bereinigt.
|
|
"""
|
|
analyzed = []
|
|
errors = []
|
|
skipped = []
|
|
for f in EINGANG_DIR.iterdir():
|
|
if not is_valid_input_file(f):
|
|
skipped.append(f.name)
|
|
continue
|
|
try:
|
|
out = analyze_scan_structure_with_ai(f)
|
|
analyzed.append(out.name)
|
|
except Exception as e:
|
|
errors.append({"file": f.name, "error": str(e)})
|
|
return {"status": "OK", "analyzed": analyzed, "errors": errors, "skipped": skipped}
|
|
|
|
|
|
@router.post("/generate-clean")
|
|
def generate_clean_worksheets():
|
|
"""
|
|
Baut aus allen *_analyse.json Dateien saubere HTML-Arbeitsblätter.
|
|
Ergebnis: *_clean.html in Bereinigt.
|
|
"""
|
|
generated = []
|
|
errors = []
|
|
for f in BEREINIGT_DIR.iterdir():
|
|
if f.suffix == ".json" and f.name.endswith("_analyse.json"):
|
|
try:
|
|
out = build_clean_html_from_analysis(f)
|
|
generated.append(out.name)
|
|
except Exception as e:
|
|
errors.append({"file": f.name, "error": str(e)})
|
|
return {"status": "OK", "generated": generated, "errors": errors}
|
|
|
|
|
|
# === Mapping Alt vs. Neu + HTML-/Image-Auslieferung ===
|
|
|
|
|
|
@router.get("/worksheet-pairs")
|
|
def list_worksheet_pairs():
|
|
"""
|
|
Für jede Datei im Eingang wird nach einem passenden „sauberen“ Pendant gesucht:
|
|
|
|
- HTML:
|
|
- <stem>_clean.html
|
|
- <stem>__clean.html (kompatibel zu früherem Bug mit doppeltem Unterstrich)
|
|
- <stem>.html
|
|
|
|
- Bild:
|
|
- <stem>_clean.(jpg|jpeg|png|JPG|JPEG|PNG)
|
|
- <stem>__clean.(...) (falls so erzeugt)
|
|
"""
|
|
pairs = []
|
|
for f in EINGANG_DIR.iterdir():
|
|
if not is_valid_input_file(f):
|
|
continue
|
|
base = f.stem
|
|
clean_html = None
|
|
clean_image = None
|
|
|
|
# HTML-Kandidaten
|
|
html_candidates = [
|
|
f"{base}_clean.html",
|
|
f"{base}__clean.html",
|
|
f"{base}.html",
|
|
]
|
|
for cand in html_candidates:
|
|
p = BEREINIGT_DIR / cand
|
|
if p.exists() and p.is_file():
|
|
clean_html = cand
|
|
break
|
|
|
|
# Bild-Kandidaten (immer suchen, unabhängig von HTML)
|
|
exts = [".jpg", ".jpeg", ".png", ".JPG", ".JPEG", ".PNG"]
|
|
found = False
|
|
for ext in exts:
|
|
for suffix in ["_clean", "__clean"]:
|
|
cand_img = f"{base}{suffix}{ext}"
|
|
p = BEREINIGT_DIR / cand_img
|
|
if p.exists() and p.is_file():
|
|
clean_image = cand_img
|
|
found = True
|
|
break
|
|
if found:
|
|
break
|
|
|
|
pairs.append(
|
|
{
|
|
"original": f.name,
|
|
"clean_html": clean_html,
|
|
"clean_image": clean_image,
|
|
}
|
|
)
|
|
|
|
return {"pairs": pairs}
|
|
|
|
|
|
@router.get("/clean-html/{filename}", response_class=HTMLResponse)
|
|
def get_clean_html(filename: str):
|
|
"""
|
|
Liefert eine der erzeugten clean-HTML-Dateien aus dem Ordner Bereinigt.
|
|
Wird im Frontend für die „neu aufgebautes Arbeitsblatt"-Vorschau genutzt.
|
|
"""
|
|
path = BEREINIGT_DIR / filename
|
|
if not path.exists() or not path.is_file() or path.suffix != ".html":
|
|
return HTMLResponse(
|
|
"<html><body><p>Kein neu aufgebautes Arbeitsblatt gefunden.</p></body></html>",
|
|
status_code=404,
|
|
)
|
|
content = path.read_text(encoding="utf-8")
|
|
return HTMLResponse(content)
|
|
|
|
|
|
# === MULTIPLE CHOICE ===
|
|
|
|
|
|
@router.post("/generate-mc")
|
|
def generate_mc_all():
|
|
"""
|
|
Generiert Multiple-Choice-Fragen für alle analysierten Arbeitsblätter.
|
|
|
|
Voraussetzung: Die Analyse (*_analyse.json) muss bereits existieren.
|
|
Ergebnis: *_mc.json Dateien im Ordner Bereinigt.
|
|
|
|
Die MC-Fragen:
|
|
- Entsprechen dem Schwierigkeitsgrad des Originals
|
|
- Haben zufällig angeordnete Antworten (nicht immer A = richtig)
|
|
"""
|
|
generated = []
|
|
errors = []
|
|
skipped = []
|
|
|
|
for f in BEREINIGT_DIR.iterdir():
|
|
if f.suffix == ".json" and f.name.endswith("_analyse.json"):
|
|
try:
|
|
out = generate_mc_from_analysis(f)
|
|
generated.append(out.name)
|
|
except Exception as e:
|
|
errors.append({"file": f.name, "error": str(e)})
|
|
elif f.suffix == ".json":
|
|
skipped.append(f.name)
|
|
|
|
return {
|
|
"status": "OK" if generated else "ERROR",
|
|
"generated": generated,
|
|
"errors": errors,
|
|
"skipped": skipped,
|
|
}
|
|
|
|
|
|
@router.get("/mc-data/{filename}")
|
|
def get_mc_data(filename: str):
|
|
"""
|
|
Liefert die generierten MC-Fragen für ein Arbeitsblatt.
|
|
|
|
Args:
|
|
filename: Name der Original-Datei (z.B. "2024-12-10_mathe.jpg")
|
|
oder der MC-Datei (z.B. "2024-12-10_mathe_mc.json")
|
|
|
|
Returns:
|
|
JSON mit questions und metadata
|
|
"""
|
|
import json
|
|
|
|
# Versuche verschiedene Dateinamen-Formate
|
|
base = filename
|
|
if base.endswith("_mc.json"):
|
|
mc_filename = base
|
|
else:
|
|
# Entferne Dateiendung und füge _mc.json hinzu
|
|
base_stem = filename.rsplit(".", 1)[0] if "." in filename else filename
|
|
mc_filename = f"{base_stem}_mc.json"
|
|
|
|
path = BEREINIGT_DIR / mc_filename
|
|
if not path.exists() or not path.is_file():
|
|
return {"status": "NOT_FOUND", "message": f"MC-Daten nicht gefunden: {mc_filename}"}
|
|
|
|
try:
|
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|
return {"status": "OK", "data": data, "filename": mc_filename}
|
|
except json.JSONDecodeError as e:
|
|
return {"status": "ERROR", "message": f"Ungültige JSON-Datei: {e}"}
|
|
|
|
|
|
@router.get("/mc-list")
|
|
def list_mc_files():
|
|
"""
|
|
Listet alle generierten MC-Dateien auf.
|
|
|
|
Returns:
|
|
Liste der MC-Dateien mit zugehörigen Original-Dateinamen
|
|
"""
|
|
mc_files = []
|
|
|
|
for f in BEREINIGT_DIR.iterdir():
|
|
if f.suffix == ".json" and f.name.endswith("_mc.json"):
|
|
# Versuche Original-Datei zu finden
|
|
base_stem = f.stem.replace("_mc", "")
|
|
original = None
|
|
|
|
# Suche nach passender Datei im Eingang
|
|
for ext in [".jpg", ".jpeg", ".png", ".JPG", ".JPEG", ".PNG", ".pdf", ".PDF"]:
|
|
candidate = EINGANG_DIR / f"{base_stem}{ext}"
|
|
if candidate.exists():
|
|
original = candidate.name
|
|
break
|
|
|
|
mc_files.append({
|
|
"mc_file": f.name,
|
|
"original": original,
|
|
"base_name": base_stem,
|
|
})
|
|
|
|
return {"mc_files": mc_files}
|
|
|
|
|
|
# === LÜCKENTEXT ===
|
|
|
|
|
|
@router.post("/generate-cloze")
|
|
def generate_cloze_all(target_language: str = "tr"):
|
|
"""
|
|
Generiert Lückentexte für alle analysierten Arbeitsblätter.
|
|
|
|
Voraussetzung: Die Analyse (*_analyse.json) muss bereits existieren.
|
|
Ergebnis: *_cloze.json Dateien im Ordner Bereinigt.
|
|
|
|
Die Lückentexte:
|
|
- Haben mehrere sinnvolle Lücken pro Satz
|
|
- Entsprechen dem Schwierigkeitsgrad des Originals
|
|
- Enthalten eine Übersetzung mit denselben Lücken
|
|
|
|
Args:
|
|
target_language: Sprachcode für Übersetzung (default: "tr" für Türkisch)
|
|
Unterstützt: tr, ar, ru, en, fr, es, pl, uk
|
|
"""
|
|
generated = []
|
|
errors = []
|
|
skipped = []
|
|
|
|
for f in BEREINIGT_DIR.iterdir():
|
|
if f.suffix == ".json" and f.name.endswith("_analyse.json"):
|
|
try:
|
|
out = generate_cloze_from_analysis(f, target_language)
|
|
generated.append(out.name)
|
|
except Exception as e:
|
|
errors.append({"file": f.name, "error": str(e)})
|
|
elif f.suffix == ".json":
|
|
skipped.append(f.name)
|
|
|
|
return {
|
|
"status": "OK" if generated else "ERROR",
|
|
"generated": generated,
|
|
"errors": errors,
|
|
"skipped": skipped,
|
|
"target_language": target_language,
|
|
}
|
|
|
|
|
|
@router.get("/cloze-data/{filename}")
|
|
def get_cloze_data(filename: str):
|
|
"""
|
|
Liefert die generierten Lückentexte für ein Arbeitsblatt.
|
|
|
|
Args:
|
|
filename: Name der Original-Datei (z.B. "2024-12-10_deutsch.jpg")
|
|
oder der Cloze-Datei (z.B. "2024-12-10_deutsch_cloze.json")
|
|
|
|
Returns:
|
|
JSON mit cloze_items und metadata
|
|
"""
|
|
import json
|
|
|
|
# Versuche verschiedene Dateinamen-Formate
|
|
base = filename
|
|
if base.endswith("_cloze.json"):
|
|
cloze_filename = base
|
|
else:
|
|
# Entferne Dateiendung und füge _cloze.json hinzu
|
|
base_stem = filename.rsplit(".", 1)[0] if "." in filename else filename
|
|
cloze_filename = f"{base_stem}_cloze.json"
|
|
|
|
path = BEREINIGT_DIR / cloze_filename
|
|
if not path.exists() or not path.is_file():
|
|
return {"status": "NOT_FOUND", "message": f"Lückentext-Daten nicht gefunden: {cloze_filename}"}
|
|
|
|
try:
|
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|
return {"status": "OK", "data": data, "filename": cloze_filename}
|
|
except json.JSONDecodeError as e:
|
|
return {"status": "ERROR", "message": f"Ungültige JSON-Datei: {e}"}
|
|
|
|
|
|
@router.get("/cloze-list")
|
|
def list_cloze_files():
|
|
"""
|
|
Listet alle generierten Lückentext-Dateien auf.
|
|
|
|
Returns:
|
|
Liste der Cloze-Dateien mit zugehörigen Original-Dateinamen
|
|
"""
|
|
cloze_files = []
|
|
|
|
for f in BEREINIGT_DIR.iterdir():
|
|
if f.suffix == ".json" and f.name.endswith("_cloze.json"):
|
|
# Versuche Original-Datei zu finden
|
|
base_stem = f.stem.replace("_cloze", "")
|
|
original = None
|
|
|
|
# Suche nach passender Datei im Eingang
|
|
for ext in [".jpg", ".jpeg", ".png", ".JPG", ".JPEG", ".PNG", ".pdf", ".PDF"]:
|
|
candidate = EINGANG_DIR / f"{base_stem}{ext}"
|
|
if candidate.exists():
|
|
original = candidate.name
|
|
break
|
|
|
|
cloze_files.append({
|
|
"cloze_file": f.name,
|
|
"original": original,
|
|
"base_name": base_stem,
|
|
})
|
|
|
|
return {"cloze_files": cloze_files}
|
|
|
|
|
|
# === FRAGE-ANTWORT (Q&A) MIT LEITNER-SYSTEM ===
|
|
|
|
|
|
@router.post("/generate-qa")
|
|
def generate_qa_all(num_questions: int = 8):
|
|
"""
|
|
Generiert Frage-Antwort-Paare für alle analysierten Arbeitsblätter.
|
|
|
|
Voraussetzung: Die Analyse (*_analyse.json) muss bereits existieren.
|
|
Ergebnis: *_qa.json Dateien im Ordner Bereinigt.
|
|
|
|
Die Q&A-Paare:
|
|
- Fragen sind fast wörtlich aus dem Text entnommen
|
|
- Schlüsselbegriffe sind markiert für Spaced Repetition
|
|
- Enthalten Leitner-Box Metadaten für Lernfortschritt
|
|
|
|
Args:
|
|
num_questions: Anzahl der zu generierenden Fragen (default: 8)
|
|
"""
|
|
generated = []
|
|
errors = []
|
|
skipped = []
|
|
|
|
for f in BEREINIGT_DIR.iterdir():
|
|
if f.suffix == ".json" and f.name.endswith("_analyse.json"):
|
|
try:
|
|
out = generate_qa_from_analysis(f, num_questions)
|
|
generated.append(out.name)
|
|
except Exception as e:
|
|
errors.append({"file": f.name, "error": str(e)})
|
|
elif f.suffix == ".json":
|
|
skipped.append(f.name)
|
|
|
|
return {
|
|
"status": "OK" if generated else "ERROR",
|
|
"generated": generated,
|
|
"errors": errors,
|
|
"skipped": skipped,
|
|
"num_questions": num_questions,
|
|
}
|
|
|
|
|
|
@router.get("/qa-data/{filename}")
|
|
def get_qa_data(filename: str):
|
|
"""
|
|
Liefert die generierten Q&A-Paare für ein Arbeitsblatt.
|
|
|
|
Args:
|
|
filename: Name der Original-Datei (z.B. "2024-12-10_deutsch.jpg")
|
|
oder der QA-Datei (z.B. "2024-12-10_deutsch_qa.json")
|
|
|
|
Returns:
|
|
JSON mit qa_items und metadata inkl. Leitner-Fortschritt
|
|
"""
|
|
import json
|
|
|
|
# Versuche verschiedene Dateinamen-Formate
|
|
base = filename
|
|
if base.endswith("_qa.json"):
|
|
qa_filename = base
|
|
else:
|
|
# Entferne Dateiendung und füge _qa.json hinzu
|
|
base_stem = filename.rsplit(".", 1)[0] if "." in filename else filename
|
|
qa_filename = f"{base_stem}_qa.json"
|
|
|
|
path = BEREINIGT_DIR / qa_filename
|
|
if not path.exists() or not path.is_file():
|
|
return {"status": "NOT_FOUND", "message": f"Q&A-Daten nicht gefunden: {qa_filename}"}
|
|
|
|
try:
|
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|
return {"status": "OK", "data": data, "filename": qa_filename}
|
|
except json.JSONDecodeError as e:
|
|
return {"status": "ERROR", "message": f"Ungültige JSON-Datei: {e}"}
|
|
|
|
|
|
@router.get("/qa-list")
|
|
def list_qa_files():
|
|
"""
|
|
Listet alle generierten Q&A-Dateien auf.
|
|
|
|
Returns:
|
|
Liste der QA-Dateien mit zugehörigen Original-Dateinamen
|
|
"""
|
|
qa_files = []
|
|
|
|
for f in BEREINIGT_DIR.iterdir():
|
|
if f.suffix == ".json" and f.name.endswith("_qa.json"):
|
|
# Versuche Original-Datei zu finden
|
|
base_stem = f.stem.replace("_qa", "")
|
|
original = None
|
|
|
|
# Suche nach passender Datei im Eingang
|
|
for ext in [".jpg", ".jpeg", ".png", ".JPG", ".JPEG", ".PNG", ".pdf", ".PDF"]:
|
|
candidate = EINGANG_DIR / f"{base_stem}{ext}"
|
|
if candidate.exists():
|
|
original = candidate.name
|
|
break
|
|
|
|
qa_files.append({
|
|
"qa_file": f.name,
|
|
"original": original,
|
|
"base_name": base_stem,
|
|
})
|
|
|
|
return {"qa_files": qa_files}
|
|
|
|
|
|
@router.post("/qa-progress")
|
|
def update_qa_progress(filename: str, item_id: str, correct: bool):
|
|
"""
|
|
Aktualisiert den Leitner-Lernfortschritt für eine Q&A-Frage.
|
|
|
|
Das Leitner-System:
|
|
- Box 0 (neu): Wiederholung nach 1 Tag
|
|
- Box 1 (gelernt): Wiederholung nach 3 Tagen
|
|
- Box 2 (gefestigt): Wiederholung nach 7 Tagen
|
|
- Falsche Antwort: Eine Box zurück, Wiederholung nach 4 Stunden
|
|
|
|
Args:
|
|
filename: Name der QA-Datei
|
|
item_id: ID der Frage (z.B. "q_0")
|
|
correct: True wenn richtig beantwortet, False wenn falsch
|
|
|
|
Returns:
|
|
Aktualisierter Fortschritt für die Frage
|
|
"""
|
|
# Finde QA-Datei
|
|
base = filename
|
|
if base.endswith("_qa.json"):
|
|
qa_filename = base
|
|
else:
|
|
base_stem = filename.rsplit(".", 1)[0] if "." in filename else filename
|
|
qa_filename = f"{base_stem}_qa.json"
|
|
|
|
path = BEREINIGT_DIR / qa_filename
|
|
if not path.exists() or not path.is_file():
|
|
return {"status": "NOT_FOUND", "message": f"Q&A-Daten nicht gefunden: {qa_filename}"}
|
|
|
|
try:
|
|
result = update_leitner_progress(path, item_id, correct)
|
|
return {"status": "OK", "progress": result, "filename": qa_filename}
|
|
except Exception as e:
|
|
return {"status": "ERROR", "message": str(e)}
|
|
|
|
|
|
@router.get("/qa-review/{filename}")
|
|
def get_qa_review_items(filename: str, limit: int = 5):
|
|
"""
|
|
Liefert die nächsten zu wiederholenden Fragen basierend auf dem Leitner-System.
|
|
|
|
Priorisierung:
|
|
1. Überfällige Fragen (nach next_review Zeit)
|
|
2. Niedrigere Boxen zuerst (Box 0 vor Box 1 vor Box 2)
|
|
3. Längere Zeit nicht gesehen
|
|
|
|
Args:
|
|
filename: Name der QA-Datei
|
|
limit: Maximale Anzahl zurückzugebender Fragen (default: 5)
|
|
|
|
Returns:
|
|
Liste der zu wiederholenden Fragen mit Leitner-Metadaten
|
|
"""
|
|
# Finde QA-Datei
|
|
base = filename
|
|
if base.endswith("_qa.json"):
|
|
qa_filename = base
|
|
else:
|
|
base_stem = filename.rsplit(".", 1)[0] if "." in filename else filename
|
|
qa_filename = f"{base_stem}_qa.json"
|
|
|
|
path = BEREINIGT_DIR / qa_filename
|
|
if not path.exists() or not path.is_file():
|
|
return {"status": "NOT_FOUND", "message": f"Q&A-Daten nicht gefunden: {qa_filename}"}
|
|
|
|
try:
|
|
items = get_next_review_items(path, limit)
|
|
return {"status": "OK", "review_items": items, "filename": qa_filename, "count": len(items)}
|
|
except Exception as e:
|
|
return {"status": "ERROR", "message": str(e)}
|
|
|
|
|
|
# === PRINT-VERSIONEN ===
|
|
|
|
|
|
@router.get("/print-qa/{filename}", response_class=HTMLResponse)
|
|
def get_print_qa(filename: str, show_answers: bool = False):
|
|
"""
|
|
Liefert eine druckbare HTML-Version der Q&A-Fragen.
|
|
|
|
Args:
|
|
filename: Name der QA-Datei oder Original-Datei
|
|
show_answers: True für Lösungsblatt, False für Fragenblatt
|
|
|
|
Returns:
|
|
Druckbare HTML-Seite
|
|
"""
|
|
# Finde QA-Datei
|
|
base = filename
|
|
if base.endswith("_qa.json"):
|
|
qa_filename = base
|
|
else:
|
|
base_stem = filename.rsplit(".", 1)[0] if "." in filename else filename
|
|
qa_filename = f"{base_stem}_qa.json"
|
|
|
|
path = BEREINIGT_DIR / qa_filename
|
|
if not path.exists() or not path.is_file():
|
|
return HTMLResponse(
|
|
"<html><body><p>Q&A-Daten nicht gefunden.</p></body></html>",
|
|
status_code=404,
|
|
)
|
|
|
|
try:
|
|
html = generate_print_version_qa(path, show_answers)
|
|
return HTMLResponse(html)
|
|
except Exception as e:
|
|
return HTMLResponse(
|
|
f"<html><body><p>Fehler: {e}</p></body></html>",
|
|
status_code=500,
|
|
)
|
|
|
|
|
|
@router.get("/print-cloze/{filename}", response_class=HTMLResponse)
|
|
def get_print_cloze(filename: str, show_answers: bool = False):
|
|
"""
|
|
Liefert eine druckbare HTML-Version der Lückentexte.
|
|
|
|
Args:
|
|
filename: Name der Cloze-Datei oder Original-Datei
|
|
show_answers: True für Lösungsblatt mit ausgefüllten Lücken,
|
|
False für Übungsblatt mit Wortbank
|
|
|
|
Returns:
|
|
Druckbare HTML-Seite
|
|
"""
|
|
# Finde Cloze-Datei
|
|
base = filename
|
|
if base.endswith("_cloze.json"):
|
|
cloze_filename = base
|
|
else:
|
|
base_stem = filename.rsplit(".", 1)[0] if "." in filename else filename
|
|
cloze_filename = f"{base_stem}_cloze.json"
|
|
|
|
path = BEREINIGT_DIR / cloze_filename
|
|
if not path.exists() or not path.is_file():
|
|
return HTMLResponse(
|
|
"<html><body><p>Lückentext-Daten nicht gefunden.</p></body></html>",
|
|
status_code=404,
|
|
)
|
|
|
|
try:
|
|
html = generate_print_version_cloze(path, show_answers)
|
|
return HTMLResponse(html)
|
|
except Exception as e:
|
|
return HTMLResponse(
|
|
f"<html><body><p>Fehler: {e}</p></body></html>",
|
|
status_code=500,
|
|
)
|
|
|
|
|
|
@router.get("/print-mc/{filename}", response_class=HTMLResponse)
|
|
def get_print_mc(filename: str, show_answers: bool = False):
|
|
"""
|
|
Liefert eine druckbare HTML-Version der Multiple-Choice-Fragen.
|
|
|
|
Args:
|
|
filename: Name der MC-Datei oder Original-Datei
|
|
show_answers: True für Lösungsblatt mit markierten Antworten,
|
|
False für Testblatt zum Ausfüllen
|
|
|
|
Returns:
|
|
Druckbare HTML-Seite
|
|
"""
|
|
# Finde MC-Datei
|
|
base = filename
|
|
if base.endswith("_mc.json"):
|
|
mc_filename = base
|
|
else:
|
|
base_stem = filename.rsplit(".", 1)[0] if "." in filename else filename
|
|
mc_filename = f"{base_stem}_mc.json"
|
|
|
|
path = BEREINIGT_DIR / mc_filename
|
|
if not path.exists() or not path.is_file():
|
|
return HTMLResponse(
|
|
"<html><body><p>MC-Daten nicht gefunden.</p></body></html>",
|
|
status_code=404,
|
|
)
|
|
|
|
try:
|
|
html = generate_print_version_mc(path, show_answers)
|
|
return HTMLResponse(html)
|
|
except Exception as e:
|
|
return HTMLResponse(
|
|
f"<html><body><p>Fehler beim Erstellen der MC-Druckversion: {e}</p></body></html>",
|
|
status_code=500,
|
|
)
|
|
|
|
|
|
@router.get("/print-worksheet/{filename}", response_class=HTMLResponse)
|
|
def get_print_worksheet(filename: str):
|
|
"""
|
|
Liefert eine druckoptimierte HTML-Version des neu aufgebauten Arbeitsblatts.
|
|
|
|
- Große, klare Schrift
|
|
- Schwarz-weiß / Graustufen-tauglich
|
|
- Direkt druckbar für Eltern
|
|
"""
|
|
# Analysedatei finden
|
|
base = filename.rsplit(".", 1)[0] # Entfernt Dateiendung
|
|
analysis_path = BEREINIGT_DIR / f"{base}_analyse.json"
|
|
|
|
if not analysis_path.exists():
|
|
return HTMLResponse(
|
|
f"<html><body><p>Keine Analyse gefunden für: {filename}</p></body></html>",
|
|
status_code=404,
|
|
)
|
|
|
|
try:
|
|
html = generate_print_version_worksheet(analysis_path)
|
|
return HTMLResponse(html)
|
|
except Exception as e:
|
|
return HTMLResponse(
|
|
f"<html><body><p>Fehler beim Erstellen der Druckversion: {e}</p></body></html>",
|
|
status_code=500,
|
|
)
|
|
|
|
|
|
# === MINDMAP LERNPOSTER ===
|
|
|
|
|
|
@router.post("/generate-mindmap/{filename}")
|
|
def generate_mindmap(filename: str):
|
|
"""
|
|
Generiert eine kindgerechte Mindmap aus einem analysierten Arbeitsblatt.
|
|
|
|
Die Mindmap:
|
|
- Zeigt das Hauptthema in der Mitte
|
|
- Gruppiert Fachbegriffe in farbige Kategorien
|
|
- Enthält Emojis für bessere visuelle Orientierung
|
|
- Kann als A3 Poster gedruckt werden
|
|
|
|
Args:
|
|
filename: Name der Original-Datei (z.B. "2024-12-10_biologie.jpg")
|
|
|
|
Returns:
|
|
Status und Dateiname der generierten Mindmap
|
|
"""
|
|
# Analysedatei finden
|
|
base_stem = filename.rsplit(".", 1)[0] if "." in filename else filename
|
|
analysis_path = BEREINIGT_DIR / f"{base_stem}_analyse.json"
|
|
|
|
if not analysis_path.exists():
|
|
return {
|
|
"status": "NOT_FOUND",
|
|
"message": f"Keine Analyse gefunden für: {filename}. Bitte zuerst analysieren."
|
|
}
|
|
|
|
try:
|
|
# Mindmap-Daten generieren
|
|
mindmap_data = generate_mindmap_data(analysis_path)
|
|
|
|
# Mindmap speichern
|
|
mindmap_path = save_mindmap_for_worksheet(analysis_path, mindmap_data)
|
|
|
|
return {
|
|
"status": "OK",
|
|
"message": "Mindmap erfolgreich generiert",
|
|
"filename": mindmap_path.name,
|
|
"topic": mindmap_data.get("topic", ""),
|
|
"categories_count": len(mindmap_data.get("categories", [])),
|
|
}
|
|
except Exception as e:
|
|
return {
|
|
"status": "ERROR",
|
|
"message": f"Fehler bei der Mindmap-Generierung: {e}"
|
|
}
|
|
|
|
|
|
@router.get("/mindmap-data/{filename}")
|
|
def get_mindmap_data(filename: str):
|
|
"""
|
|
Liefert die generierten Mindmap-Daten für ein Arbeitsblatt.
|
|
|
|
Args:
|
|
filename: Name der Original-Datei oder der Mindmap-Datei
|
|
|
|
Returns:
|
|
JSON mit topic, subject und categories
|
|
"""
|
|
import json
|
|
|
|
# Versuche verschiedene Dateinamen-Formate
|
|
base = filename
|
|
if base.endswith("_mindmap.json"):
|
|
mindmap_filename = base
|
|
else:
|
|
base_stem = filename.rsplit(".", 1)[0] if "." in filename else filename
|
|
mindmap_filename = f"{base_stem}_mindmap.json"
|
|
|
|
path = BEREINIGT_DIR / mindmap_filename
|
|
if not path.exists() or not path.is_file():
|
|
return {"status": "NOT_FOUND", "message": f"Mindmap-Daten nicht gefunden: {mindmap_filename}"}
|
|
|
|
try:
|
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|
return {"status": "OK", "data": data, "filename": mindmap_filename}
|
|
except json.JSONDecodeError as e:
|
|
return {"status": "ERROR", "message": f"Ungültige JSON-Datei: {e}"}
|
|
|
|
|
|
@router.get("/mindmap-html/{filename}", response_class=HTMLResponse)
|
|
def get_mindmap_html(filename: str, format: str = "a3"):
|
|
"""
|
|
Liefert die Mindmap als druckbares HTML mit SVG.
|
|
|
|
Args:
|
|
filename: Name der Original-Datei oder der Mindmap-Datei
|
|
format: Druckformat - "a3" (Standard, Poster) oder "a4"
|
|
|
|
Returns:
|
|
HTML-Seite mit SVG-Mindmap, druckoptimiert
|
|
"""
|
|
import json
|
|
|
|
# Versuche verschiedene Dateinamen-Formate
|
|
base = filename
|
|
if base.endswith("_mindmap.json"):
|
|
mindmap_filename = base
|
|
else:
|
|
base_stem = filename.rsplit(".", 1)[0] if "." in filename else filename
|
|
mindmap_filename = f"{base_stem}_mindmap.json"
|
|
|
|
path = BEREINIGT_DIR / mindmap_filename
|
|
if not path.exists() or not path.is_file():
|
|
return HTMLResponse(
|
|
"<html><body><p>Mindmap-Daten nicht gefunden. Bitte zuerst generieren.</p></body></html>",
|
|
status_code=404,
|
|
)
|
|
|
|
try:
|
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|
html = generate_mindmap_html(data, format)
|
|
return HTMLResponse(html)
|
|
except json.JSONDecodeError:
|
|
return HTMLResponse(
|
|
"<html><body><p>Ungültige Mindmap-Datei.</p></body></html>",
|
|
status_code=500,
|
|
)
|
|
except Exception as e:
|
|
return HTMLResponse(
|
|
f"<html><body><p>Fehler beim Erstellen der Mindmap: {e}</p></body></html>",
|
|
status_code=500,
|
|
)
|
|
|
|
|
|
@router.get("/mindmap-list")
|
|
def list_mindmap_files():
|
|
"""
|
|
Listet alle generierten Mindmap-Dateien auf.
|
|
|
|
Returns:
|
|
Liste der Mindmap-Dateien mit zugehörigen Original-Dateinamen
|
|
"""
|
|
mindmap_files = []
|
|
|
|
for f in BEREINIGT_DIR.iterdir():
|
|
if f.suffix == ".json" and f.name.endswith("_mindmap.json"):
|
|
# Versuche Original-Datei zu finden
|
|
base_stem = f.stem.replace("_mindmap", "")
|
|
original = None
|
|
|
|
# Suche nach passender Datei im Eingang
|
|
for ext in [".jpg", ".jpeg", ".png", ".JPG", ".JPEG", ".PNG", ".pdf", ".PDF"]:
|
|
candidate = EINGANG_DIR / f"{base_stem}{ext}"
|
|
if candidate.exists():
|
|
original = candidate.name
|
|
break
|
|
|
|
mindmap_files.append({
|
|
"mindmap_file": f.name,
|
|
"original": original,
|
|
"base_name": base_stem,
|
|
})
|
|
|
|
return {"mindmap_files": mindmap_files}
|