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>
389 lines
13 KiB
Python
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))
|