This repository has been archived on 2026-02-15. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
BreakPilot Dev 19855efacc
Some checks failed
Tests / Go Tests (push) Has been cancelled
Tests / Python Tests (push) Has been cancelled
Tests / Integration Tests (push) Has been cancelled
Tests / Go Lint (push) Has been cancelled
Tests / Python Lint (push) Has been cancelled
Tests / Security Scan (push) Has been cancelled
Tests / All Checks Passed (push) Has been cancelled
Security Scanning / Secret Scanning (push) Has been cancelled
Security Scanning / Dependency Vulnerability Scan (push) Has been cancelled
Security Scanning / Go Security Scan (push) Has been cancelled
Security Scanning / Python Security Scan (push) Has been cancelled
Security Scanning / Node.js Security Scan (push) Has been cancelled
Security Scanning / Docker Image Security (push) Has been cancelled
Security Scanning / Security Summary (push) Has been cancelled
CI/CD Pipeline / Go Tests (push) Has been cancelled
CI/CD Pipeline / Python Tests (push) Has been cancelled
CI/CD Pipeline / Website Tests (push) Has been cancelled
CI/CD Pipeline / Linting (push) Has been cancelled
CI/CD Pipeline / Security Scan (push) Has been cancelled
CI/CD Pipeline / Docker Build & Push (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Deploy to Staging (push) Has been cancelled
CI/CD Pipeline / Deploy to Production (push) Has been cancelled
CI/CD Pipeline / CI Summary (push) Has been cancelled
ci/woodpecker/manual/build-ci-image Pipeline was successful
ci/woodpecker/manual/main Pipeline failed
feat: BreakPilot PWA - Full codebase (clean push without large binaries)
All services: admin-v2, studio-v2, website, ai-compliance-sdk,
consent-service, klausur-service, voice-service, and infrastructure.
Large PDFs and compiled binaries excluded via .gitignore.
2026-02-11 13:25:58 +01:00

472 lines
15 KiB
Python

"""
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 -->
"""