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-pwa/klausur-service/backend/worksheet_editor_api.py
Benjamin Admin 21a844cb8a fix: Restore all files lost during destructive rebase
A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.

This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).

Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 09:51:32 +01:00

1306 lines
45 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Worksheet Editor API - Backend Endpoints for Visual Worksheet Editor
Provides endpoints for:
- AI Image generation via Ollama/Stable Diffusion
- Worksheet Save/Load
- PDF Export
"""
import os
import io
import uuid
import json
import base64
import logging
from datetime import datetime, timezone
from typing import Optional, List, Dict, Any
from enum import Enum
from dataclasses import dataclass, field, asdict
from fastapi import APIRouter, HTTPException, Request, BackgroundTasks
from fastapi.responses import FileResponse, StreamingResponse
from pydantic import BaseModel, Field
import httpx
# PDF Generation
try:
from reportlab.lib import colors
from reportlab.lib.pagesizes import A4
from reportlab.lib.units import mm
from reportlab.pdfgen import canvas
from reportlab.lib.styles import getSampleStyleSheet
REPORTLAB_AVAILABLE = True
except ImportError:
REPORTLAB_AVAILABLE = False
logger = logging.getLogger(__name__)
# =============================================
# CONFIGURATION
# =============================================
OLLAMA_URL = os.getenv("OLLAMA_URL", "http://host.docker.internal:11434")
SD_MODEL = os.getenv("SD_MODEL", "stable-diffusion") # or specific SD model
WORKSHEET_STORAGE_DIR = os.getenv("WORKSHEET_STORAGE_DIR",
os.path.join(os.path.dirname(os.path.abspath(__file__)), "worksheet-storage"))
# Ensure storage directory exists
os.makedirs(WORKSHEET_STORAGE_DIR, exist_ok=True)
# =============================================
# ENUMS & MODELS
# =============================================
class AIImageStyle(str, Enum):
REALISTIC = "realistic"
CARTOON = "cartoon"
SKETCH = "sketch"
CLIPART = "clipart"
EDUCATIONAL = "educational"
class WorksheetStatus(str, Enum):
DRAFT = "draft"
PUBLISHED = "published"
ARCHIVED = "archived"
# Style prompt modifiers
STYLE_PROMPTS = {
AIImageStyle.REALISTIC: "photorealistic, high detail, professional photography",
AIImageStyle.CARTOON: "cartoon style, colorful, child-friendly, simple shapes",
AIImageStyle.SKETCH: "pencil sketch, hand-drawn, black and white, artistic",
AIImageStyle.CLIPART: "clipart style, flat design, simple, vector-like",
AIImageStyle.EDUCATIONAL: "educational illustration, clear, informative, textbook style"
}
# =============================================
# REQUEST/RESPONSE MODELS
# =============================================
class AIImageRequest(BaseModel):
prompt: str = Field(..., min_length=3, max_length=500)
style: AIImageStyle = AIImageStyle.EDUCATIONAL
width: int = Field(512, ge=256, le=1024)
height: int = Field(512, ge=256, le=1024)
class AIImageResponse(BaseModel):
image_base64: str
prompt_used: str
error: Optional[str] = None
class PageData(BaseModel):
id: str
index: int
canvasJSON: str
class PageFormat(BaseModel):
width: float = 210
height: float = 297
orientation: str = "portrait"
margins: Dict[str, float] = {"top": 15, "right": 15, "bottom": 15, "left": 15}
class WorksheetSaveRequest(BaseModel):
id: Optional[str] = None
title: str
description: Optional[str] = None
pages: List[PageData]
pageFormat: Optional[PageFormat] = None
class WorksheetResponse(BaseModel):
id: str
title: str
description: Optional[str]
pages: List[PageData]
pageFormat: PageFormat
createdAt: str
updatedAt: str
# =============================================
# IN-MEMORY STORAGE (Development)
# =============================================
worksheets_db: Dict[str, Dict] = {}
# =============================================
# ROUTER
# =============================================
router = APIRouter(prefix="/api/v1/worksheet", tags=["Worksheet Editor"])
# =============================================
# AI IMAGE GENERATION
# =============================================
@router.post("/ai-image", response_model=AIImageResponse)
async def generate_ai_image(request: AIImageRequest):
"""
Generate an AI image using Ollama with a text-to-image model.
Supported models:
- stable-diffusion (via Ollama)
- sd3.5-medium
- llava (for image understanding, not generation)
Falls back to a placeholder if Ollama is not available.
"""
try:
# Build enhanced prompt with style
style_modifier = STYLE_PROMPTS.get(request.style, "")
enhanced_prompt = f"{request.prompt}, {style_modifier}"
logger.info(f"Generating AI image: {enhanced_prompt[:100]}...")
# Check if Ollama is available
async with httpx.AsyncClient(timeout=10.0) as check_client:
try:
health_response = await check_client.get(f"{OLLAMA_URL}/api/tags")
if health_response.status_code != 200:
raise HTTPException(status_code=503, detail="Ollama service not available")
except httpx.ConnectError:
logger.warning("Ollama not reachable, returning placeholder")
# Return a placeholder image (simple colored rectangle)
return _generate_placeholder_image(request, enhanced_prompt)
# Try to generate with Stable Diffusion via Ollama
# Note: Ollama doesn't natively support SD, this is a placeholder for when it does
# or when using a compatible endpoint
try:
async with httpx.AsyncClient(timeout=300.0) as client:
# Check if SD model is available
tags_response = await client.get(f"{OLLAMA_URL}/api/tags")
available_models = [m.get("name", "") for m in tags_response.json().get("models", [])]
# Look for SD-compatible model
sd_model = None
for model in available_models:
if "stable" in model.lower() or "sd" in model.lower() or "diffusion" in model.lower():
sd_model = model
break
if not sd_model:
logger.warning("No Stable Diffusion model found in Ollama")
return _generate_placeholder_image(request, enhanced_prompt)
# Generate image (this would need Ollama's image generation API)
# For now, return placeholder
logger.info(f"SD model found: {sd_model}, but image generation API not implemented")
return _generate_placeholder_image(request, enhanced_prompt)
except Exception as e:
logger.error(f"Image generation failed: {e}")
return _generate_placeholder_image(request, enhanced_prompt)
except HTTPException:
raise
except Exception as e:
logger.error(f"AI image generation error: {e}")
raise HTTPException(status_code=500, detail=str(e))
def _generate_placeholder_image(request: AIImageRequest, prompt: str) -> AIImageResponse:
"""
Generate a placeholder image when AI generation is not available.
Creates a simple SVG-based placeholder with the prompt text.
"""
from PIL import Image, ImageDraw, ImageFont
# Create image
width, height = request.width, request.height
# Style-based colors
style_colors = {
AIImageStyle.REALISTIC: ("#2563eb", "#dbeafe"),
AIImageStyle.CARTOON: ("#f97316", "#ffedd5"),
AIImageStyle.SKETCH: ("#6b7280", "#f3f4f6"),
AIImageStyle.CLIPART: ("#8b5cf6", "#ede9fe"),
AIImageStyle.EDUCATIONAL: ("#059669", "#d1fae5"),
}
fg_color, bg_color = style_colors.get(request.style, ("#6366f1", "#e0e7ff"))
# Create image with Pillow
img = Image.new('RGB', (width, height), bg_color)
draw = ImageDraw.Draw(img)
# Draw border
draw.rectangle([5, 5, width-6, height-6], outline=fg_color, width=3)
# Draw icon (simple shapes)
cx, cy = width // 2, height // 2 - 30
draw.ellipse([cx-40, cy-40, cx+40, cy+40], outline=fg_color, width=3)
draw.line([cx-20, cy-10, cx+20, cy-10], fill=fg_color, width=3)
draw.line([cx, cy-10, cx, cy+20], fill=fg_color, width=3)
# Draw text
try:
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 14)
except:
font = ImageFont.load_default()
# Wrap text
max_chars = 40
lines = []
words = prompt[:200].split()
current_line = ""
for word in words:
if len(current_line) + len(word) + 1 <= max_chars:
current_line += (" " + word if current_line else word)
else:
if current_line:
lines.append(current_line)
current_line = word
if current_line:
lines.append(current_line)
text_y = cy + 60
for line in lines[:4]: # Max 4 lines
bbox = draw.textbbox((0, 0), line, font=font)
text_width = bbox[2] - bbox[0]
draw.text((cx - text_width // 2, text_y), line, fill=fg_color, font=font)
text_y += 20
# Draw "AI Placeholder" badge
badge_text = "KI-Bild (Platzhalter)"
try:
badge_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 10)
except:
badge_font = font
draw.rectangle([10, height-30, 150, height-10], fill=fg_color)
draw.text((15, height-27), badge_text, fill="white", font=badge_font)
# Convert to base64
buffer = io.BytesIO()
img.save(buffer, format='PNG')
buffer.seek(0)
image_base64 = f"data:image/png;base64,{base64.b64encode(buffer.getvalue()).decode('utf-8')}"
return AIImageResponse(
image_base64=image_base64,
prompt_used=prompt,
error="AI image generation not available. Using placeholder."
)
# =============================================
# WORKSHEET SAVE/LOAD
# =============================================
@router.post("/save", response_model=WorksheetResponse)
async def save_worksheet(request: WorksheetSaveRequest):
"""
Save a worksheet document.
- If id is provided, updates existing worksheet
- If id is not provided, creates new worksheet
"""
try:
now = datetime.now(timezone.utc).isoformat()
# Generate or use existing ID
worksheet_id = request.id or f"ws_{uuid.uuid4().hex[:12]}"
# Build worksheet data
worksheet = {
"id": worksheet_id,
"title": request.title,
"description": request.description,
"pages": [p.dict() for p in request.pages],
"pageFormat": (request.pageFormat or PageFormat()).dict(),
"createdAt": worksheets_db.get(worksheet_id, {}).get("createdAt", now),
"updatedAt": now
}
# Save to in-memory storage
worksheets_db[worksheet_id] = worksheet
# Also persist to file
filepath = os.path.join(WORKSHEET_STORAGE_DIR, f"{worksheet_id}.json")
with open(filepath, 'w', encoding='utf-8') as f:
json.dump(worksheet, f, ensure_ascii=False, indent=2)
logger.info(f"Saved worksheet: {worksheet_id}")
return WorksheetResponse(**worksheet)
except Exception as e:
logger.error(f"Failed to save worksheet: {e}")
raise HTTPException(status_code=500, detail=f"Failed to save: {str(e)}")
@router.get("/{worksheet_id}", response_model=WorksheetResponse)
async def get_worksheet(worksheet_id: str):
"""
Load a worksheet document by ID.
"""
try:
# Try in-memory first
if worksheet_id in worksheets_db:
return WorksheetResponse(**worksheets_db[worksheet_id])
# Try file storage
filepath = os.path.join(WORKSHEET_STORAGE_DIR, f"{worksheet_id}.json")
if os.path.exists(filepath):
with open(filepath, 'r', encoding='utf-8') as f:
worksheet = json.load(f)
worksheets_db[worksheet_id] = worksheet # Cache it
return WorksheetResponse(**worksheet)
raise HTTPException(status_code=404, detail="Worksheet not found")
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to load worksheet {worksheet_id}: {e}")
raise HTTPException(status_code=500, detail=f"Failed to load: {str(e)}")
@router.get("/list/all")
async def list_worksheets():
"""
List all available worksheets.
"""
try:
worksheets = []
# Load from file storage
for filename in os.listdir(WORKSHEET_STORAGE_DIR):
if filename.endswith('.json'):
filepath = os.path.join(WORKSHEET_STORAGE_DIR, filename)
try:
with open(filepath, 'r', encoding='utf-8') as f:
worksheet = json.load(f)
worksheets.append({
"id": worksheet.get("id"),
"title": worksheet.get("title"),
"description": worksheet.get("description"),
"pageCount": len(worksheet.get("pages", [])),
"updatedAt": worksheet.get("updatedAt"),
"createdAt": worksheet.get("createdAt")
})
except Exception as e:
logger.warning(f"Failed to load {filename}: {e}")
# Sort by updatedAt descending
worksheets.sort(key=lambda x: x.get("updatedAt", ""), reverse=True)
return {"worksheets": worksheets, "total": len(worksheets)}
except Exception as e:
logger.error(f"Failed to list worksheets: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/{worksheet_id}")
async def delete_worksheet(worksheet_id: str):
"""
Delete a worksheet document.
"""
try:
# Remove from memory
if worksheet_id in worksheets_db:
del worksheets_db[worksheet_id]
# Remove file
filepath = os.path.join(WORKSHEET_STORAGE_DIR, f"{worksheet_id}.json")
if os.path.exists(filepath):
os.remove(filepath)
logger.info(f"Deleted worksheet: {worksheet_id}")
return {"status": "deleted", "id": worksheet_id}
raise HTTPException(status_code=404, detail="Worksheet not found")
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to delete worksheet {worksheet_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
# =============================================
# PDF EXPORT
# =============================================
@router.post("/{worksheet_id}/export-pdf")
async def export_worksheet_pdf(worksheet_id: str):
"""
Export worksheet as PDF.
Note: This creates a basic PDF. For full canvas rendering,
the frontend should use pdf-lib with canvas.toDataURL().
"""
if not REPORTLAB_AVAILABLE:
raise HTTPException(status_code=501, detail="PDF export not available (reportlab not installed)")
try:
# Load worksheet
worksheet = worksheets_db.get(worksheet_id)
if not worksheet:
filepath = os.path.join(WORKSHEET_STORAGE_DIR, f"{worksheet_id}.json")
if os.path.exists(filepath):
with open(filepath, 'r', encoding='utf-8') as f:
worksheet = json.load(f)
else:
raise HTTPException(status_code=404, detail="Worksheet not found")
# Create PDF
buffer = io.BytesIO()
c = canvas.Canvas(buffer, pagesize=A4)
page_width, page_height = A4
for page_data in worksheet.get("pages", []):
# Add title on first page
if page_data.get("index", 0) == 0:
c.setFont("Helvetica-Bold", 18)
c.drawString(50, page_height - 50, worksheet.get("title", "Arbeitsblatt"))
c.setFont("Helvetica", 10)
c.drawString(50, page_height - 70, f"Erstellt: {worksheet.get('createdAt', '')[:10]}")
# Parse canvas JSON and render basic elements
canvas_json_str = page_data.get("canvasJSON", "{}")
if canvas_json_str:
try:
canvas_data = json.loads(canvas_json_str)
objects = canvas_data.get("objects", [])
for obj in objects:
obj_type = obj.get("type", "")
if obj_type in ["text", "i-text", "textbox"]:
# Render text
text = obj.get("text", "")
left = obj.get("left", 50)
top = obj.get("top", 100)
font_size = obj.get("fontSize", 12)
# Convert from canvas coords to PDF coords
pdf_x = left * 0.75 # Approximate scale
pdf_y = page_height - (top * 0.75)
c.setFont("Helvetica", min(font_size, 24))
c.drawString(pdf_x, pdf_y, text[:100])
elif obj_type == "rect":
# Render rectangle
left = obj.get("left", 0) * 0.75
top = obj.get("top", 0) * 0.75
width = obj.get("width", 50) * 0.75
height = obj.get("height", 30) * 0.75
c.rect(left, page_height - top - height, width, height)
elif obj_type == "circle":
# Render circle
left = obj.get("left", 0) * 0.75
top = obj.get("top", 0) * 0.75
radius = obj.get("radius", 25) * 0.75
c.circle(left + radius, page_height - top - radius, radius)
except json.JSONDecodeError:
pass
c.showPage()
c.save()
buffer.seek(0)
filename = f"{worksheet.get('title', 'worksheet').replace(' ', '_')}.pdf"
return StreamingResponse(
buffer,
media_type="application/pdf",
headers={"Content-Disposition": f"attachment; filename={filename}"}
)
except HTTPException:
raise
except Exception as e:
logger.error(f"PDF export failed: {e}")
raise HTTPException(status_code=500, detail=str(e))
# =============================================
# AI WORKSHEET MODIFICATION
# =============================================
class AIModifyRequest(BaseModel):
prompt: str = Field(..., min_length=3, max_length=1000)
canvas_json: str
model: str = "qwen2.5vl:32b"
class AIModifyResponse(BaseModel):
modified_canvas_json: Optional[str] = None
message: str
error: Optional[str] = None
@router.post("/ai-modify", response_model=AIModifyResponse)
async def modify_worksheet_with_ai(request: AIModifyRequest):
"""
Modify a worksheet using AI based on natural language prompt.
Uses Ollama with qwen2.5vl:32b to understand the canvas state
and generate modifications based on the user's request.
"""
try:
logger.info(f"AI modify request: {request.prompt[:100]}...")
# Parse current canvas state
try:
canvas_data = json.loads(request.canvas_json)
except json.JSONDecodeError:
return AIModifyResponse(
message="Fehler beim Parsen des Canvas",
error="Invalid canvas JSON"
)
# Build system prompt for the AI
system_prompt = """Du bist ein Assistent fuer die Bearbeitung von Arbeitsblaettern.
Du erhaeltst den aktuellen Zustand eines Canvas im JSON-Format und eine Anweisung des Nutzers.
Deine Aufgabe ist es, die gewuenschten Aenderungen am Canvas vorzunehmen.
Der Canvas verwendet Fabric.js. Hier sind die wichtigsten Objekttypen:
- i-text: Interaktiver Text mit fontFamily, fontSize, fill, left, top
- rect: Rechteck mit left, top, width, height, fill, stroke, strokeWidth
- circle: Kreis mit left, top, radius, fill, stroke, strokeWidth
- line: Linie mit x1, y1, x2, y2, stroke, strokeWidth
Das Canvas ist 794x1123 Pixel (A4 bei 96 DPI).
Antworte NUR mit einem JSON-Objekt in diesem Format:
{
"action": "modify" oder "add" oder "delete" oder "info",
"objects": [...], // Neue/modifizierte Objekte (bei modify/add)
"message": "Kurze Beschreibung der Aenderung"
}
Wenn du Objekte hinzufuegst, generiere eindeutige IDs im Format "obj_<timestamp>_<random>".
"""
user_prompt = f"""Aktueller Canvas-Zustand:
```json
{json.dumps(canvas_data, indent=2)[:5000]}
```
Nutzer-Anweisung: {request.prompt}
Fuehre die Aenderung durch und antworte mit dem JSON-Objekt."""
# Call Ollama
try:
async with httpx.AsyncClient(timeout=120.0) as client:
response = await client.post(
f"{OLLAMA_URL}/api/generate",
json={
"model": request.model,
"prompt": user_prompt,
"system": system_prompt,
"stream": False,
"options": {
"temperature": 0.3,
"num_predict": 4096
}
}
)
if response.status_code != 200:
logger.warning(f"Ollama error: {response.status_code}, trying local fallback")
# Fallback: Try to handle simple requests locally
return _handle_simple_modification(request.prompt, canvas_data)
result = response.json()
ai_response = result.get("response", "")
except httpx.ConnectError:
logger.warning("Ollama not reachable")
# Fallback: Try to handle simple requests locally
return _handle_simple_modification(request.prompt, canvas_data)
except httpx.TimeoutException:
logger.warning("Ollama timeout, trying local fallback")
# Fallback: Try to handle simple requests locally
return _handle_simple_modification(request.prompt, canvas_data)
# Parse AI response
try:
# Find JSON in response
json_start = ai_response.find('{')
json_end = ai_response.rfind('}') + 1
if json_start == -1 or json_end <= json_start:
logger.warning(f"No JSON found in AI response: {ai_response[:200]}")
return AIModifyResponse(
message="KI konnte die Anfrage nicht verarbeiten",
error="No JSON in response"
)
ai_json = json.loads(ai_response[json_start:json_end])
action = ai_json.get("action", "info")
message = ai_json.get("message", "Aenderungen angewendet")
new_objects = ai_json.get("objects", [])
if action == "info":
return AIModifyResponse(message=message)
if action == "add" and new_objects:
# Add new objects to canvas
existing_objects = canvas_data.get("objects", [])
existing_objects.extend(new_objects)
canvas_data["objects"] = existing_objects
return AIModifyResponse(
modified_canvas_json=json.dumps(canvas_data),
message=message
)
if action == "modify" and new_objects:
# Replace matching objects or add new ones
existing_objects = canvas_data.get("objects", [])
new_ids = {obj.get("id") for obj in new_objects if obj.get("id")}
# Keep objects that aren't being modified
kept_objects = [obj for obj in existing_objects if obj.get("id") not in new_ids]
kept_objects.extend(new_objects)
canvas_data["objects"] = kept_objects
return AIModifyResponse(
modified_canvas_json=json.dumps(canvas_data),
message=message
)
if action == "delete":
# Delete objects by ID
delete_ids = ai_json.get("delete_ids", [])
if delete_ids:
existing_objects = canvas_data.get("objects", [])
canvas_data["objects"] = [obj for obj in existing_objects if obj.get("id") not in delete_ids]
return AIModifyResponse(
modified_canvas_json=json.dumps(canvas_data),
message=message
)
return AIModifyResponse(message=message)
except json.JSONDecodeError as e:
logger.error(f"Failed to parse AI JSON: {e}")
return AIModifyResponse(
message="Fehler beim Verarbeiten der KI-Antwort",
error=str(e)
)
except Exception as e:
logger.error(f"AI modify error: {e}")
return AIModifyResponse(
message="Ein unerwarteter Fehler ist aufgetreten",
error=str(e)
)
def _handle_simple_modification(prompt: str, canvas_data: dict) -> AIModifyResponse:
"""
Handle simple modifications locally when Ollama is not available.
Supports basic commands like adding headings, lines, etc.
"""
import time
import random
prompt_lower = prompt.lower()
objects = canvas_data.get("objects", [])
def generate_id():
return f"obj_{int(time.time()*1000)}_{random.randint(1000, 9999)}"
# Add heading
if "ueberschrift" in prompt_lower or "titel" in prompt_lower or "heading" in prompt_lower:
# Extract text if provided in quotes
import re
text_match = re.search(r'"([^"]+)"', prompt)
text = text_match.group(1) if text_match else "Ueberschrift"
new_text = {
"type": "i-text",
"id": generate_id(),
"text": text,
"left": 397, # Center of A4
"top": 50,
"originX": "center",
"fontFamily": "Arial",
"fontSize": 28,
"fontWeight": "bold",
"fill": "#000000"
}
objects.append(new_text)
canvas_data["objects"] = objects
return AIModifyResponse(
modified_canvas_json=json.dumps(canvas_data),
message=f"Ueberschrift '{text}' hinzugefuegt"
)
# Add lines for writing
if "linie" in prompt_lower or "line" in prompt_lower or "schreib" in prompt_lower:
# Count how many lines
import re
num_match = re.search(r'(\d+)', prompt)
num_lines = int(num_match.group(1)) if num_match else 5
num_lines = min(num_lines, 20) # Max 20 lines
start_y = 150
line_spacing = 40
for i in range(num_lines):
new_line = {
"type": "line",
"id": generate_id(),
"x1": 60,
"y1": start_y + i * line_spacing,
"x2": 734,
"y2": start_y + i * line_spacing,
"stroke": "#cccccc",
"strokeWidth": 1
}
objects.append(new_line)
canvas_data["objects"] = objects
return AIModifyResponse(
modified_canvas_json=json.dumps(canvas_data),
message=f"{num_lines} Schreiblinien hinzugefuegt"
)
# Make text bigger
if "groesser" in prompt_lower or "bigger" in prompt_lower or "larger" in prompt_lower:
modified = 0
for obj in objects:
if obj.get("type") in ["i-text", "text", "textbox"]:
current_size = obj.get("fontSize", 16)
obj["fontSize"] = int(current_size * 1.25)
modified += 1
canvas_data["objects"] = objects
if modified > 0:
return AIModifyResponse(
modified_canvas_json=json.dumps(canvas_data),
message=f"{modified} Texte vergroessert"
)
# Center elements
if "zentrier" in prompt_lower or "center" in prompt_lower or "mitte" in prompt_lower:
center_x = 397
for obj in objects:
if not obj.get("isGrid"):
obj["left"] = center_x
obj["originX"] = "center"
canvas_data["objects"] = objects
return AIModifyResponse(
modified_canvas_json=json.dumps(canvas_data),
message="Elemente zentriert"
)
# Add numbering
if "nummer" in prompt_lower or "nummerier" in prompt_lower or "1-10" in prompt_lower:
import re
range_match = re.search(r'(\d+)\s*[-bis]+\s*(\d+)', prompt)
if range_match:
start, end = int(range_match.group(1)), int(range_match.group(2))
else:
start, end = 1, 10
y = 100
for i in range(start, min(end + 1, start + 20)):
new_text = {
"type": "i-text",
"id": generate_id(),
"text": f"{i}.",
"left": 40,
"top": y,
"fontFamily": "Arial",
"fontSize": 14,
"fill": "#000000"
}
objects.append(new_text)
y += 35
canvas_data["objects"] = objects
return AIModifyResponse(
modified_canvas_json=json.dumps(canvas_data),
message=f"Nummerierung {start}-{end} hinzugefuegt"
)
# Add rectangle/box
if "rechteck" in prompt_lower or "box" in prompt_lower or "kasten" in prompt_lower:
new_rect = {
"type": "rect",
"id": generate_id(),
"left": 100,
"top": 200,
"width": 200,
"height": 100,
"fill": "transparent",
"stroke": "#000000",
"strokeWidth": 2
}
objects.append(new_rect)
canvas_data["objects"] = objects
return AIModifyResponse(
modified_canvas_json=json.dumps(canvas_data),
message="Rechteck hinzugefuegt"
)
# Add grid/raster
if "raster" in prompt_lower or "grid" in prompt_lower or "tabelle" in prompt_lower:
import re
# Parse dimensions like "3x4", "3/4", "3 mal 4", "3 by 4"
dim_match = re.search(r'(\d+)\s*[x/×\*mal by]\s*(\d+)', prompt_lower)
if dim_match:
cols = int(dim_match.group(1))
rows = int(dim_match.group(2))
else:
# Try single numbers
nums = re.findall(r'(\d+)', prompt)
if len(nums) >= 2:
cols, rows = int(nums[0]), int(nums[1])
else:
cols, rows = 3, 4 # Default grid
# Limit grid size
cols = min(max(1, cols), 10)
rows = min(max(1, rows), 15)
# Canvas dimensions (A4 at 96 DPI)
canvas_width = 794
canvas_height = 1123
# Grid positioning
margin = 60
available_width = canvas_width - 2 * margin
available_height = canvas_height - 2 * margin - 80 # Leave space for header
cell_width = available_width / cols
cell_height = min(available_height / rows, 80) # Max cell height
start_x = margin
start_y = 120 # Below potential header
# Create grid lines
grid_objects = []
# Horizontal lines
for r in range(rows + 1):
y = start_y + r * cell_height
grid_objects.append({
"type": "line",
"id": generate_id(),
"x1": start_x,
"y1": y,
"x2": start_x + cols * cell_width,
"y2": y,
"stroke": "#666666",
"strokeWidth": 1,
"isGrid": True
})
# Vertical lines
for c in range(cols + 1):
x = start_x + c * cell_width
grid_objects.append({
"type": "line",
"id": generate_id(),
"x1": x,
"y1": start_y,
"x2": x,
"y2": start_y + rows * cell_height,
"stroke": "#666666",
"strokeWidth": 1,
"isGrid": True
})
objects.extend(grid_objects)
canvas_data["objects"] = objects
return AIModifyResponse(
modified_canvas_json=json.dumps(canvas_data),
message=f"{cols}x{rows} Raster hinzugefuegt ({cols} Spalten, {rows} Zeilen)"
)
# Default: Ollama needed
return AIModifyResponse(
message="Diese Aenderung erfordert den KI-Service. Bitte stellen Sie sicher, dass Ollama laeuft.",
error="Complex modification requires Ollama"
)
# =============================================
# HEALTH CHECK
# =============================================
@router.get("/health/check")
async def health_check():
"""
Check worksheet editor API health and dependencies.
"""
status = {
"status": "healthy",
"ollama": False,
"storage": os.path.exists(WORKSHEET_STORAGE_DIR),
"reportlab": REPORTLAB_AVAILABLE,
"worksheets_count": len(worksheets_db)
}
# Check Ollama
try:
async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.get(f"{OLLAMA_URL}/api/tags")
status["ollama"] = response.status_code == 200
except:
pass
return status
# =============================================
# DOCUMENT RECONSTRUCTION FROM VOCAB SESSION
# =============================================
class ReconstructRequest(BaseModel):
session_id: str
page_number: int = 1
include_images: bool = True
regenerate_graphics: bool = False
class ReconstructResponse(BaseModel):
canvas_json: str
page_width: int
page_height: int
elements_count: int
vocabulary_matched: int
message: str
error: Optional[str] = None
@router.post("/reconstruct-from-session", response_model=ReconstructResponse)
async def reconstruct_document_from_session(request: ReconstructRequest):
"""
Reconstruct a document from a vocab session into Fabric.js canvas format.
This endpoint:
1. Loads the original PDF from the vocab session
2. Runs OCR with position tracking
3. Uses vision LLM to understand layout (headers, images, columns)
4. Creates Fabric.js canvas JSON with positioned elements
5. Maps extracted vocabulary to their positions
Returns canvas JSON ready to load into the worksheet editor.
"""
try:
# Import vocab session storage
from vocab_worksheet_api import _sessions, convert_pdf_page_to_image
# Check if session exists
if request.session_id not in _sessions:
raise HTTPException(status_code=404, detail=f"Session {request.session_id} not found")
session = _sessions[request.session_id]
# Check if PDF data exists
if not session.get("pdf_data"):
raise HTTPException(status_code=400, detail="Session has no PDF data")
pdf_data = session["pdf_data"]
page_count = session.get("pdf_page_count", 1)
if request.page_number < 1 or request.page_number > page_count:
raise HTTPException(
status_code=400,
detail=f"Page {request.page_number} not found. PDF has {page_count} pages."
)
# Get extracted vocabulary for this page
vocabulary = session.get("vocabulary", [])
page_vocab = [v for v in vocabulary if v.get("source_page") == request.page_number]
logger.info(f"Reconstructing page {request.page_number} from session {request.session_id}")
logger.info(f"Found {len(page_vocab)} vocabulary items for this page")
# Convert PDF page to image (async function)
image_bytes = await convert_pdf_page_to_image(pdf_data, request.page_number)
if not image_bytes:
raise HTTPException(status_code=500, detail="Failed to convert PDF page to image")
# Get image dimensions
from PIL import Image
img = Image.open(io.BytesIO(image_bytes))
img_width, img_height = img.size
# Run OCR with positions
from hybrid_vocab_extractor import run_paddle_ocr, OCRRegion
ocr_regions, raw_text = run_paddle_ocr(image_bytes)
logger.info(f"OCR found {len(ocr_regions)} text regions")
# Scale factor: Convert image pixels to A4 canvas pixels (794x1123)
A4_WIDTH = 794
A4_HEIGHT = 1123
scale_x = A4_WIDTH / img_width
scale_y = A4_HEIGHT / img_height
# Build Fabric.js objects
fabric_objects = []
# 1. Add white background
fabric_objects.append({
"type": "rect",
"left": 0,
"top": 0,
"width": A4_WIDTH,
"height": A4_HEIGHT,
"fill": "#ffffff",
"selectable": False,
"evented": False,
"isBackground": True
})
# 2. Group OCR regions by Y-coordinate to detect rows
sorted_regions = sorted(ocr_regions, key=lambda r: (r.y1, r.x1))
# 3. Detect headers (larger text at top)
headers = []
body_regions = []
for region in sorted_regions:
height = region.y2 - region.y1
# Headers are typically taller and near the top
if region.y1 < img_height * 0.15 and height > 30:
headers.append(region)
else:
body_regions.append(region)
# 4. Create text objects for each region
vocab_matched = 0
for region in sorted_regions:
# Scale positions to A4
left = int(region.x1 * scale_x)
top = int(region.y1 * scale_y)
# Determine if this is a header
is_header = region in headers
# Determine font size based on region height
region_height = region.y2 - region.y1
base_font_size = max(10, min(32, int(region_height * scale_y * 0.8)))
if is_header:
base_font_size = max(base_font_size, 24)
# Check if this text matches vocabulary
is_vocab = False
vocab_match = None
for v in page_vocab:
if v.get("english", "").lower() in region.text.lower() or \
v.get("german", "").lower() in region.text.lower():
is_vocab = True
vocab_match = v
vocab_matched += 1
break
# Create Fabric.js text object
text_obj = {
"type": "i-text",
"id": f"text_{uuid.uuid4().hex[:8]}",
"left": left,
"top": top,
"text": region.text,
"fontFamily": "Arial",
"fontSize": base_font_size,
"fontWeight": "bold" if is_header else "normal",
"fill": "#000000",
"originX": "left",
"originY": "top",
}
# Add metadata for vocabulary items
if is_vocab and vocab_match:
text_obj["isVocabulary"] = True
text_obj["vocabularyId"] = vocab_match.get("id")
text_obj["english"] = vocab_match.get("english")
text_obj["german"] = vocab_match.get("german")
fabric_objects.append(text_obj)
# 5. If include_images, try to detect and extract image regions
if request.include_images:
image_regions = await _detect_image_regions(image_bytes, ocr_regions, img_width, img_height)
for i, img_region in enumerate(image_regions):
# Extract image region from original
img_x1 = int(img_region["x1"])
img_y1 = int(img_region["y1"])
img_x2 = int(img_region["x2"])
img_y2 = int(img_region["y2"])
# Crop the region
cropped = img.crop((img_x1, img_y1, img_x2, img_y2))
# Convert to base64
buffer = io.BytesIO()
cropped.save(buffer, format='PNG')
buffer.seek(0)
img_base64 = f"data:image/png;base64,{base64.b64encode(buffer.getvalue()).decode('utf-8')}"
# Create Fabric.js image object
fabric_objects.append({
"type": "image",
"id": f"img_{uuid.uuid4().hex[:8]}",
"left": int(img_x1 * scale_x),
"top": int(img_y1 * scale_y),
"width": int((img_x2 - img_x1) * scale_x),
"height": int((img_y2 - img_y1) * scale_y),
"src": img_base64,
"scaleX": 1,
"scaleY": 1,
})
# Build canvas JSON
canvas_data = {
"version": "6.0.0",
"objects": fabric_objects,
"background": "#ffffff"
}
return ReconstructResponse(
canvas_json=json.dumps(canvas_data),
page_width=A4_WIDTH,
page_height=A4_HEIGHT,
elements_count=len(fabric_objects),
vocabulary_matched=vocab_matched,
message=f"Reconstructed page {request.page_number} with {len(fabric_objects)} elements, {vocab_matched} vocabulary items matched"
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Document reconstruction failed: {e}")
import traceback
logger.error(traceback.format_exc())
raise HTTPException(status_code=500, detail=str(e))
async def _detect_image_regions(
image_bytes: bytes,
ocr_regions: list,
img_width: int,
img_height: int
) -> List[Dict]:
"""
Detect image/graphic regions in the document.
Uses a simple approach:
1. Find large gaps between text regions (potential image areas)
2. Use edge detection to find bounded regions
3. Filter out text areas
"""
from PIL import Image
import numpy as np
try:
img = Image.open(io.BytesIO(image_bytes))
img_array = np.array(img.convert('L')) # Grayscale
# Create a mask of text regions
text_mask = np.ones_like(img_array, dtype=bool)
for region in ocr_regions:
x1 = max(0, region.x1 - 5)
y1 = max(0, region.y1 - 5)
x2 = min(img_width, region.x2 + 5)
y2 = min(img_height, region.y2 + 5)
text_mask[y1:y2, x1:x2] = False
# Find contours in non-text areas
# Simple approach: look for rectangular regions with significant content
image_regions = []
# Use edge detection
import cv2
edges = cv2.Canny(img_array, 50, 150)
# Apply text mask
edges[~text_mask] = 0
# Find contours
contours, _ = cv2.findContours(edges, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
for contour in contours:
x, y, w, h = cv2.boundingRect(contour)
# Filter: minimum size for images (at least 50x50 pixels)
if w > 50 and h > 50:
# Filter: not too large (not the whole page)
if w < img_width * 0.9 and h < img_height * 0.9:
# Check if this region has actual content (not just edges)
region_content = img_array[y:y+h, x:x+w]
variance = np.var(region_content)
if variance > 500: # Has enough visual content
image_regions.append({
"x1": x,
"y1": y,
"x2": x + w,
"y2": y + h
})
# Remove overlapping regions (keep larger ones)
filtered_regions = []
for region in sorted(image_regions, key=lambda r: (r["x2"]-r["x1"])*(r["y2"]-r["y1"]), reverse=True):
overlaps = False
for existing in filtered_regions:
# Check overlap
if not (region["x2"] < existing["x1"] or region["x1"] > existing["x2"] or
region["y2"] < existing["y1"] or region["y1"] > existing["y2"]):
overlaps = True
break
if not overlaps:
filtered_regions.append(region)
logger.info(f"Detected {len(filtered_regions)} image regions")
return filtered_regions[:10] # Limit to 10 images max
except Exception as e:
logger.warning(f"Image region detection failed: {e}")
return []
@router.get("/sessions/available")
async def get_available_sessions():
"""
Get list of available vocab sessions that can be reconstructed.
"""
try:
from vocab_worksheet_api import _sessions
available = []
for session_id, session in _sessions.items():
if session.get("pdf_data"): # Only sessions with PDF
available.append({
"id": session_id,
"name": session.get("name", "Unnamed"),
"description": session.get("description"),
"vocabulary_count": len(session.get("vocabulary", [])),
"page_count": session.get("pdf_page_count", 1),
"status": session.get("status", "unknown"),
"created_at": session.get("created_at", "").isoformat() if session.get("created_at") else None
})
return {"sessions": available, "total": len(available)}
except Exception as e:
logger.error(f"Failed to list sessions: {e}")
raise HTTPException(status_code=500, detail=str(e))