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 """
Kein neu aufgebautes Arbeitsblatt gefunden.
", 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( "Q&A-Daten nicht gefunden.
", status_code=404, ) try: html = generate_print_version_qa(path, show_answers) return HTMLResponse(html) except Exception as e: return HTMLResponse( f"Fehler: {e}
", 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( "Lückentext-Daten nicht gefunden.
", status_code=404, ) try: html = generate_print_version_cloze(path, show_answers) return HTMLResponse(html) except Exception as e: return HTMLResponse( f"Fehler: {e}
", 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( "MC-Daten nicht gefunden.
", status_code=404, ) try: html = generate_print_version_mc(path, show_answers) return HTMLResponse(html) except Exception as e: return HTMLResponse( f"Fehler beim Erstellen der MC-Druckversion: {e}
", 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"Keine Analyse gefunden für: {filename}
", status_code=404, ) try: html = generate_print_version_worksheet(analysis_path) return HTMLResponse(html) except Exception as e: return HTMLResponse( f"Fehler beim Erstellen der Druckversion: {e}
", 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( "Mindmap-Daten nicht gefunden. Bitte zuerst generieren.
", 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( "Ungültige Mindmap-Datei.
", status_code=500, ) except Exception as e: return HTMLResponse( f"Fehler beim Erstellen der Mindmap: {e}
", 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}