fix: Restore all files lost during destructive rebase
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>
This commit is contained in:
17
backend/ai_processor/visualization/__init__.py
Normal file
17
backend/ai_processor/visualization/__init__.py
Normal file
@@ -0,0 +1,17 @@
|
||||
"""
|
||||
AI Processor - Visualization Module
|
||||
|
||||
Mindmap generation for learning posters.
|
||||
"""
|
||||
|
||||
from .mindmap import (
|
||||
generate_mindmap_data,
|
||||
generate_mindmap_html,
|
||||
save_mindmap_for_worksheet,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"generate_mindmap_data",
|
||||
"generate_mindmap_html",
|
||||
"save_mindmap_for_worksheet",
|
||||
]
|
||||
471
backend/ai_processor/visualization/mindmap.py
Normal file
471
backend/ai_processor/visualization/mindmap.py
Normal file
@@ -0,0 +1,471 @@
|
||||
"""
|
||||
AI Processor - Mindmap Generator
|
||||
|
||||
Generate mindmaps for learning posters.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import requests
|
||||
|
||||
from ..config import BEREINIGT_DIR, get_openai_api_key
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def generate_mindmap_data(analysis_path: Path) -> dict:
|
||||
"""
|
||||
Extract technical terms from analysis and group them for a mindmap.
|
||||
|
||||
Args:
|
||||
analysis_path: Path to *_analyse.json file
|
||||
|
||||
Returns:
|
||||
Dictionary with mindmap structure:
|
||||
{
|
||||
"topic": "Main topic",
|
||||
"subject": "Subject",
|
||||
"categories": [
|
||||
{
|
||||
"name": "Category",
|
||||
"color": "#hexcolor",
|
||||
"emoji": "🔬",
|
||||
"terms": [
|
||||
{"term": "Term", "explanation": "Short explanation"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
if not analysis_path.exists():
|
||||
raise FileNotFoundError(f"Analysedatei nicht gefunden: {analysis_path}")
|
||||
|
||||
try:
|
||||
data = json.loads(analysis_path.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError as e:
|
||||
raise RuntimeError(f"Analyse-Datei enthaelt kein gueltiges JSON: {analysis_path}\n{e}") from e
|
||||
|
||||
title = data.get("title") or "Arbeitsblatt"
|
||||
subject = data.get("subject") or ""
|
||||
canonical_text = data.get("canonical_text") or ""
|
||||
tasks = data.get("tasks", []) or []
|
||||
|
||||
# Collect all text for analysis
|
||||
all_text = canonical_text
|
||||
for task in tasks:
|
||||
if task.get("description"):
|
||||
all_text += "\n" + task.get("description")
|
||||
if task.get("text_with_gaps"):
|
||||
all_text += "\n" + task.get("text_with_gaps")
|
||||
|
||||
if not all_text.strip():
|
||||
return {
|
||||
"topic": title,
|
||||
"subject": subject,
|
||||
"categories": []
|
||||
}
|
||||
|
||||
# AI-based extraction of technical terms
|
||||
api_key = get_openai_api_key()
|
||||
|
||||
prompt = f"""Analysiere diesen Schultext und extrahiere alle Fachbegriffe fuer eine kindgerechte Lern-Mindmap.
|
||||
|
||||
THEMA: {title}
|
||||
FACH: {subject}
|
||||
|
||||
TEXT:
|
||||
{all_text[:3000]}
|
||||
|
||||
AUFGABE:
|
||||
1. Identifiziere das Hauptthema (ein einzelnes Wort oder kurzer Begriff)
|
||||
2. Finde ALLE Fachbegriffe und gruppiere sie in 3-6 sinnvolle Kategorien
|
||||
3. Gib fuer jeden Begriff eine kurze, kindgerechte Erklaerung (max 10 Woerter)
|
||||
4. Waehle fuer jede Kategorie ein passendes Emoji und eine Farbe
|
||||
|
||||
Antworte NUR mit diesem JSON-Format:
|
||||
{{
|
||||
"topic": "Hauptthema (z.B. 'Das Auge')",
|
||||
"categories": [
|
||||
{{
|
||||
"name": "Kategoriename",
|
||||
"emoji": "passendes Emoji",
|
||||
"color": "#Hexfarbe (bunt, kindgerecht)",
|
||||
"terms": [
|
||||
{{"term": "Fachbegriff", "explanation": "Kurze Erklaerung"}}
|
||||
]
|
||||
}}
|
||||
]
|
||||
}}
|
||||
|
||||
WICHTIG:
|
||||
- Verwende kindgerechte, einfache Sprache
|
||||
- Bunte, froehliche Farben: #FF6B6B, #4ECDC4, #45B7D1, #96CEB4, #FFEAA7, #DDA0DD, #98D8C8
|
||||
- Passende Emojis fuer jede Kategorie
|
||||
- Mindestens 3 Begriffe pro Kategorie wenn moeglich
|
||||
- Maximal 6 Kategorien"""
|
||||
|
||||
try:
|
||||
# Try Claude first
|
||||
claude_key = os.environ.get("ANTHROPIC_API_KEY")
|
||||
if claude_key:
|
||||
import anthropic
|
||||
client = anthropic.Anthropic(api_key=claude_key)
|
||||
response = client.messages.create(
|
||||
model="claude-3-5-sonnet-20241022",
|
||||
max_tokens=2000,
|
||||
messages=[{"role": "user", "content": prompt}]
|
||||
)
|
||||
result_text = response.content[0].text
|
||||
else:
|
||||
# Fallback to OpenAI
|
||||
logger.info("Claude Mindmap-Generierung fehlgeschlagen, nutze OpenAI: ANTHROPIC_API_KEY ist nicht gesetzt.")
|
||||
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 Experte fuer kindgerechte Lernmaterialien."},
|
||||
{"role": "user", "content": prompt}
|
||||
],
|
||||
"max_tokens": 2000,
|
||||
"temperature": 0.7
|
||||
}
|
||||
resp = requests.post(url, headers=headers, json=payload, timeout=60)
|
||||
resp.raise_for_status()
|
||||
result_text = resp.json()["choices"][0]["message"]["content"]
|
||||
|
||||
# Extract JSON
|
||||
result_text = result_text.strip()
|
||||
if result_text.startswith("```"):
|
||||
result_text = result_text.split("```")[1]
|
||||
if result_text.startswith("json"):
|
||||
result_text = result_text[4:]
|
||||
result_text = result_text.strip()
|
||||
|
||||
mindmap_data = json.loads(result_text)
|
||||
mindmap_data["subject"] = subject
|
||||
|
||||
return mindmap_data
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Mindmap-Generierung fehlgeschlagen: {e}")
|
||||
return {
|
||||
"topic": title,
|
||||
"subject": subject,
|
||||
"categories": []
|
||||
}
|
||||
|
||||
|
||||
def generate_mindmap_html(mindmap_data: dict, format: str = "a3") -> str:
|
||||
"""
|
||||
Generate a child-friendly HTML/SVG mindmap poster.
|
||||
|
||||
Args:
|
||||
mindmap_data: Dictionary from generate_mindmap_data()
|
||||
format: "a3" for A3 poster (default) or "a4" for A4 view
|
||||
|
||||
Returns:
|
||||
HTML string with SVG mindmap
|
||||
"""
|
||||
topic = mindmap_data.get("topic", "Thema")
|
||||
subject = mindmap_data.get("subject", "")
|
||||
categories = mindmap_data.get("categories", [])
|
||||
|
||||
# Format-specific settings
|
||||
if format.lower() == "a4":
|
||||
page_size = "A4 landscape"
|
||||
svg_width = 1100
|
||||
svg_height = 780
|
||||
radius = 250
|
||||
else: # a3 (default)
|
||||
page_size = "A3 landscape"
|
||||
svg_width = 1400
|
||||
svg_height = 990
|
||||
radius = 320
|
||||
|
||||
# If no categories, show placeholder
|
||||
if not categories:
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Mindmap - {topic}</title>
|
||||
<style>
|
||||
body {{ font-family: 'Comic Sans MS', cursive, sans-serif; text-align: center; padding: 50px; }}
|
||||
h1 {{ color: #FF6B6B; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>🧠 Mindmap: {topic}</h1>
|
||||
<p>Noch keine Daten vorhanden. Bitte zuerst das Arbeitsblatt analysieren.</p>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
num_categories = len(categories)
|
||||
center_x = svg_width // 2
|
||||
center_y = svg_height // 2
|
||||
|
||||
# Calculate positions of categories in a circle
|
||||
category_positions = []
|
||||
|
||||
for i, cat in enumerate(categories):
|
||||
angle = (2 * math.pi * i / num_categories) - (math.pi / 2) # Start at top
|
||||
x = center_x + radius * math.cos(angle)
|
||||
y = center_y + radius * math.sin(angle)
|
||||
category_positions.append({
|
||||
"x": x,
|
||||
"y": y,
|
||||
"angle": angle,
|
||||
"data": cat
|
||||
})
|
||||
|
||||
html = _get_mindmap_html_header(topic, subject, page_size, svg_width, svg_height)
|
||||
|
||||
# Draw connection lines
|
||||
for pos in category_positions:
|
||||
color = pos["data"].get("color", "#4ECDC4")
|
||||
html += f""" <path d="M {center_x} {center_y} Q {(center_x + pos['x'])/2 + 30} {(center_y + pos['y'])/2 - 30} {pos['x']} {pos['y']}"
|
||||
stroke="{color}" stroke-width="4" fill="none" stroke-linecap="round" opacity="0.6"/>
|
||||
"""
|
||||
|
||||
# Center (main topic)
|
||||
html += f"""
|
||||
<!-- Center: Main Topic -->
|
||||
<g filter="url(#glow)">
|
||||
<circle cx="{center_x}" cy="{center_y}" r="85" fill="url(#centerGradient)"/>
|
||||
<defs>
|
||||
<radialGradient id="centerGradient" cx="30%" cy="30%">
|
||||
<stop offset="0%" stop-color="#FFD93D"/>
|
||||
<stop offset="100%" stop-color="#FF6B6B"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<text x="{center_x}" y="{center_y - 10}" text-anchor="middle" font-size="28" font-weight="bold" fill="white">🌟</text>
|
||||
<text x="{center_x}" y="{center_y + 25}" text-anchor="middle" font-size="22" font-weight="bold" fill="white">{topic}</text>
|
||||
</g>
|
||||
"""
|
||||
|
||||
# Draw categories with their terms
|
||||
for i, pos in enumerate(category_positions):
|
||||
cat = pos["data"]
|
||||
cat_x = pos["x"]
|
||||
cat_y = pos["y"]
|
||||
color = cat.get("color", "#4ECDC4")
|
||||
emoji = cat.get("emoji", "📚")
|
||||
name = cat.get("name", "Kategorie")
|
||||
terms = cat.get("terms", [])
|
||||
|
||||
# Category bubble
|
||||
html += f"""
|
||||
<!-- Category: {name} -->
|
||||
<g class="category-group" transform="translate({cat_x}, {cat_y})">
|
||||
<ellipse cx="0" cy="0" rx="75" ry="45" fill="{color}" filter="url(#shadow)"/>
|
||||
<text x="0" y="-8" text-anchor="middle" font-size="20">{emoji}</text>
|
||||
<text x="0" y="18" text-anchor="middle" font-size="14" font-weight="bold" fill="white">{name}</text>
|
||||
"""
|
||||
|
||||
# Terms around the category
|
||||
term_radius = 110
|
||||
num_terms = len(terms)
|
||||
for j, term_data in enumerate(terms[:8]): # Max 8 terms per category
|
||||
term = term_data.get("term", "")
|
||||
|
||||
# Calculate position relative to category
|
||||
base_angle = pos["angle"]
|
||||
spread = math.pi * 0.8 # 80% of a half circle
|
||||
if num_terms > 1:
|
||||
term_angle = base_angle - spread/2 + (spread * j / (num_terms - 1))
|
||||
else:
|
||||
term_angle = base_angle
|
||||
|
||||
term_x = term_radius * math.cos(term_angle - base_angle)
|
||||
term_y = term_radius * math.sin(term_angle - base_angle)
|
||||
|
||||
# Small connection line
|
||||
html += f""" <line x1="0" y1="0" x2="{term_x * 0.6}" y2="{term_y * 0.6}" stroke="{color}" stroke-width="2" opacity="0.5"/>
|
||||
"""
|
||||
|
||||
# Term bubble
|
||||
bubble_width = max(70, len(term) * 8 + 20)
|
||||
html += f""" <g class="term-bubble" transform="translate({term_x}, {term_y})">
|
||||
<rect x="{-bubble_width/2}" y="-22" width="{bubble_width}" height="44" rx="22" fill="white" stroke="{color}" stroke-width="2" filter="url(#shadow)"/>
|
||||
<text x="0" y="5" text-anchor="middle" font-size="12" font-weight="bold" fill="#333">{term}</text>
|
||||
</g>
|
||||
"""
|
||||
|
||||
html += " </g>\n"
|
||||
|
||||
# Legend with explanations (bottom)
|
||||
html += f"""
|
||||
<!-- Legend -->
|
||||
<g transform="translate(50, {svg_height - 80})">
|
||||
<text x="0" y="0" font-size="14" font-weight="bold" fill="#666">📖 Begriffe zum Lernen:</text>
|
||||
"""
|
||||
legend_x = 0
|
||||
for i, pos in enumerate(category_positions):
|
||||
cat = pos["data"]
|
||||
color = cat.get("color", "#4ECDC4")
|
||||
emoji = cat.get("emoji", "📚")
|
||||
name = cat.get("name", "")
|
||||
terms = cat.get("terms", [])
|
||||
|
||||
terms_text = ", ".join([t.get("term", "") for t in terms[:3]])
|
||||
if len(terms) > 3:
|
||||
terms_text += "..."
|
||||
|
||||
html += f""" <g transform="translate({legend_x}, 25)">
|
||||
<circle cx="8" cy="0" r="8" fill="{color}"/>
|
||||
<text x="22" y="4" font-size="11" fill="#444"><tspan font-weight="bold">{emoji} {name}:</tspan> {terms_text}</text>
|
||||
</g>
|
||||
"""
|
||||
legend_x += 220
|
||||
|
||||
html += """ </g>
|
||||
</svg>
|
||||
</div>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
return html
|
||||
|
||||
|
||||
def save_mindmap_for_worksheet(analysis_path: Path, mindmap_data: dict = None) -> Path:
|
||||
"""
|
||||
Save a mindmap for a worksheet.
|
||||
|
||||
Args:
|
||||
analysis_path: Path to *_analyse.json file
|
||||
mindmap_data: Optional - already generated mindmap data.
|
||||
If not provided, it will be generated.
|
||||
|
||||
Returns:
|
||||
Path to saved *_mindmap.json file
|
||||
"""
|
||||
if mindmap_data is None:
|
||||
mindmap_data = generate_mindmap_data(analysis_path)
|
||||
|
||||
# Save JSON
|
||||
out_name = analysis_path.stem.replace("_analyse", "") + "_mindmap.json"
|
||||
out_path = BEREINIGT_DIR / out_name
|
||||
out_path.write_text(json.dumps(mindmap_data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
logger.info(f"Mindmap-Daten gespeichert: {out_path.name}")
|
||||
return out_path
|
||||
|
||||
|
||||
def _get_mindmap_html_header(topic: str, subject: str, page_size: str, svg_width: int, svg_height: int) -> str:
|
||||
"""Get HTML header for mindmap."""
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Lernposter - {topic}</title>
|
||||
<style>
|
||||
@page {{
|
||||
size: {page_size};
|
||||
margin: 10mm;
|
||||
}}
|
||||
@media print {{
|
||||
body {{ -webkit-print-color-adjust: exact; print-color-adjust: exact; }}
|
||||
.no-print {{ display: none !important; }}
|
||||
}}
|
||||
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
|
||||
body {{
|
||||
font-family: 'Comic Sans MS', 'Chalkboard SE', 'Comic Neue', cursive, sans-serif;
|
||||
background: linear-gradient(135deg, #f5f7fa 0%, #e4e8f0 100%);
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}}
|
||||
.poster-container {{
|
||||
width: 100%;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 10px 40px rgba(0,0,0,0.1);
|
||||
overflow: hidden;
|
||||
}}
|
||||
.poster-header {{
|
||||
background: linear-gradient(90deg, #FF6B6B, #4ECDC4);
|
||||
padding: 15px 30px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}}
|
||||
.poster-title {{
|
||||
color: white;
|
||||
font-size: 24px;
|
||||
text-shadow: 2px 2px 4px rgba(0,0,0,0.2);
|
||||
}}
|
||||
.poster-subject {{
|
||||
color: white;
|
||||
font-size: 16px;
|
||||
opacity: 0.9;
|
||||
}}
|
||||
.mindmap-svg {{
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}}
|
||||
.print-btn {{
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
padding: 12px 24px;
|
||||
background: #4ECDC4;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 25px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 15px rgba(78, 205, 196, 0.4);
|
||||
font-family: inherit;
|
||||
}}
|
||||
.print-btn:hover {{
|
||||
transform: scale(1.05);
|
||||
background: #45B7D1;
|
||||
}}
|
||||
.category-group:hover {{
|
||||
transform: scale(1.02);
|
||||
cursor: pointer;
|
||||
}}
|
||||
.term-bubble:hover {{
|
||||
transform: scale(1.1);
|
||||
filter: brightness(1.1);
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<button class="print-btn no-print" onclick="window.print()">🖨️ Als A3 drucken</button>
|
||||
|
||||
<div class="poster-container">
|
||||
<div class="poster-header">
|
||||
<div class="poster-title">🧠 Lernposter: {topic}</div>
|
||||
<div class="poster-subject">{subject}</div>
|
||||
</div>
|
||||
|
||||
<svg class="mindmap-svg" viewBox="0 0 {svg_width} {svg_height}" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<!-- Shadow for bubbles -->
|
||||
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feDropShadow dx="2" dy="4" stdDeviation="4" flood-opacity="0.2"/>
|
||||
</filter>
|
||||
<!-- Glow effect for center -->
|
||||
<filter id="glow">
|
||||
<feGaussianBlur stdDeviation="8" result="coloredBlur"/>
|
||||
<feMerge>
|
||||
<feMergeNode in="coloredBlur"/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- Background pattern (subtle dots) -->
|
||||
<pattern id="dots" x="0" y="0" width="30" height="30" patternUnits="userSpaceOnUse">
|
||||
<circle cx="15" cy="15" r="1.5" fill="#e0e0e0"/>
|
||||
</pattern>
|
||||
<rect width="100%" height="100%" fill="url(#dots)"/>
|
||||
|
||||
<!-- Connection lines from center to categories -->
|
||||
"""
|
||||
Reference in New Issue
Block a user