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>
308 lines
11 KiB
Python
308 lines
11 KiB
Python
"""
|
|
AI Processor - Scan Analyzer
|
|
|
|
Vision-based analysis of worksheets using OpenAI and Claude APIs.
|
|
"""
|
|
|
|
from pathlib import Path
|
|
import json
|
|
import logging
|
|
import shutil
|
|
import requests
|
|
|
|
from ..config import (
|
|
VISION_API,
|
|
BEREINIGT_DIR,
|
|
get_openai_api_key,
|
|
)
|
|
from ..utils import encode_image_to_data_url
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def describe_scan_with_ai(input_path: Path) -> Path:
|
|
"""
|
|
Vision model gives a short description of the worksheet.
|
|
|
|
Args:
|
|
input_path: Path to the input image
|
|
|
|
Returns:
|
|
Path to the description text file
|
|
"""
|
|
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-Arbeitsblaetter knapp beschreibt.",
|
|
},
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{
|
|
"type": "text",
|
|
"text": (
|
|
"Beschreibe dieses Arbeitsblatt knapp: Thema, Art der Aufgaben "
|
|
"(z.B. Lueckentext, 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 _analyze_with_openai(input_path: Path) -> Path:
|
|
"""
|
|
Structured JSON analysis of the worksheet using OpenAI.
|
|
|
|
Features:
|
|
- canonical_text: complete corrected text without handwriting
|
|
- printed_blocks: structured blocks of printed text
|
|
- handwritten_annotations: student handwritten notes
|
|
- struck_through_words: crossed out words
|
|
"""
|
|
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 = (
|
|
"Du bist ein Experte fuer die Analyse von Schul-Arbeitsblaettern.\n\n"
|
|
"HAUPTAUFGABEN:\n"
|
|
"1. Erkenne ALLE gedruckten Elemente: Text, Ueberschriften, Tabellen, Linien, Kaestchen, Diagramme, Illustrationen\n"
|
|
"2. Identifiziere ALLE handschriftlichen Ergaenzungen: Antworten, Zahlen, Buchstaben, Notizen, Zeichnungen\n"
|
|
"3. Bestimme praezise Positionen (Bounding Boxes in Pixeln) fuer 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"
|
|
"- Fuer JEDES visuelle Element: Erstelle einen Eintrag in 'diagram_elements' mit genauer Position\n"
|
|
"- Beschrifte-Linien (von Beschriftung zu Bildteil) gehoeren zum Diagramm!\n\n"
|
|
"HANDSCHRIFT ERKENNUNG:\n"
|
|
"- Unterscheide gedruckt vs. handgeschrieben anhand der Schriftart\n"
|
|
"- Klassifiziere Farbe: blau/schwarz/rot/pencil (Bleistift)\n"
|
|
"- Durchgestrichene Woerter separat auflisten\n\n"
|
|
"AUSGABE: Gib deine Antwort AUSSCHLIESSLICH als gueltiges JSON zurueck (kein Markdown, keine Code-Bloecke)."
|
|
)
|
|
|
|
user_text = _get_analysis_user_prompt()
|
|
|
|
payload = {
|
|
"model": "gpt-4o-mini",
|
|
"response_format": {"type": "json_object"},
|
|
"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,
|
|
"temperature": 0.15,
|
|
}
|
|
|
|
response = requests.post(url, headers=headers, json=payload)
|
|
response.raise_for_status()
|
|
data = response.json()
|
|
try:
|
|
content = data["choices"][0]["message"]["content"]
|
|
except Exception as e:
|
|
raise RuntimeError(f"Unerwartete Antwortstruktur von der KI: {e}\nAntwort: {data}") from e
|
|
|
|
try:
|
|
obj = json.loads(content)
|
|
except json.JSONDecodeError as e:
|
|
raise RuntimeError(f"Modell hat ungueltiges JSON geliefert: {e}\nInhalt: {content}") 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:
|
|
"""
|
|
Structured JSON analysis with Claude Vision API.
|
|
|
|
Uses Claude 3.5 Sonnet for better OCR and layout detection.
|
|
"""
|
|
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:
|
|
"""
|
|
Structured JSON analysis of the worksheet (Hybrid mode).
|
|
|
|
Uses the API configured in VISION_API:
|
|
- "claude" (default): Claude 3.5 Sonnet - better OCR, layout detection
|
|
- "openai": OpenAI GPT-4o-mini - cheaper, faster
|
|
|
|
Switch via environment variable:
|
|
export VISION_API="claude" # or "openai"
|
|
|
|
Returns:
|
|
Path to analysis JSON file
|
|
"""
|
|
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)
|
|
|
|
|
|
def remove_handwriting_from_scan(input_path: Path) -> Path:
|
|
"""
|
|
Remove handwriting from worksheet scan using AI-guided image processing.
|
|
|
|
Process:
|
|
1. Load corresponding analysis JSON (from Stage 1)
|
|
2. Apply multi-strategy cleaning using WorksheetCleaner
|
|
3. Preserve diagrams and printed content
|
|
4. Save cleaned image
|
|
|
|
Returns:
|
|
Path to cleaned image (*_clean.jpg)
|
|
"""
|
|
if not input_path.exists():
|
|
raise FileNotFoundError(f"Eingabedatei nicht gefunden: {input_path}")
|
|
|
|
from image_cleaner import WorksheetCleaner
|
|
|
|
# Load analysis JSON (from Stage 1)
|
|
analysis_name = input_path.stem + "_analyse.json"
|
|
analysis_path = BEREINIGT_DIR / analysis_name
|
|
|
|
# If analysis doesn't exist, run it first
|
|
if not analysis_path.exists():
|
|
logger.info(f"Analysis not found for {input_path.name}, running analysis first")
|
|
analysis_path = analyze_scan_structure_with_ai(input_path)
|
|
|
|
# Load analysis data
|
|
try:
|
|
analysis_data = json.loads(analysis_path.read_text(encoding='utf-8'))
|
|
except json.JSONDecodeError as e:
|
|
logger.error(f"Invalid analysis JSON: {analysis_path}\n{e}")
|
|
analysis_data = {
|
|
"layout": {"text_regions": [], "diagram_elements": []},
|
|
"handwriting_regions": []
|
|
}
|
|
|
|
# Prepare output path
|
|
output_name = input_path.stem + "_clean" + input_path.suffix
|
|
output_path = BEREINIGT_DIR / output_name
|
|
|
|
# Clean the image using WorksheetCleaner
|
|
cleaner = WorksheetCleaner(debug_mode=False)
|
|
try:
|
|
cleaned_path = cleaner.clean_worksheet(input_path, analysis_data, output_path)
|
|
logger.info(f"Successfully cleaned {input_path.name}")
|
|
return cleaned_path
|
|
except Exception as e:
|
|
logger.error(f"Cleaning failed for {input_path.name}, using original: {e}")
|
|
shutil.copy2(input_path, output_path)
|
|
return output_path
|
|
|
|
|
|
def _get_analysis_user_prompt() -> str:
|
|
"""Get the user prompt for worksheet analysis."""
|
|
return (
|
|
"Analysiere dieses Arbeitsblatt und gib ein JSON mit folgendem Aufbau zurueck:\n\n"
|
|
"{\n"
|
|
' "title": string | null,\n'
|
|
' "subject": string | null,\n'
|
|
' "grade_level": string | null,\n'
|
|
' "instructions": string | null,\n'
|
|
' "canonical_text": string | null,\n'
|
|
' "printed_blocks": [\n'
|
|
" {\n"
|
|
' "id": string,\n'
|
|
' "role": "title" | "instructions" | "body" | "other",\n'
|
|
' "text": string\n'
|
|
" }\n"
|
|
" ],\n"
|
|
' "layout": {\n'
|
|
' "page_structure": {\n'
|
|
' "has_diagram": boolean,\n'
|
|
' "orientation": "portrait" | "landscape"\n'
|
|
" },\n"
|
|
' "text_regions": [...],\n'
|
|
' "diagram_elements": [...]\n'
|
|
" },\n"
|
|
' "handwriting_regions": [...],\n'
|
|
' "handwritten_annotations": [...],\n'
|
|
' "struck_through_words": [...],\n'
|
|
' "tasks": [...]\n'
|
|
"}\n\n"
|
|
"WICHTIG - BITTE GENAU BEACHTEN:\n"
|
|
"1. CANONICAL TEXT: Nur gedruckter Text, OHNE Handschrift\n"
|
|
"2. DIAGRAMME: Bei JEDER Zeichnung/Grafik has_diagram: true setzen\n"
|
|
"3. HANDSCHRIFT: Mit Farb-Klassifizierung und Bounding Boxes\n"
|
|
"4. Bei Unsicherheit: null oder leeres Array"
|
|
)
|