""" AI Processing - Worksheet Analysis. Strukturierte Analyse von Arbeitsblättern mit OpenAI oder Claude. """ from pathlib import Path import json import requests import logging from .core import ( get_openai_api_key, encode_image_to_data_url, BEREINIGT_DIR, get_vision_api, ) logger = logging.getLogger(__name__) def describe_scan_with_ai(input_path: Path) -> Path: """Vision-Modell gibt eine kurze Beschreibung des Arbeitsblatts zurück.""" if not input_path.exists(): raise FileNotFoundError(f"Eingabedatei nicht gefunden: {input_path}") api_key = get_openai_api_key() image_data_url = encode_image_to_data_url(input_path) url = "https://api.openai.com/v1/chat/completions" headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"} payload = { "model": "gpt-4o-mini", "messages": [ { "role": "system", "content": "Du bist ein hilfreicher Assistent, der Schul-Arbeitsblätter knapp beschreibt.", }, { "role": "user", "content": [ { "type": "text", "text": ( "Beschreibe dieses Arbeitsblatt knapp: Thema, Art der Aufgaben " "(z.B. Lückentext, Multiple Choice, Rechenaufgaben) und groben Inhalt." ), }, {"type": "image_url", "image_url": {"url": image_data_url}}, ], }, ], "max_tokens": 400, } response = requests.post(url, headers=headers, json=payload) response.raise_for_status() data = response.json() try: description = data["choices"][0]["message"]["content"] except Exception as e: raise RuntimeError(f"Unerwartete Antwortstruktur von der KI: {e}\nAntwort: {data}") from e out_name = input_path.stem + "_beschreibung.txt" out_path = BEREINIGT_DIR / out_name out_path.write_text(description, encoding="utf-8") return out_path def _get_analysis_system_prompt() -> str: """Gibt den System-Prompt für die Arbeitsblatt-Analyse zurück.""" return ( "Du bist ein Experte für die Analyse von Schul-Arbeitsblättern.\n\n" "HAUPTAUFGABEN:\n" "1. Erkenne ALLE gedruckten Elemente: Text, Überschriften, Tabellen, Linien, Kästchen, Diagramme, Illustrationen\n" "2. Identifiziere ALLE handschriftlichen Ergänzungen: Antworten, Zahlen, Buchstaben, Notizen, Zeichnungen\n" "3. Bestimme präzise Positionen (Bounding Boxes in Pixeln) für JEDES Element\n\n" "KRITISCH - DIAGRAMME & ILLUSTRATIONEN:\n" "- Suche aktiv nach: anatomischen Zeichnungen, beschrifteten Diagrammen, Grafiken, Tabellen, Skizzen\n" "- Wenn du irgendeine bildliche Darstellung siehst (z.B. Auge, Pflanze, Karte, Schaubild), setze 'has_diagram: true'\n" "- Für JEDES visuelle Element: Erstelle einen Eintrag in 'diagram_elements' mit genauer Position\n" "- Beschrifte-Linien (von Beschriftung zu Bildteil) gehören zum Diagramm!\n\n" "HANDSCHRIFT ERKENNUNG:\n" "- Unterscheide gedruckt vs. handgeschrieben anhand der Schriftart\n" "- Klassifiziere Farbe: blau/schwarz/rot/pencil (Bleistift)\n" "- Durchgestrichene Wörter separat auflisten\n\n" "AUSGABE: Gib deine Antwort AUSSCHLIESSLICH als gültiges JSON zurück (kein Markdown, keine Code-Blöcke)." ) def _analyze_with_openai(input_path: Path) -> Path: """Strukturierte JSON-Analyse des Arbeitsblatts mit OpenAI.""" if not input_path.exists(): raise FileNotFoundError(f"Eingabedatei nicht gefunden: {input_path}") api_key = get_openai_api_key() image_data_url = encode_image_to_data_url(input_path) url = "https://api.openai.com/v1/chat/completions" headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"} system_prompt = _get_analysis_system_prompt() user_text = '''Analysiere dieses Arbeitsblatt und gib ein JSON mit folgendem Aufbau zurück: { "title": string | null, "subject": string | null, "grade_level": string | null, "instructions": string | null, "canonical_text": string, "printed_blocks": [...], "handwritten_annotations": [...], "struck_through_words": [...], "tasks": [...], "has_diagram": boolean, "diagram_elements": [...] }''' payload = { "model": "gpt-4o-mini", "messages": [ {"role": "system", "content": system_prompt}, { "role": "user", "content": [ {"type": "text", "text": user_text}, {"type": "image_url", "image_url": {"url": image_data_url}}, ], }, ], "max_tokens": 2500, } response = requests.post(url, headers=headers, json=payload) response.raise_for_status() data = response.json() try: raw_content = data["choices"][0]["message"]["content"] # JSON-Block extrahieren falls in Markdown eingebettet if "```json" in raw_content: raw_content = raw_content.split("```json")[1].split("```")[0].strip() elif "```" in raw_content: raw_content = raw_content.split("```")[1].split("```")[0].strip() obj = json.loads(raw_content) except json.JSONDecodeError as e: raise RuntimeError(f"KI hat kein valides JSON zurückgegeben: {e}\nAntwort: {raw_content}") from e except Exception as e: raise RuntimeError(f"Unerwartete Antwortstruktur: {e}\nAntwort: {data}") from e out_name = input_path.stem + "_analyse.json" out_path = BEREINIGT_DIR / out_name out_path.write_text(json.dumps(obj, ensure_ascii=False, indent=2), encoding="utf-8") return out_path def _analyze_with_claude(input_path: Path) -> Path: """Strukturierte JSON-Analyse mit Claude Vision API.""" from claude_vision import analyze_worksheet_with_claude if not input_path.exists(): raise FileNotFoundError(f"Eingabedatei nicht gefunden: {input_path}") logger.info(f"Analyzing with Claude Vision: {input_path.name}") try: analysis_data = analyze_worksheet_with_claude(input_path, max_tokens=2500) out_name = input_path.stem + "_analyse.json" out_path = BEREINIGT_DIR / out_name out_path.write_text( json.dumps(analysis_data, ensure_ascii=False, indent=2), encoding="utf-8" ) logger.info(f"Claude analysis saved: {out_path.name}") return out_path except Exception as e: logger.error(f"Claude analysis failed: {e}") raise def analyze_scan_structure_with_ai(input_path: Path) -> Path: """ Strukturierte JSON-Analyse des Arbeitsblatts (Hybrid-Modus). Verwendet die in VISION_API konfigurierte API: - "claude" (Standard): Claude 3.5 Sonnet - bessere OCR, Layout-Erkennung - "openai": OpenAI GPT-4o-mini - günstiger, schneller """ vision_api = get_vision_api() logger.info(f"Using Vision API: {vision_api}") if vision_api == "claude": try: return _analyze_with_claude(input_path) except Exception as e: logger.warning(f"Claude failed, falling back to OpenAI: {e}") return _analyze_with_openai(input_path) elif vision_api == "openai": return _analyze_with_openai(input_path) else: logger.warning(f"Unknown VISION_API '{vision_api}', using Claude as default") return _analyze_with_claude(input_path)