Files
breakpilot-lehrer/klausur-service/backend/worksheet/editor_api.py
Benjamin Admin 165c493d1e
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 28s
CI / test-go-edu-search (push) Successful in 28s
CI / test-python-klausur (push) Failing after 2m22s
CI / test-python-agent-core (push) Successful in 21s
CI / test-nodejs-website (push) Successful in 23s
Restructure: Move 52 files into 7 domain packages
korrektur/ zeugnis/ admin/ compliance/ worksheet/ training/ metrics/
52 shims, relative imports, RAG untouched.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 22:10:48 +02:00

389 lines
13 KiB
Python

"""
Worksheet Editor API - Backend Endpoints for Visual Worksheet Editor
Provides endpoints for:
- AI Image generation via Ollama/Stable Diffusion
- Worksheet Save/Load
- PDF Export
Split modules:
- worksheet_editor_models: Enums, Pydantic models, configuration
- worksheet_editor_ai: AI image generation and AI worksheet modification
- worksheet_editor_reconstruct: Document reconstruction from vocab sessions
"""
import os
import io
import json
import logging
from datetime import datetime, timezone
import uuid
from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
import httpx
# Re-export everything from sub-modules for backward compatibility
from .editor_models import ( # noqa: F401
AIImageStyle,
WorksheetStatus,
AIImageRequest,
AIImageResponse,
PageData,
PageFormat,
WorksheetSaveRequest,
WorksheetResponse,
AIModifyRequest,
AIModifyResponse,
ReconstructRequest,
ReconstructResponse,
worksheets_db,
OLLAMA_URL,
SD_MODEL,
WORKSHEET_STORAGE_DIR,
STYLE_PROMPTS,
REPORTLAB_AVAILABLE,
)
from .editor_ai import ( # noqa: F401
generate_ai_image_logic,
_generate_placeholder_image,
modify_worksheet_with_ai_logic,
_handle_simple_modification,
)
from .editor_reconstruct import ( # noqa: F401
reconstruct_document_logic,
_detect_image_regions,
)
logger = logging.getLogger(__name__)
# =============================================
# 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.
"""
return await generate_ai_image_logic(request)
# =============================================
# 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()
worksheet_id = request.id or f"ws_{uuid.uuid4().hex[:12]}"
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
}
worksheets_db[worksheet_id] = worksheet
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:
if worksheet_id in worksheets_db:
return WorksheetResponse(**worksheets_db[worksheet_id])
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
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 = []
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}")
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:
if worksheet_id in worksheets_db:
del worksheets_db[worksheet_id]
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:
from reportlab.lib.pagesizes import A4
from reportlab.pdfgen import canvas
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")
buffer = io.BytesIO()
c = canvas.Canvas(buffer, pagesize=A4)
page_width, page_height = A4
for page_data in worksheet.get("pages", []):
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]}")
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"]:
text = obj.get("text", "")
left = obj.get("left", 50)
top = obj.get("top", 100)
font_size = obj.get("fontSize", 12)
pdf_x = left * 0.75
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":
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":
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
# =============================================
@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.
"""
return await modify_worksheet_with_ai_logic(request)
# =============================================
# 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)
}
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 Exception:
pass
return status
# =============================================
# DOCUMENT RECONSTRUCTION FROM VOCAB SESSION
# =============================================
@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.
Returns canvas JSON ready to load into the worksheet editor.
"""
try:
return await reconstruct_document_logic(request)
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))
@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"):
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))