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
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:
@@ -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"]
|
||||
|
||||
81
backend-lehrer/services/ai_processor.py
Normal file
81
backend-lehrer/services/ai_processor.py
Normal 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",
|
||||
]
|
||||
125
backend-lehrer/services/audio.py
Normal file
125
backend-lehrer/services/audio.py
Normal 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
|
||||
299
backend-lehrer/services/claude_vision.py
Normal file
299
backend-lehrer/services/claude_vision.py
Normal 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
|
||||
395
backend-lehrer/services/email.py
Normal file
395
backend-lehrer/services/email.py
Normal 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()
|
||||
108
backend-lehrer/services/story_generator.py
Normal file
108
backend-lehrer/services/story_generator.py
Normal 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."
|
||||
179
backend-lehrer/services/translation.py
Normal file
179
backend-lehrer/services/translation.py
Normal 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
|
||||
Reference in New Issue
Block a user