Services: Admin-Lehrer, Backend-Lehrer, Studio v2, Website, Klausur-Service, School-Service, Voice-Service, Geo-Service, BreakPilot Drive, Agent-Core Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
473 lines
15 KiB
Python
473 lines
15 KiB
Python
"""
|
|
AI Processing - Mindmap Generator.
|
|
|
|
Generiert kindgerechte Lernposter-Mindmaps aus Arbeitsblatt-Analysen.
|
|
"""
|
|
|
|
from pathlib import Path
|
|
import math
|
|
import json
|
|
import os
|
|
import requests
|
|
import logging
|
|
|
|
from .core import get_openai_api_key, BEREINIGT_DIR
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def generate_mindmap_data(analysis_path: Path) -> dict:
|
|
"""
|
|
Extrahiert Fachbegriffe aus der Analyse und gruppiert sie für eine Mindmap.
|
|
|
|
Args:
|
|
analysis_path: Pfad zur *_analyse.json Datei
|
|
|
|
Returns:
|
|
Dictionary mit Mindmap-Struktur:
|
|
{
|
|
"topic": "Hauptthema",
|
|
"subject": "Fach",
|
|
"categories": [
|
|
{
|
|
"name": "Kategorie",
|
|
"color": "#hexcolor",
|
|
"emoji": "🔬",
|
|
"terms": [
|
|
{"term": "Begriff", "explanation": "Kurze Erklärung"}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
"""
|
|
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 enthält kein gültiges 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 []
|
|
|
|
# Sammle allen Text für die Analyse
|
|
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": []
|
|
}
|
|
|
|
# KI-basierte Extraktion der Fachbegriffe
|
|
api_key = get_openai_api_key()
|
|
|
|
prompt = f"""Analysiere diesen Schultext und extrahiere alle Fachbegriffe für 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 für jeden Begriff eine kurze, kindgerechte Erklärung (max 10 Wörter)
|
|
4. Wähle für 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 Erklärung"}}
|
|
]
|
|
}}
|
|
]
|
|
}}
|
|
|
|
WICHTIG:
|
|
- Verwende kindgerechte, einfache Sprache
|
|
- Bunte, fröhliche Farben: #FF6B6B, #4ECDC4, #45B7D1, #96CEB4, #FFEAA7, #DDA0DD, #98D8C8
|
|
- Passende Emojis für jede Kategorie
|
|
- Mindestens 3 Begriffe pro Kategorie wenn möglich
|
|
- Maximal 6 Kategorien"""
|
|
|
|
try:
|
|
# Versuche Claude
|
|
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 zu 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 für 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"]
|
|
|
|
# JSON extrahieren
|
|
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}")
|
|
# Fallback: Einfache Struktur zurückgeben
|
|
return {
|
|
"topic": title,
|
|
"subject": subject,
|
|
"categories": []
|
|
}
|
|
|
|
|
|
def generate_mindmap_html(mindmap_data: dict, format: str = "a3") -> str:
|
|
"""
|
|
Generiert ein kindgerechtes HTML/SVG Mindmap-Poster.
|
|
|
|
Args:
|
|
mindmap_data: Dictionary aus generate_mindmap_data()
|
|
format: "a3" für A3-Poster (Standard) oder "a4" für A4-Ansicht
|
|
|
|
Returns:
|
|
HTML-String mit SVG-Mindmap
|
|
"""
|
|
topic = mindmap_data.get("topic", "Thema")
|
|
subject = mindmap_data.get("subject", "")
|
|
categories = mindmap_data.get("categories", [])
|
|
|
|
# Format-spezifische Einstellungen
|
|
if format.lower() == "a4":
|
|
page_size = "A4 landscape"
|
|
svg_width = 1100
|
|
svg_height = 780
|
|
radius = 250
|
|
else: # a3 (Standard)
|
|
page_size = "A3 landscape"
|
|
svg_width = 1400
|
|
svg_height = 990
|
|
radius = 320
|
|
|
|
# Wenn keine Kategorien, zeige Platzhalter
|
|
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>"""
|
|
|
|
# Farben für Verbindungslinien
|
|
num_categories = len(categories)
|
|
|
|
# SVG-Dimensionen wurden oben basierend auf format gesetzt
|
|
center_x = svg_width // 2
|
|
center_y = svg_height // 2
|
|
|
|
# Berechne Positionen der Kategorien im Kreis
|
|
category_positions = []
|
|
|
|
for i, cat in enumerate(categories):
|
|
angle = (2 * math.pi * i / num_categories) - (math.pi / 2) # Start oben
|
|
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 = 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;
|
|
}}
|
|
/* Animationen für interaktive Version */
|
|
.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>
|
|
<!-- Schatten für 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-Effekt für Zentrum -->
|
|
<filter id="glow">
|
|
<feGaussianBlur stdDeviation="8" result="coloredBlur"/>
|
|
<feMerge>
|
|
<feMergeNode in="coloredBlur"/>
|
|
<feMergeNode in="SourceGraphic"/>
|
|
</feMerge>
|
|
</filter>
|
|
</defs>
|
|
|
|
<!-- Hintergrund-Muster (dezente Punkte) -->
|
|
<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)"/>
|
|
|
|
<!-- Verbindungslinien vom Zentrum zu Kategorien -->
|
|
"""
|
|
|
|
# Zeichne Verbindungslinien
|
|
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"/>
|
|
"""
|
|
|
|
# Zentrum (Hauptthema)
|
|
html += f"""
|
|
<!-- Zentrum: Hauptthema -->
|
|
<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>
|
|
"""
|
|
|
|
# Zeichne Kategorien mit ihren Begriffen
|
|
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", [])
|
|
|
|
# Kategorie-Bubble
|
|
html += f"""
|
|
<!-- Kategorie: {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>
|
|
"""
|
|
|
|
# Begriffe um die Kategorie herum
|
|
term_radius = 110
|
|
num_terms = len(terms)
|
|
for j, term_data in enumerate(terms[:8]): # Max 8 Begriffe pro Kategorie
|
|
term = term_data.get("term", "")
|
|
|
|
# Berechne Position relativ zur Kategorie
|
|
# Verteile Begriffe in einem Halbkreis auf der Außenseite
|
|
base_angle = pos["angle"]
|
|
spread = math.pi * 0.8 # 80% eines Halbkreises
|
|
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)
|
|
|
|
# Kleine Verbindungslinie
|
|
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"/>
|
|
"""
|
|
|
|
# Begriff-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"
|
|
|
|
# Legende mit Erklärungen (unten)
|
|
html += f"""
|
|
<!-- Legende -->
|
|
<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", [])
|
|
|
|
# Zeige Kategorie mit ersten 3 Begriffen
|
|
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:
|
|
"""
|
|
Speichert eine Mindmap für ein Arbeitsblatt.
|
|
|
|
Args:
|
|
analysis_path: Pfad zur *_analyse.json Datei
|
|
mindmap_data: Optional - bereits generierte Mindmap-Daten. Falls nicht angegeben, werden sie generiert.
|
|
|
|
Returns:
|
|
Pfad zur gespeicherten *_mindmap.json Datei
|
|
"""
|
|
if mindmap_data is None:
|
|
mindmap_data = generate_mindmap_data(analysis_path)
|
|
|
|
# Speichere 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
|