Restructure: Move 52 files into 7 domain packages
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
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
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>
This commit is contained in:
@@ -1,388 +1,4 @@
|
||||
"""
|
||||
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))
|
||||
# Backward-compat shim -- module moved to worksheet/editor_api.py
|
||||
import importlib as _importlib
|
||||
import sys as _sys
|
||||
_sys.modules[__name__] = _importlib.import_module("worksheet.editor_api")
|
||||
|
||||
Reference in New Issue
Block a user