Restructure: Move final 16 root files into packages (backend-lehrer)
Some checks failed
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-school (push) Successful in 37s
CI / test-go-edu-search (push) Successful in 35s
CI / test-python-klausur (push) Failing after 2m41s
CI / test-python-agent-core (push) Successful in 30s
CI / test-nodejs-website (push) Successful in 38s

classroom/ (+2): state_engine_api, state_engine_models
vocabulary/ (2): api, db
worksheets/ (2): api, models
services/  (+6): audio, email, translation, claude_vision, ai_processor, story_generator
api/        (4): school, klausur_proxy, progress, user_language

Only main.py + config.py remain at root. 16 shims added.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-04-25 22:50:37 +02:00
parent 6be555fb7c
commit cba877c65a
36 changed files with 3712 additions and 3564 deletions

View File

@@ -19,4 +19,12 @@ except (ImportError, OSError) as e:
FileProcessor = None # type: ignore
_file_processor_available = False
# Lazy-loaded service modules (imported on demand to avoid heavy deps at startup):
# .audio — TTS audio generation for vocabulary words
# .email — Email/SMTP service
# .translation — Batch vocabulary translation via Ollama
# .claude_vision — Claude Vision API for worksheet analysis
# .ai_processor — Legacy shim for ai_processor/ package
# .story_generator — Story generation from vocabulary words
__all__ = ["PDFService", "FileProcessor"]

View File

@@ -0,0 +1,81 @@
"""
AI Processor - Legacy Import Wrapper
This file provides backward compatibility for code that imports from ai_processor.
All functionality has been moved to the ai_processor/ module.
Usage (new):
from ai_processor import analyze_scan_structure_with_ai
Usage (legacy, still works):
from ai_processor import analyze_scan_structure_with_ai
"""
# Re-export everything from the new modular structure
from ai_processor import (
# Configuration
BASE_DIR,
EINGANG_DIR,
BEREINIGT_DIR,
VISION_API,
# Utilities (with legacy aliases)
encode_image_to_data_url as _encode_image_to_data_url,
dummy_process_scan,
# Vision - Scan Analysis
analyze_scan_structure_with_ai,
describe_scan_with_ai,
remove_handwriting_from_scan,
build_clean_html_from_analysis,
# Generators - Multiple Choice
generate_mc_from_analysis,
# Generators - Cloze
generate_cloze_from_analysis,
# Generators - Q&A with Leitner
generate_qa_from_analysis,
update_leitner_progress,
get_next_review_items,
# Export - Print Versions
generate_print_version_qa,
generate_print_version_cloze,
generate_print_version_mc,
generate_print_version_worksheet,
# Visualization - Mindmap
generate_mindmap_data,
generate_mindmap_html,
save_mindmap_for_worksheet,
)
# Legacy function alias
from ai_processor import get_openai_api_key as _get_api_key
__all__ = [
# Configuration
"BASE_DIR",
"EINGANG_DIR",
"BEREINIGT_DIR",
"VISION_API",
# Legacy private functions
"_get_api_key",
"_encode_image_to_data_url",
# Vision
"analyze_scan_structure_with_ai",
"describe_scan_with_ai",
"remove_handwriting_from_scan",
"build_clean_html_from_analysis",
"dummy_process_scan",
# Generators
"generate_mc_from_analysis",
"generate_cloze_from_analysis",
"generate_qa_from_analysis",
"update_leitner_progress",
"get_next_review_items",
# Export
"generate_print_version_qa",
"generate_print_version_cloze",
"generate_print_version_mc",
"generate_print_version_worksheet",
# Visualization
"generate_mindmap_data",
"generate_mindmap_html",
"save_mindmap_for_worksheet",
]

View File

@@ -0,0 +1,125 @@
"""
Audio Service — Generates TTS audio for vocabulary words.
Uses the Piper TTS service (compliance-tts-service, MIT license)
for high-quality German (Thorsten) and English (Lessac) voices.
Falls back to a placeholder response if TTS service is unavailable.
Audio files are cached — generated once, served forever.
"""
import hashlib
import logging
import os
from typing import Optional
import httpx
logger = logging.getLogger(__name__)
# Piper TTS service (runs in compliance stack)
TTS_SERVICE_URL = os.getenv("TTS_SERVICE_URL", "http://bp-compliance-tts:8095")
# Local cache directory for generated audio
AUDIO_CACHE_DIR = os.path.expanduser("~/Arbeitsblaetter/audio-cache")
def _ensure_cache_dir():
os.makedirs(AUDIO_CACHE_DIR, exist_ok=True)
def _cache_key(text: str, language: str) -> str:
"""Generate a deterministic cache key for text + language."""
h = hashlib.sha256(f"{language}:{text}".encode()).hexdigest()[:16]
return f"{language}_{h}"
def _cache_path(text: str, language: str) -> str:
"""Full path to cached MP3 file."""
_ensure_cache_dir()
return os.path.join(AUDIO_CACHE_DIR, f"{_cache_key(text, language)}.mp3")
async def synthesize_word(
text: str,
language: str = "de",
word_id: str = "",
) -> Optional[str]:
"""
Generate TTS audio for a word or short phrase.
Returns the file path to the cached MP3, or None on error.
Uses Piper TTS service (compliance-tts-service).
"""
# Check cache first
cached = _cache_path(text, language)
if os.path.exists(cached):
return cached
# Call Piper TTS service
try:
async with httpx.AsyncClient(timeout=30.0) as client:
resp = await client.post(
f"{TTS_SERVICE_URL}/synthesize",
json={
"text": text,
"language": language,
"voice": "thorsten-high" if language == "de" else "lessac-high",
"module_id": "vocabulary",
"content_id": word_id or _cache_key(text, language),
},
)
if resp.status_code != 200:
logger.warning(f"TTS service returned {resp.status_code} for '{text}'")
return None
data = resp.json()
audio_url = data.get("audio_url") or data.get("presigned_url")
if audio_url:
# Download the audio file
audio_resp = await client.get(audio_url)
if audio_resp.status_code == 200:
with open(cached, "wb") as f:
f.write(audio_resp.content)
logger.info(f"TTS cached: '{text}' ({language}) → {cached}")
return cached
except Exception as e:
logger.warning(f"TTS service unavailable: {e}")
# Fallback: try direct MP3 endpoint
try:
async with httpx.AsyncClient(timeout=30.0) as client:
resp = await client.post(
f"{TTS_SERVICE_URL}/synthesize/mp3",
json={
"text": text,
"language": language,
"voice": "thorsten-high" if language == "de" else "lessac-high",
"module_id": "vocabulary",
},
)
if resp.status_code == 200 and resp.headers.get("content-type", "").startswith("audio"):
with open(cached, "wb") as f:
f.write(resp.content)
logger.info(f"TTS cached (direct): '{text}' ({language}) → {cached}")
return cached
except Exception as e:
logger.debug(f"TTS direct fallback also failed: {e}")
return None
async def get_or_generate_audio(
text: str, language: str = "de", word_id: str = "",
) -> Optional[bytes]:
"""
Get audio bytes for a word. Returns MP3 bytes or None.
Generates via TTS if not cached.
"""
path = await synthesize_word(text, language, word_id)
if path and os.path.exists(path):
with open(path, "rb") as f:
return f.read()
return None

View File

@@ -0,0 +1,299 @@
"""
Claude Vision API Integration for Worksheet Analysis
Uses Anthropic's Claude 3.5 Sonnet for superior OCR and layout understanding.
"""
import os
import base64
import json
from pathlib import Path
from typing import Dict, Optional
import logging
logger = logging.getLogger(__name__)
# Try to import Anthropic SDK
try:
from anthropic import Anthropic
ANTHROPIC_AVAILABLE = True
except ImportError:
ANTHROPIC_AVAILABLE = False
logger.warning("Anthropic SDK not installed. Run: pip install anthropic")
def _get_anthropic_api_key() -> str:
"""Get Anthropic API key from environment variable"""
api_key = os.getenv("ANTHROPIC_API_KEY")
if not api_key:
raise RuntimeError(
"ANTHROPIC_API_KEY ist nicht gesetzt. "
"Bitte API-Schlüssel als Umgebungsvariable setzen:\n"
"export ANTHROPIC_API_KEY='sk-ant-api03-...'"
)
return api_key
def _encode_image_to_base64(image_path: Path) -> tuple[str, str]:
"""
Encode image to base64 for Claude API.
Returns:
(base64_string, media_type)
"""
image_bytes = image_path.read_bytes()
image_b64 = base64.standard_b64encode(image_bytes).decode("utf-8")
# Determine media type from extension
ext = image_path.suffix.lower()
media_type_map = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.gif': 'image/gif',
'.webp': 'image/webp'
}
media_type = media_type_map.get(ext, 'image/jpeg')
return image_b64, media_type
def analyze_worksheet_with_claude(
image_path: Path,
max_tokens: int = 2500,
model: str = "claude-3-5-sonnet-20241022"
) -> Dict:
"""
Analyze worksheet using Claude Vision API.
Args:
image_path: Path to worksheet image
max_tokens: Maximum tokens in response (default 2500)
model: Claude model to use (default: Claude 3.5 Sonnet)
Returns:
Analysis dict with same structure as OpenAI version
Raises:
RuntimeError: If API key not set or SDK not installed
Exception: If API call fails
"""
if not ANTHROPIC_AVAILABLE:
raise RuntimeError("Anthropic SDK nicht installiert. Run: pip install anthropic")
if not image_path.exists():
raise FileNotFoundError(f"Image not found: {image_path}")
# Get API key
api_key = _get_anthropic_api_key()
# Initialize Anthropic client
client = Anthropic(api_key=api_key)
# Encode image
image_b64, media_type = _encode_image_to_base64(image_path)
# System prompt (instructions)
system_prompt = """Du bist ein Experte für die Analyse von Schul-Arbeitsblättern.
Deine Aufgabe ist es, das Arbeitsblatt detailliert zu analysieren und strukturierte Informationen zu extrahieren:
1. **Gedruckter Text**: Erkenne den VOLLSTÄNDIGEN gedruckten Text inklusive durchgestrichener Wörter
2. **Handschrift**: Identifiziere alle handschriftlichen Eintragungen (Schülerantworten, Korrekturen, Notizen)
3. **Layout**: Bestimme räumliche Positionen aller Elemente (Bounding Boxes in Pixeln)
4. **Diagramme**: Erkenne gedruckte Illustrationen, Grafiken, Diagramme
5. **Farben**: Klassifiziere Handschrift nach Farbe (blau/schwarz/rot/Bleistift)
WICHTIG: Gib deine Antwort als gültiges JSON zurück, nicht als Markdown Code Block!"""
# User prompt with JSON schema
user_prompt = """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 | null,
"printed_blocks": [
{
"id": string,
"role": "title" | "instructions" | "body" | "other",
"text": string
}
],
"layout": {
"page_structure": {
"has_diagram": boolean,
"orientation": "portrait" | "landscape"
},
"text_regions": [
{
"id": string,
"type": "title" | "paragraph" | "list" | "instruction",
"text": string,
"bounding_box": {"x": int, "y": int, "width": int, "height": int},
"font_characteristics": {
"is_bold": boolean,
"approximate_size": "large" | "medium" | "small"
}
}
],
"diagram_elements": [
{
"id": string,
"type": "illustration" | "chart" | "graph" | "shape",
"description": string,
"bounding_box": {"x": int, "y": int, "width": int, "height": int},
"preserve": boolean
}
]
},
"handwriting_regions": [
{
"id": string,
"text": string,
"type": "student_answer" | "correction" | "note" | "drawing",
"bounding_box": {"x": int, "y": int, "width": int, "height": int},
"color_hint": "blue" | "black" | "red" | "pencil" | "unknown"
}
],
"handwritten_annotations": [
{
"text": string,
"approx_location": string
}
],
"struck_through_words": [
{
"text": string,
"context": string
}
],
"tasks": [
{
"id": string,
"type": "cloze" | "mcq" | "short_answer" | "math" | "other",
"description": string,
"text_with_gaps": string | null,
"gaps": [
{
"id": string,
"solution": string,
"position_hint": string
}
]
}
]
}
WICHTIGE HINWEISE:
- "canonical_text" enthält den KORRIGIERTEN gedruckten Text OHNE Handschrift und OHNE durchgestrichene Wörter
- "struck_through_words" enthält alle durchgestrichenen Wörter mit Kontext
- Bounding Boxes sind ungefähre Pixel-Positionen (x, y von oben links, width/height in Pixeln)
- "layout.text_regions" sollte alle gedruckten Textbereiche mit genauen Positionen enthalten
- "handwriting_regions" sollte alle handschriftlichen Bereiche mit Farb-Hinweisen enthalten
- Setze "preserve": true für Diagramm-Elemente die erhalten bleiben sollen
- Durchgestrichene Wörter NUR in "struck_through_words", NICHT in "canonical_text"
Gib NUR das JSON zurück, ohne Code-Block-Marker!"""
try:
logger.info(f"Calling Claude API for analysis of {image_path.name}")
# Call Claude API
response = client.messages.create(
model=model,
max_tokens=max_tokens,
system=system_prompt,
messages=[
{
"role": "user",
"content": [
{
"type": "image",
"source": {
"type": "base64",
"media_type": media_type,
"data": image_b64,
},
},
{
"type": "text",
"text": user_prompt
}
],
}
],
)
# Extract text from response
if not response.content:
raise RuntimeError("Empty response from Claude API")
# Get first text block
text_content = None
for block in response.content:
if block.type == "text":
text_content = block.text
break
if not text_content:
raise RuntimeError("No text content in Claude response")
logger.info(f"Received response from Claude ({len(text_content)} chars)")
# Parse JSON
# Claude might wrap JSON in ```json ... ```, remove if present
text_content = text_content.strip()
if text_content.startswith("```json"):
text_content = text_content[7:]
if text_content.startswith("```"):
text_content = text_content[3:]
if text_content.endswith("```"):
text_content = text_content[:-3]
text_content = text_content.strip()
try:
analysis_data = json.loads(text_content)
except json.JSONDecodeError as e:
logger.error(f"Failed to parse Claude JSON response: {e}")
logger.error(f"Response text: {text_content[:500]}...")
raise RuntimeError(f"Invalid JSON from Claude: {e}\nContent: {text_content[:200]}...") from e
logger.info("Successfully parsed Claude analysis")
return analysis_data
except Exception as e:
logger.error(f"Claude API call failed: {e}")
raise
def test_claude_connection() -> bool:
"""
Test if Claude API is accessible with current credentials.
Returns:
True if connection successful, False otherwise
"""
if not ANTHROPIC_AVAILABLE:
logger.error("Anthropic SDK not installed")
return False
try:
api_key = _get_anthropic_api_key()
client = Anthropic(api_key=api_key)
# Simple test call
response = client.messages.create(
model="claude-3-5-sonnet-20241022",
max_tokens=10,
messages=[{"role": "user", "content": "Test"}]
)
logger.info("✅ Claude API connection successful")
return True
except Exception as e:
logger.error(f"❌ Claude API connection failed: {e}")
return False

View File

@@ -0,0 +1,395 @@
"""
BreakPilot Email Service
Ermoeglicht den Versand von Emails via SMTP.
Verwendet Mailpit im Entwicklungsmodus.
"""
import os
import smtplib
import logging
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from email.utils import formataddr
from typing import Optional, List
from dataclasses import dataclass
from datetime import datetime
logger = logging.getLogger(__name__)
# SMTP Konfiguration aus Umgebungsvariablen
SMTP_HOST = os.getenv("SMTP_HOST", "localhost")
SMTP_PORT = int(os.getenv("SMTP_PORT", "1025"))
SMTP_USERNAME = os.getenv("SMTP_USERNAME", "")
SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "")
SMTP_FROM_NAME = os.getenv("SMTP_FROM_NAME", "BreakPilot")
SMTP_FROM_ADDR = os.getenv("SMTP_FROM_ADDR", "noreply@breakpilot.app")
SMTP_USE_TLS = os.getenv("SMTP_USE_TLS", "false").lower() == "true"
@dataclass
class EmailResult:
"""Ergebnis eines Email-Versands."""
success: bool
message_id: Optional[str] = None
error: Optional[str] = None
recipient: Optional[str] = None
sent_at: Optional[str] = None
class EmailService:
"""Service fuer den Email-Versand."""
def __init__(
self,
host: str = SMTP_HOST,
port: int = SMTP_PORT,
username: str = SMTP_USERNAME,
password: str = SMTP_PASSWORD,
from_name: str = SMTP_FROM_NAME,
from_addr: str = SMTP_FROM_ADDR,
use_tls: bool = SMTP_USE_TLS
):
self.host = host
self.port = port
self.username = username
self.password = password
self.from_name = from_name
self.from_addr = from_addr
self.use_tls = use_tls
def _get_connection(self):
"""Erstellt eine SMTP-Verbindung."""
if self.use_tls:
smtp = smtplib.SMTP_SSL(self.host, self.port)
else:
smtp = smtplib.SMTP(self.host, self.port)
if self.username and self.password:
smtp.login(self.username, self.password)
return smtp
def send_email(
self,
to_email: str,
subject: str,
body_text: str,
body_html: Optional[str] = None,
reply_to: Optional[str] = None,
cc: Optional[List[str]] = None,
bcc: Optional[List[str]] = None
) -> EmailResult:
"""
Sendet eine Email.
Args:
to_email: Empfaenger-Email
subject: Betreff
body_text: Plaintext-Inhalt
body_html: Optional HTML-Inhalt
reply_to: Optional Reply-To Adresse
cc: Optional CC-Empfaenger
bcc: Optional BCC-Empfaenger
Returns:
EmailResult mit Erfolg/Fehler
"""
try:
# Message erstellen
if body_html:
msg = MIMEMultipart("alternative")
msg.attach(MIMEText(body_text, "plain", "utf-8"))
msg.attach(MIMEText(body_html, "html", "utf-8"))
else:
msg = MIMEText(body_text, "plain", "utf-8")
msg["Subject"] = subject
msg["From"] = formataddr((self.from_name, self.from_addr))
msg["To"] = to_email
if reply_to:
msg["Reply-To"] = reply_to
if cc:
msg["Cc"] = ", ".join(cc)
# Alle Empfaenger sammeln
recipients = [to_email]
if cc:
recipients.extend(cc)
if bcc:
recipients.extend(bcc)
# Senden
with self._get_connection() as smtp:
smtp.sendmail(self.from_addr, recipients, msg.as_string())
logger.info(f"Email sent to {to_email}: {subject}")
return EmailResult(
success=True,
recipient=to_email,
sent_at=datetime.utcnow().isoformat()
)
except smtplib.SMTPException as e:
logger.error(f"SMTP error sending to {to_email}: {e}")
return EmailResult(
success=False,
error=f"SMTP Fehler: {str(e)}",
recipient=to_email
)
except Exception as e:
logger.error(f"Error sending email to {to_email}: {e}")
return EmailResult(
success=False,
error=str(e),
recipient=to_email
)
def send_messenger_notification(
self,
to_email: str,
to_name: str,
sender_name: str,
message_content: str,
reply_link: Optional[str] = None
) -> EmailResult:
"""
Sendet eine Messenger-Benachrichtigung per Email.
Args:
to_email: Empfaenger-Email
to_name: Name des Empfaengers
sender_name: Name des Absenders
message_content: Nachrichteninhalt
reply_link: Optional Link zum Antworten
Returns:
EmailResult
"""
subject = f"Neue Nachricht von {sender_name} - BreakPilot"
# Plaintext Version
body_text = f"""Hallo {to_name},
Sie haben eine neue Nachricht von {sender_name} erhalten:
---
{message_content}
---
"""
if reply_link:
body_text += f"Um zu antworten, klicken Sie hier: {reply_link}\n\n"
body_text += """Mit freundlichen Gruessen
Ihr BreakPilot Team
---
Diese E-Mail wurde automatisch versendet.
Bitte antworten Sie nicht direkt auf diese E-Mail.
"""
# HTML Version
body_html = f"""<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }}
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
.header {{ background: #1a2b4e; color: white; padding: 20px; border-radius: 8px 8px 0 0; }}
.content {{ background: #f8f9fa; padding: 20px; border: 1px solid #e0e0e0; }}
.message-box {{ background: white; padding: 15px; border-radius: 8px; border-left: 4px solid #ffb800; margin: 15px 0; }}
.button {{ display: inline-block; background: #ffb800; color: #1a2b4e; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: bold; }}
.footer {{ padding: 15px; font-size: 12px; color: #666; text-align: center; }}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h2 style="margin: 0;">Neue Nachricht</h2>
</div>
<div class="content">
<p>Hallo <strong>{to_name}</strong>,</p>
<p>Sie haben eine neue Nachricht von <strong>{sender_name}</strong> erhalten:</p>
<div class="message-box">
{message_content.replace(chr(10), '<br>')}
</div>
"""
if reply_link:
body_html += f'<p><a href="{reply_link}" class="button">Nachricht beantworten</a></p>'
body_html += """
</div>
<div class="footer">
<p>Mit freundlichen Gruessen<br>Ihr BreakPilot Team</p>
<p style="font-size: 11px; color: #999;">
Diese E-Mail wurde automatisch versendet.<br>
Bitte antworten Sie nicht direkt auf diese E-Mail.
</p>
</div>
</div>
</body>
</html>
"""
return self.send_email(
to_email=to_email,
subject=subject,
body_text=body_text,
body_html=body_html
)
def send_jitsi_invitation(
self,
to_email: str,
to_name: str,
organizer_name: str,
meeting_title: str,
meeting_date: str,
meeting_time: str,
jitsi_url: str,
additional_info: Optional[str] = None
) -> EmailResult:
"""
Sendet eine Jitsi-Meeting-Einladung per Email.
Args:
to_email: Empfaenger-Email
to_name: Name des Empfaengers
organizer_name: Name des Organisators
meeting_title: Titel des Meetings
meeting_date: Datum des Meetings (z.B. "20. Dezember 2024")
meeting_time: Uhrzeit des Meetings (z.B. "14:00 Uhr")
jitsi_url: Der Jitsi-Meeting-Link
additional_info: Optional zusaetzliche Informationen
Returns:
EmailResult
"""
subject = f"Einladung: {meeting_title} - {meeting_date}"
# Plaintext Version
body_text = f"""Hallo {to_name},
{organizer_name} laedt Sie zu einem Videogespraech ein.
TERMIN: {meeting_title}
DATUM: {meeting_date}
UHRZEIT: {meeting_time}
Treten Sie dem Meeting bei:
{jitsi_url}
"""
if additional_info:
body_text += f"HINWEISE:\n{additional_info}\n\n"
body_text += """TECHNISCHE VORAUSSETZUNGEN:
- Aktueller Webbrowser (Chrome, Firefox, Safari oder Edge)
- Keine Installation erforderlich
- Optional: Kopfhoerer fuer bessere Audioqualitaet
Bei technischen Problemen wenden Sie sich bitte an den Organisator.
Mit freundlichen Gruessen
Ihr BreakPilot Team
"""
# HTML Version
body_html = f"""<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }}
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
.header {{ background: linear-gradient(135deg, #8b5cf6, #6366f1); color: white; padding: 30px; border-radius: 12px 12px 0 0; text-align: center; }}
.header h2 {{ margin: 0 0 10px 0; font-size: 24px; }}
.content {{ background: #f8f9fa; padding: 25px; border: 1px solid #e0e0e0; }}
.meeting-info {{ background: white; padding: 20px; border-radius: 12px; margin: 20px 0; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }}
.info-row {{ display: flex; padding: 10px 0; border-bottom: 1px solid #eee; }}
.info-row:last-child {{ border-bottom: none; }}
.info-label {{ font-weight: 600; color: #666; width: 100px; }}
.info-value {{ color: #333; }}
.join-button {{ display: block; background: linear-gradient(135deg, #8b5cf6, #6366f1); color: white; padding: 16px 32px; text-decoration: none; border-radius: 8px; font-weight: bold; font-size: 18px; text-align: center; margin: 25px 0; }}
.join-button:hover {{ opacity: 0.9; }}
.requirements {{ background: #e8f5e9; padding: 15px; border-radius: 8px; margin: 20px 0; }}
.requirements h4 {{ margin: 0 0 10px 0; color: #2e7d32; }}
.requirements ul {{ margin: 0; padding-left: 20px; }}
.footer {{ padding: 20px; font-size: 12px; color: #666; text-align: center; }}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h2>Einladung zum Videogespraech</h2>
<p style="margin: 0; opacity: 0.9;">{meeting_title}</p>
</div>
<div class="content">
<p>Hallo <strong>{to_name}</strong>,</p>
<p><strong>{organizer_name}</strong> laedt Sie zu einem Videogespraech ein.</p>
<div class="meeting-info">
<div class="info-row">
<span class="info-label">Termin:</span>
<span class="info-value">{meeting_title}</span>
</div>
<div class="info-row">
<span class="info-label">Datum:</span>
<span class="info-value">{meeting_date}</span>
</div>
<div class="info-row">
<span class="info-label">Uhrzeit:</span>
<span class="info-value">{meeting_time}</span>
</div>
</div>
<a href="{jitsi_url}" class="join-button">Meeting beitreten</a>
"""
if additional_info:
body_html += f"""
<div style="background: #fff3e0; padding: 15px; border-radius: 8px; margin: 20px 0;">
<h4 style="margin: 0 0 10px 0; color: #e65100;">Hinweise:</h4>
<p style="margin: 0;">{additional_info}</p>
</div>
"""
body_html += """
<div class="requirements">
<h4>Technische Voraussetzungen:</h4>
<ul>
<li>Aktueller Webbrowser (Chrome, Firefox, Safari oder Edge)</li>
<li>Keine Installation erforderlich</li>
<li>Optional: Kopfhoerer fuer bessere Audioqualitaet</li>
</ul>
</div>
<p style="font-size: 14px; color: #666;">
Bei technischen Problemen wenden Sie sich bitte an den Organisator.
</p>
</div>
<div class="footer">
<p>Mit freundlichen Gruessen<br>Ihr BreakPilot Team</p>
</div>
</div>
</body>
</html>
"""
return self.send_email(
to_email=to_email,
subject=subject,
body_text=body_text,
body_html=body_html
)
# Globale Instanz
email_service = EmailService()

View File

@@ -0,0 +1,108 @@
"""
Story Generator — Creates short stories using vocabulary words.
Generates age-appropriate mini-stories (3-5 sentences) that incorporate
the given vocabulary words, marked with <mark> tags for highlighting.
Uses Ollama (local LLM) for generation.
"""
import os
import json
import logging
import requests
from typing import List, Dict, Any, Optional
logger = logging.getLogger(__name__)
OLLAMA_URL = os.getenv("OLLAMA_BASE_URL", "http://host.docker.internal:11434")
STORY_MODEL = os.getenv("STORY_MODEL", "llama3.1:8b")
def generate_story(
vocabulary: List[Dict[str, str]],
language: str = "en",
grade_level: str = "5-8",
max_words: int = 5,
) -> Dict[str, Any]:
"""
Generate a short story incorporating vocabulary words.
Args:
vocabulary: List of dicts with 'english' and 'german' keys
language: 'en' for English story, 'de' for German story
grade_level: Target grade level
max_words: Maximum vocab words to include (to keep story short)
Returns:
Dict with 'story_html', 'story_text', 'vocab_used', 'language'
"""
# Select subset of vocabulary
words = vocabulary[:max_words]
word_list = [w.get("english", "") if language == "en" else w.get("german", "") for w in words]
word_list = [w for w in word_list if w.strip()]
if not word_list:
return {"story_html": "", "story_text": "", "vocab_used": [], "language": language}
lang_name = "English" if language == "en" else "German"
words_str = ", ".join(word_list)
prompt = f"""Write a short story (3-5 sentences) in {lang_name} for a grade {grade_level} student.
The story MUST use these vocabulary words: {words_str}
Rules:
1. The story should be fun and age-appropriate
2. Each vocabulary word must appear at least once
3. Keep sentences simple and clear
4. The story should make sense and be engaging
Write ONLY the story, nothing else. No title, no introduction."""
try:
resp = requests.post(
f"{OLLAMA_URL}/api/generate",
json={
"model": STORY_MODEL,
"prompt": prompt,
"stream": False,
"options": {"temperature": 0.8, "num_predict": 300},
},
timeout=30,
)
resp.raise_for_status()
story_text = resp.json().get("response", "").strip()
except Exception as e:
logger.error(f"Story generation failed: {e}")
# Fallback: simple template story
story_text = _fallback_story(word_list, language)
# Mark vocabulary words in the story
story_html = story_text
vocab_found = []
for word in word_list:
if word.lower() in story_html.lower():
# Case-insensitive replacement preserving original case
import re
pattern = re.compile(re.escape(word), re.IGNORECASE)
story_html = pattern.sub(
lambda m: f'<mark class="vocab-highlight">{m.group()}</mark>',
story_html,
count=1,
)
vocab_found.append(word)
return {
"story_html": story_html,
"story_text": story_text,
"vocab_used": vocab_found,
"vocab_total": len(word_list),
"language": language,
}
def _fallback_story(words: List[str], language: str) -> str:
"""Simple fallback when LLM is unavailable."""
if language == "de":
return f"Heute habe ich neue Woerter gelernt: {', '.join(words)}. Es war ein guter Tag zum Lernen."
return f"Today I learned new words: {', '.join(words)}. It was a great day for learning."

View File

@@ -0,0 +1,179 @@
"""
Translation Service — Batch-translates vocabulary words into target languages.
Uses Ollama (local LLM) to translate EN/DE word pairs into TR, AR, UK, RU, PL.
Translations are cached in vocabulary_words.translations JSONB field.
All processing happens locally — no external API calls, GDPR-compliant.
"""
import json
import logging
import os
from typing import Any, Dict, List
import httpx
logger = logging.getLogger(__name__)
OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://host.docker.internal:11434")
TRANSLATION_MODEL = os.getenv("TRANSLATION_MODEL", "qwen3:30b-a3b")
LANGUAGE_NAMES = {
"tr": "Turkish",
"ar": "Arabic",
"uk": "Ukrainian",
"ru": "Russian",
"pl": "Polish",
"fr": "French",
"es": "Spanish",
}
async def translate_words_batch(
words: List[Dict[str, str]],
target_language: str,
batch_size: int = 30,
) -> List[Dict[str, str]]:
"""
Translate a batch of EN/DE word pairs into a target language.
Args:
words: List of dicts with 'english' and 'german' keys
target_language: ISO 639-1 code (tr, ar, uk, ru, pl)
batch_size: Words per LLM request
Returns:
List of dicts with 'english', 'translation', 'example' keys
"""
lang_name = LANGUAGE_NAMES.get(target_language, target_language)
all_translations = []
for i in range(0, len(words), batch_size):
batch = words[i:i + batch_size]
word_list = "\n".join(
f"{j+1}. {w['english']} = {w.get('german', '')}"
for j, w in enumerate(batch)
)
prompt = f"""Translate these English/German word pairs into {lang_name}.
For each word, provide the translation and a short example sentence in {lang_name}.
Words:
{word_list}
Reply ONLY with a JSON array, no explanation:
[
{{"english": "word", "translation": "...", "example": "..."}},
...
]"""
try:
async with httpx.AsyncClient(timeout=120.0) as client:
resp = await client.post(
f"{OLLAMA_BASE_URL}/api/generate",
json={
"model": TRANSLATION_MODEL,
"prompt": prompt,
"stream": False,
"options": {"temperature": 0.2, "num_predict": 4096},
},
)
resp.raise_for_status()
response_text = resp.json().get("response", "")
# Parse JSON from response
import re
match = re.search(r'\[[\s\S]*\]', response_text)
if match:
batch_translations = json.loads(match.group())
all_translations.extend(batch_translations)
logger.info(
f"Translated batch {i//batch_size + 1}: "
f"{len(batch_translations)} words → {lang_name}"
)
else:
logger.warning(f"No JSON array in LLM response for {lang_name}")
except Exception as e:
logger.error(f"Translation batch failed ({lang_name}): {e}")
return all_translations
async def translate_and_store(
word_ids: List[str],
target_language: str,
) -> int:
"""
Translate vocabulary words and store in the database.
Fetches words from DB, translates via LLM, stores in translations JSONB.
Skips words that already have a translation for the target language.
Returns count of newly translated words.
"""
from vocabulary_db import get_pool
pool = await get_pool()
async with pool.acquire() as conn:
# Fetch words that need translation
rows = await conn.fetch(
"""
SELECT id, english, german, translations
FROM vocabulary_words
WHERE id = ANY($1::uuid[])
""",
[__import__('uuid').UUID(wid) for wid in word_ids],
)
words_to_translate = []
word_map = {}
for row in rows:
translations = row["translations"] or {}
if isinstance(translations, str):
translations = json.loads(translations)
if target_language not in translations:
words_to_translate.append({
"english": row["english"],
"german": row["german"],
})
word_map[row["english"].lower()] = str(row["id"])
if not words_to_translate:
logger.info(f"All {len(rows)} words already translated to {target_language}")
return 0
# Translate
results = await translate_words_batch(words_to_translate, target_language)
# Store results
updated = 0
async with pool.acquire() as conn:
for result in results:
en = result.get("english", "").lower()
word_id = word_map.get(en)
if not word_id:
continue
translation = result.get("translation", "")
example = result.get("example", "")
if not translation:
continue
await conn.execute(
"""
UPDATE vocabulary_words
SET translations = translations || $1::jsonb
WHERE id = $2
""",
json.dumps({target_language: {
"text": translation,
"example": example,
}}),
__import__('uuid').UUID(word_id),
)
updated += 1
logger.info(f"Stored {updated} translations for {target_language}")
return updated