Files
breakpilot-lehrer/klausur-service/backend/worksheet_editor_api.py
Benjamin Admin b2a0126f14 [split-required] Split remaining Python monoliths (Phase 1 continued)
klausur-service (7 monoliths):
- grid_editor_helpers.py (1,737 → 5 files: columns, filters, headers, zones)
- cv_cell_grid.py (1,675 → 7 files: build, legacy, streaming, merge, vocab)
- worksheet_editor_api.py (1,305 → 4 files: models, AI, reconstruct, routes)
- legal_corpus_ingestion.py (1,280 → 3 files: registry, chunking, ingestion)
- cv_review.py (1,248 → 4 files: pipeline, spell, LLM, barrel)
- cv_preprocessing.py (1,166 → 3 files: deskew, dewarp, barrel)
- rbac.py, admin_api.py, routes/eh.py remain (next batch)

backend-lehrer (1 monolith):
- classroom_engine/repository.py (1,705 → 7 files by domain)

All re-export barrels preserve backward compatibility.
Zero import errors verified.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-24 22:47:59 +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 worksheet_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 worksheet_editor_ai import ( # noqa: F401
generate_ai_image_logic,
_generate_placeholder_image,
modify_worksheet_with_ai_logic,
_handle_simple_modification,
)
from worksheet_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))