"""Documents Generator routes (Phase L1-L3) — letterheads, print templates, block registry, preview, render, assets. Mounted under /api/v1/reports via the report_generator manifest (documents endpoints live in their own module; the manifest registers it as a second PluginRouteDef). """ from __future__ import annotations import io import uuid as uuid_mod from typing import Any from fastapi import APIRouter, Depends, HTTPException, Response, UploadFile, status from fastapi.responses import StreamingResponse from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.ai.llm_client import llm_complete from app.core.audit import log_audit from app.core.db import get_db, set_tenant_context from app.core.storage import get_storage_backend from app.deps import require_permission, require_workspace_scope from app.plugins.builtins.report_generator.document_blocks import ( BlockValidationError, get_document_blocks, validate_blocks, ) from app.plugins.builtins.report_generator.document_renderer import ( collect_block_asset_ids, collect_placeholder_defaults, load_assets_data_urls, merge_placeholder_defaults, render_document_html, ) from app.plugins.builtins.report_generator.einvoice import ( EInvoiceValidationError, render_einvoice_xml, resolve_einvoice_data, validate_einvoice_data, ) from app.plugins.builtins.report_generator.models import ( DocumentAsset, Letterhead, PrintTemplate, ) from app.plugins.builtins.report_generator.pdf_generator import generate_pdf from app.plugins.builtins.report_generator.schemas import ( DocumentAssetResponse, DocumentPreviewRequest, DocumentPreviewResponse, DocumentRenderRequest, DocumentSuggestRequest, DocumentSuggestResponse, EInvoiceRenderForRequest, EInvoiceRenderRequest, EInvoiceValidationResponse, LetterheadCreate, LetterheadResponse, LetterheadUpdate, PrintTemplateCreate, PrintTemplateResponse, PrintTemplateUpdate, ) router = APIRouter(prefix="/api/v1/reports", tags=["documents"]) def _parse_uuid(val: str, field: str) -> uuid_mod.UUID: try: return uuid_mod.UUID(val) except (ValueError, TypeError): raise HTTPException( 400, detail={"detail": f"Invalid {field}", "code": "invalid_id"} ) from None def _letterhead_to_response(lh: Letterhead) -> LetterheadResponse: return LetterheadResponse( id=str(lh.id), name=lh.name, description=lh.description, config=lh.config or {}, is_default=lh.is_default, created_by=str(lh.created_by), created_at=lh.created_at, updated_at=lh.updated_at, ) def _template_to_response(t: PrintTemplate) -> PrintTemplateResponse: return PrintTemplateResponse( id=str(t.id), name=t.name, description=t.description, letterhead_id=str(t.letterhead_id) if t.letterhead_id else None, entity_type=t.entity_type, blocks=t.blocks or [], output_format=t.output_format, created_by=str(t.created_by), created_at=t.created_at, updated_at=t.updated_at, ) async def _load_entity_data(db, tenant_id, entity_type: str, entity_id): """Fetch document data for an entity via plugin contracts (L1). Iterates contracts exposing ``document_data(db, tenant_id, entity_id, entity_type)``; the first non-empty dict wins. Unknown entities → None (→ 404); a known entity with no data → {} (renders empty placeholders). """ from app.plugins.builtins.contracts import get_contract_registry from app.plugins.registry import get_registry for plugin_name in get_registry().list_discovered(): contract = get_contract_registry().get_contract(plugin_name) fn = getattr(contract, "document_data", None) if fn is None: continue try: data = await fn(db, tenant_id, entity_id, entity_type) except Exception: # noqa: BLE001 — broken contribution must not 500 continue if data: return data # No contribution produced data: unknown entity type or entity not # found — both are 404 for the caller (never render an empty document # silently). return None async def _get_letterhead(db, tenant_id, lh_id) -> Letterhead | None: return ( await db.execute( select(Letterhead).where( Letterhead.id == lh_id, Letterhead.tenant_id == tenant_id, Letterhead.deleted_at.is_(None), ) ) ).scalar_one_or_none() async def _get_template(db, tenant_id, tid) -> PrintTemplate | None: return ( await db.execute( select(PrintTemplate).where( PrintTemplate.id == tid, PrintTemplate.tenant_id == tenant_id, PrintTemplate.deleted_at.is_(None), ) ) ).scalar_one_or_none() # ─── Letterheads ───────────────────────────────────────────────────────────── @router.get("/letterheads") async def list_letterheads( db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("reports:read")), ): """List letterheads for the current tenant.""" tenant_id = uuid_mod.UUID(current_user["tenant_id"]) q = ( select(Letterhead) .where( Letterhead.tenant_id == tenant_id, Letterhead.deleted_at.is_(None), ) .order_by(Letterhead.name) ) items = (await db.execute(q)).scalars().all() return { "items": [_letterhead_to_response(item).model_dump() for item in items], "total": len(items), } @router.post("/letterheads", status_code=status.HTTP_201_CREATED) async def create_letterhead( body: LetterheadCreate, db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("reports:manage_templates")), ): """Create a letterhead.""" tenant_id = uuid_mod.UUID(current_user["tenant_id"]) user_id = uuid_mod.UUID(current_user["user_id"]) await set_tenant_context(db, tenant_id) config = body.config or {} for section in ("header", "footer"): section_cfg = config.get(section) or {} blocks = section_cfg.get("blocks") if blocks is not None: try: validate_blocks(blocks) except BlockValidationError as exc: raise HTTPException( 422, detail={"detail": str(exc), "code": "invalid_block"}, ) from exc lh = Letterhead( tenant_id=tenant_id, name=body.name, description=body.description, config=config, is_default=body.is_default, created_by=user_id, owner_id=user_id, ) db.add(lh) await db.flush() await log_audit( db, tenant_id, user_id, "create", "letterhead", lh.id, changes={"name": lh.name}, ) return _letterhead_to_response(lh).model_dump() @router.get("/letterheads/{letterhead_id}") async def get_letterhead( letterhead_id: str, db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("reports:read")), ): tenant_id = uuid_mod.UUID(current_user["tenant_id"]) lh_id = _parse_uuid(letterhead_id, "letterhead_id") lh = await _get_letterhead(db, tenant_id, lh_id) if lh is None: raise HTTPException(404, detail={"detail": "Letterhead not found", "code": "not_found"}) return _letterhead_to_response(lh).model_dump() @router.put("/letterheads/{letterhead_id}") async def update_letterhead( letterhead_id: str, body: LetterheadUpdate, db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("reports:manage_templates")), ): tenant_id = uuid_mod.UUID(current_user["tenant_id"]) user_id = uuid_mod.UUID(current_user["user_id"]) lh_id = _parse_uuid(letterhead_id, "letterhead_id") lh = await _get_letterhead(db, tenant_id, lh_id) if lh is None: raise HTTPException(404, detail={"detail": "Letterhead not found", "code": "not_found"}) if body.config is not None: for section in ("header", "footer"): section_cfg = (body.config or {}).get(section) or {} blocks = section_cfg.get("blocks") if blocks is not None: try: validate_blocks(blocks) except BlockValidationError as exc: raise HTTPException( 422, detail={"detail": str(exc), "code": "invalid_block"}, ) from exc lh.config = body.config if body.name is not None: lh.name = body.name if body.description is not None: lh.description = body.description if body.is_default is not None: lh.is_default = body.is_default await db.flush() await db.refresh(lh) # onupdate columns expire — refresh async-safe await log_audit( db, tenant_id, user_id, "update", "letterhead", lh.id, changes={"name": lh.name}, ) return _letterhead_to_response(lh).model_dump() @router.delete("/letterheads/{letterhead_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_letterhead( letterhead_id: str, db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("reports:manage_templates")), ): tenant_id = uuid_mod.UUID(current_user["tenant_id"]) user_id = uuid_mod.UUID(current_user["user_id"]) lh_id = _parse_uuid(letterhead_id, "letterhead_id") lh = await _get_letterhead(db, tenant_id, lh_id) if lh is None: raise HTTPException(404, detail={"detail": "Letterhead not found", "code": "not_found"}) from datetime import UTC, datetime lh.deleted_at = datetime.now(UTC) await db.flush() await log_audit( db, tenant_id, user_id, "delete", "letterhead", lh.id, changes={"name": lh.name}, ) return None # ─── Letterhead Assets (logo/image upload) ────────────────────────────────── ALLOWED_IMAGE_MIMES = {"image/png", "image/jpeg", "image/gif", "image/svg+xml", "image/webp"} MAX_ASSET_SIZE = 5 * 1024 * 1024 # 5 MB @router.post( "/letterheads/{letterhead_id}/assets", status_code=status.HTTP_201_CREATED, ) async def upload_letterhead_asset( letterhead_id: str, file: UploadFile, db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("reports:manage_templates")), ): """Upload an image asset for a letterhead (logo, header graphic).""" tenant_id = uuid_mod.UUID(current_user["tenant_id"]) user_id = uuid_mod.UUID(current_user["user_id"]) lh_id = _parse_uuid(letterhead_id, "letterhead_id") lh = await _get_letterhead(db, tenant_id, lh_id) if lh is None: raise HTTPException(404, detail={"detail": "Letterhead not found", "code": "not_found"}) mime = (file.content_type or "").lower() if mime not in ALLOWED_IMAGE_MIMES: raise HTTPException( 422, detail={ "detail": f"Nur Bild-Dateien sind erlaubt (erhalten: {mime})", "code": "invalid_mime_type", }, ) content = await file.read() if len(content) > MAX_ASSET_SIZE: raise HTTPException( 413, detail={"detail": "Bild ist größer als 5 MB", "code": "asset_too_large"}, ) asset_id = uuid_mod.uuid4() storage = get_storage_backend() storage_path = f"documents/{tenant_id}/{asset_id}" await storage.save(storage_path, content) asset = DocumentAsset( id=asset_id, tenant_id=tenant_id, letterhead_id=lh_id, filename=file.filename or "asset", mime_type=mime, size_bytes=len(content), storage_path=storage_path, created_by=user_id, owner_id=user_id, ) db.add(asset) await db.flush() await log_audit( db, tenant_id, user_id, "create", "document_asset", asset.id, changes={"filename": asset.filename, "letterhead_id": str(lh_id)}, ) import base64 as _b64 data_url = f"data:{mime};base64,{_b64.b64encode(content).decode('ascii')}" return DocumentAssetResponse( id=str(asset.id), letterhead_id=str(asset.letterhead_id), filename=asset.filename, mime_type=asset.mime_type, size_bytes=asset.size_bytes, data_url=data_url, created_at=asset.created_at, ).model_dump() @router.get("/letterheads/{letterhead_id}/assets") async def list_letterhead_assets( letterhead_id: str, db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("reports:read")), ): """List assets for a letterhead (metadata + data_url).""" tenant_id = uuid_mod.UUID(current_user["tenant_id"]) lh_id = _parse_uuid(letterhead_id, "letterhead_id") lh = await _get_letterhead(db, tenant_id, lh_id) if lh is None: raise HTTPException(404, detail={"detail": "Letterhead not found", "code": "not_found"}) assets_map = await load_assets_data_urls(db, tenant_id, letterhead_id=letterhead_id) q = select(DocumentAsset).where( DocumentAsset.tenant_id == tenant_id, DocumentAsset.letterhead_id == lh_id, DocumentAsset.deleted_at.is_(None), ) assets = (await db.execute(q)).scalars().all() return [ DocumentAssetResponse( id=str(a.id), letterhead_id=str(a.letterhead_id) if a.letterhead_id else None, filename=a.filename, mime_type=a.mime_type, size_bytes=a.size_bytes, data_url=assets_map.get(str(a.id)), created_at=a.created_at, ).model_dump() for a in assets ] # ─── Print Templates ───────────────────────────────────────────────────────── @router.get("/print-templates") async def list_print_templates( db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("reports:read")), workspace_scope: dict | None = Depends(require_workspace_scope("reports")), ): """List print templates for the current tenant. Phase N4: an active workspace scope restricts the template list to the configured subset (pure AND — never a grant). """ tenant_id = uuid_mod.UUID(current_user["tenant_id"]) q = ( select(PrintTemplate) .where( PrintTemplate.tenant_id == tenant_id, PrintTemplate.deleted_at.is_(None), ) .order_by(PrintTemplate.name) ) items = (await db.execute(q)).scalars().all() if workspace_scope: from app.services.workspace_scope_service import scope_uuid_set template_scope = scope_uuid_set(workspace_scope.get("template_ids")) if template_scope is not None: items = [t for t in items if t.id in template_scope] return { "items": [_template_to_response(t).model_dump() for t in items], "total": len(items), } @router.post("/print-templates", status_code=status.HTTP_201_CREATED) async def create_print_template( body: PrintTemplateCreate, db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("reports:manage_templates")), ): """Create a print template (drag/drop block composition).""" tenant_id = uuid_mod.UUID(current_user["tenant_id"]) user_id = uuid_mod.UUID(current_user["user_id"]) await set_tenant_context(db, tenant_id) try: validate_blocks(body.blocks) except BlockValidationError as exc: raise HTTPException( 422, detail={"detail": str(exc), "code": "invalid_block"} ) from exc letterhead_id = None if body.letterhead_id: letterhead_id = _parse_uuid(body.letterhead_id, "letterhead_id") if await _get_letterhead(db, tenant_id, letterhead_id) is None: raise HTTPException( 404, detail={"detail": "Letterhead not found", "code": "not_found"} ) template = PrintTemplate( tenant_id=tenant_id, name=body.name, description=body.description, letterhead_id=letterhead_id, entity_type=body.entity_type, blocks=body.blocks, output_format=body.output_format, created_by=user_id, owner_id=user_id, ) db.add(template) await db.flush() await log_audit( db, tenant_id, user_id, "create", "print_template", template.id, changes={"name": template.name}, ) return _template_to_response(template).model_dump() @router.get("/print-templates/{template_id}") async def get_print_template( template_id: str, db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("reports:read")), ): tenant_id = uuid_mod.UUID(current_user["tenant_id"]) tid = _parse_uuid(template_id, "template_id") template = await _get_template(db, tenant_id, tid) if template is None: raise HTTPException(404, detail={"detail": "Template not found", "code": "not_found"}) return _template_to_response(template).model_dump() @router.put("/print-templates/{template_id}") async def update_print_template( template_id: str, body: PrintTemplateUpdate, db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("reports:manage_templates")), ): tenant_id = uuid_mod.UUID(current_user["tenant_id"]) user_id = uuid_mod.UUID(current_user["user_id"]) tid = _parse_uuid(template_id, "template_id") template = await _get_template(db, tenant_id, tid) if template is None: raise HTTPException(404, detail={"detail": "Template not found", "code": "not_found"}) if body.blocks is not None: try: validate_blocks(body.blocks) except BlockValidationError as exc: raise HTTPException( 422, detail={"detail": str(exc), "code": "invalid_block"} ) from exc template.blocks = body.blocks if body.name is not None: template.name = body.name if body.description is not None: template.description = body.description if body.entity_type is not None: template.entity_type = body.entity_type if body.output_format is not None: template.output_format = body.output_format if body.letterhead_id is not None: if body.letterhead_id: lh_id = _parse_uuid(body.letterhead_id, "letterhead_id") if await _get_letterhead(db, tenant_id, lh_id) is None: raise HTTPException( 404, detail={"detail": "Letterhead not found", "code": "not_found"} ) template.letterhead_id = lh_id else: template.letterhead_id = None await db.flush() await db.refresh(template) # onupdate columns expire — refresh async-safe await log_audit( db, tenant_id, user_id, "update", "print_template", template.id, changes={"name": template.name}, ) return _template_to_response(template).model_dump() @router.delete("/print-templates/{template_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_print_template( template_id: str, db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("reports:manage_templates")), ): tenant_id = uuid_mod.UUID(current_user["tenant_id"]) user_id = uuid_mod.UUID(current_user["user_id"]) tid = _parse_uuid(template_id, "template_id") template = await _get_template(db, tenant_id, tid) if template is None: raise HTTPException(404, detail={"detail": "Template not found", "code": "not_found"}) from datetime import UTC, datetime template.deleted_at = datetime.now(UTC) await db.flush() await log_audit( db, tenant_id, user_id, "delete", "print_template", template.id, changes={"name": template.name}, ) return None # ─── Block Registry / Placeholders ─────────────────────────────────────────── @router.get("/document-blocks") async def list_document_blocks( current_user: dict = Depends(require_permission("reports:read")), ): """List all available block types (builtin + module contributions).""" return get_document_blocks() @router.get("/document-placeholders") async def list_document_placeholders( entity_type: str | None = None, current_user: dict = Depends(require_permission("reports:read")), ): """List available placeholders per entity type (module contributions).""" from app.plugins.builtins.contracts import get_contract_registry from app.plugins.registry import get_registry result: dict[str, list[dict]] = {} for plugin_name in get_registry().list_discovered(): contract = get_contract_registry().get_contract(plugin_name) fn = getattr(contract, "document_placeholders", None) if fn is None: continue try: # contracts declare which entity types they serve types_fn = getattr(contract, "document_entity_types", None) entity_types = types_fn() if types_fn else ["contact"] for etype in entity_types: placeholders = fn(etype) or [] if placeholders: result.setdefault(etype, []).extend(placeholders) except Exception: # noqa: BLE001 continue if entity_type: return {entity_type: result.get(entity_type, [])} return result # ─── Preview (HTML) ────────────────────────────────────────────────────────── @router.post("/documents/preview") async def preview_document( body: DocumentPreviewRequest, db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("reports:read")), ): """Render blocks to HTML for the live editor preview (no PDF).""" try: validate_blocks(body.blocks) except BlockValidationError as exc: raise HTTPException( 422, detail={"detail": str(exc), "code": "invalid_block"} ) from exc tenant_id = uuid_mod.UUID(current_user["tenant_id"]) # load assets referenced by image blocks (editor preview shows images) asset_ids = collect_block_asset_ids(body.blocks) header_blocks = ((body.letterhead_config or {}).get("header") or {}).get("blocks") or [] footer_blocks = ((body.letterhead_config or {}).get("footer") or {}).get("blocks") or [] asset_ids += collect_block_asset_ids(header_blocks) asset_ids += collect_block_asset_ids(footer_blocks) assets_map = await load_assets_data_urls(db, tenant_id, asset_ids=asset_ids) if asset_ids else {} data = merge_placeholder_defaults(body.data, body.entity_type) html = render_document_html( body.blocks, data, letterhead_config=body.letterhead_config, assets_map=assets_map, ) return DocumentPreviewResponse(html=html).model_dump() # ─── Render (PDF) ──────────────────────────────────────────────────────────── @router.post("/print-templates/{template_id}/render") async def render_print_template( template_id: str, body: DocumentRenderRequest, db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("reports:generate")), ): """Render a stored print template with entity data to PDF.""" tenant_id = uuid_mod.UUID(current_user["tenant_id"]) tid = _parse_uuid(template_id, "template_id") template = await _get_template(db, tenant_id, tid) if template is None: raise HTTPException(404, detail={"detail": "Template not found", "code": "not_found"}) entity_id = _parse_uuid(body.entity_id, "entity_id") entity_data = await _load_entity_data(db, tenant_id, body.entity_type, entity_id) if entity_data is None: raise HTTPException( 404, detail={ "detail": f"Kein Daten-Beitrag für entity_type '{body.entity_type}' — Modul nicht aktiv oder Entität unbekannt", "code": "no_data_source", }, ) # resolve letterhead letterhead_config = None letterhead_id = template.letterhead_id if letterhead_id: lh = await _get_letterhead(db, tenant_id, letterhead_id) if lh: letterhead_config = lh.config or {} # load image assets from template blocks + letterhead blocks asset_ids = collect_block_asset_ids(template.blocks or []) if letterhead_config: asset_ids += collect_block_asset_ids((letterhead_config.get("header") or {}).get("blocks") or []) asset_ids += collect_block_asset_ids((letterhead_config.get("footer") or {}).get("blocks") or []) assets_map = await load_assets_data_urls(db, tenant_id, asset_ids=asset_ids) if asset_ids else {} data = merge_placeholder_defaults(entity_data, template.entity_type) html = render_document_html( template.blocks or [], data, letterhead_config=letterhead_config, assets_map=assets_map, ) # sync PDF generation — close DB before CPU-bound work (existing pattern) await db.close() try: pdf_bytes = generate_pdf(html) except Exception as exc: raise HTTPException( 500, detail={"detail": f"PDF-Generierung fehlgeschlagen: {exc}", "code": "generation_failed"}, ) from exc from datetime import UTC, datetime filename = f"{template.name.replace(' ', '_')}_{datetime.now(UTC).strftime('%Y%m%d_%H%M%S')}.pdf" return StreamingResponse( io.BytesIO(pdf_bytes), media_type="application/pdf", headers={"Content-Disposition": f'attachment; filename="{filename}"'}, ) # ─── E-Invoice (EN16931/XRechnung format layer, Phase L5) ──────────────────── @router.post("/einvoice/render") async def render_einvoice( body: EInvoiceRenderRequest, current_user: dict = Depends(require_permission("reports:generate")), ): """Render inline invoice data to EN16931/XRechnung CII XML. Pure format endpoint - the sales module later uses render-for with its einvoice_data() contract hook. """ data = body.model_dump(exclude_none=True) try: validate_einvoice_data(data) except EInvoiceValidationError as exc: raise HTTPException( 422, detail={"detail": "; ".join(exc.missing), "code": "invalid_einvoice", "missing": exc.missing}, ) from exc xml = render_einvoice_xml(data) filename = f"{data['invoice_number'].replace(' ', '_')}.xml" return Response( content=xml, media_type="application/xml", headers={"Content-Disposition": f'attachment; filename="{filename}"'}, ) @router.post("/einvoice/validate") async def validate_einvoice( body: EInvoiceRenderRequest, current_user: dict = Depends(require_permission("reports:read")), ): """Validate invoice data without rendering (missing BT/BG terms -> 422).""" data = body.model_dump(exclude_none=True) try: validate_einvoice_data(data) except EInvoiceValidationError as exc: raise HTTPException( 422, detail={"detail": "; ".join(exc.missing), "code": "invalid_einvoice", "missing": exc.missing}, ) from exc return EInvoiceValidationResponse(valid=True).model_dump() @router.post("/einvoice/render-for") async def render_einvoice_for_entity( body: EInvoiceRenderForRequest, db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("reports:generate")), ): """Render an e-invoice for an entity via the einvoice_data() contract. Docking point for the future sales module: it contributes einvoice_data(db, tenant_id, entity_id, entity_type) and this endpoint handles validation + XML generation. No contribution -> 404. """ tenant_id = uuid_mod.UUID(current_user["tenant_id"]) entity_id = _parse_uuid(body.entity_id, "entity_id") data = await resolve_einvoice_data(db, tenant_id, body.entity_type, entity_id) if data is None: raise HTTPException( 404, detail={ "detail": ( f"Kein E-Invoice-Datenbeitrag fuer entity_type '{body.entity_type}' - " "Modul nicht aktiv oder Entitaet unbekannt" ), "code": "no_data_source", }, ) try: validate_einvoice_data(data) except EInvoiceValidationError as exc: raise HTTPException( 422, detail={"detail": "; ".join(exc.missing), "code": "invalid_einvoice", "missing": exc.missing}, ) from exc xml = render_einvoice_xml(data) invoice_number = str(data.get("invoice_number") or "einvoice") filename = f"{invoice_number.replace(' ', '_')}.xml" return Response( content=xml, media_type="application/xml", headers={"Content-Disposition": f'attachment; filename="{filename}"'}, ) # ─── AI block suggestion (Phase L4) ────────────────────────────────────────── _SUGGEST_HEADER = ( "Du bist ein Assistent fuer einen Drag&Drop-Dokumenteditor fuer deutsche Geschaeftsdokumente.\n" "Erstelle aus der Nutzeranfrage eine Block-Komposition fuer eine Druckvorlage.\n\n" "Antworte AUSSCHLIESSLICH mit JSON in dieser Struktur:\n" '{"blocks": [{"id": "b1", "type": "", "config": {}}], "notes": "kurze Erklaerung"}\n\n' "Verfuegbare Block-Typen mit ihren config-Feldern:\n" ) _SUGGEST_MIDDLE = "\n\nVerfuegbare Platzhalter (in Text-Bloecken in der Form {key} verwendbar):\n" _SUGGEST_FOOTER = ( "\n\nRegeln:\n" "- Nutze nur gelistete Block-Typen.\n" "- Jeder Block braucht eine eindeutige id (b1, b2, ...).\n" "- Text-Inhalte koennen Jinja2-Platzhalter wie {firstname} enthalten.\n" '- shape-Bloecke benoetigen "shape": "line", "rect" oder "circle".\n' "- Antworte nur mit dem JSON-Objekt, kein Markdown, keine Code-Fences." ) def _strip_code_fences(text: str) -> str: """Strip ```json ...``` fences LLMs like to add.""" stripped = text.strip() if stripped.startswith("```"): first_newline = stripped.find("\n") if first_newline != -1: stripped = stripped[first_newline + 1 :] if stripped.rstrip().endswith("```"): stripped = stripped.rstrip()[:-3] return stripped.strip() def _sanitize_suggested_blocks(blocks: Any) -> list[dict]: """Filter AI blocks down to registry-valid entries with server ids.""" if not isinstance(blocks, list): return [] from app.plugins.builtins.report_generator.document_blocks import ( BlockValidationError, _known_types, validate_block, ) known = _known_types() result: list[dict] = [] for i, block in enumerate(blocks): if not isinstance(block, dict): continue candidate = { "id": str(block.get("id") or f"ai_{uuid_mod.uuid4().hex[:12]}"), "type": block.get("type"), "config": block.get("config") or {}, } try: validate_block(candidate, index=i, known_types=known) except BlockValidationError: continue # drop invalid AI output instead of failing the request result.append(candidate) return result @router.post("/documents/suggest") async def suggest_document_blocks( body: DocumentSuggestRequest, db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("reports:manage_templates")), ): """KI-Steuerung (L4): natural language -> block composition suggestion. Goes through the central llm_complete (cost tracking, tenant budget). The AI response is sanitized against the block registry - invalid blocks are dropped, ids are assigned server-side. LLM failures -> 502. """ import json as _json tenant_id = uuid_mod.UUID(current_user["tenant_id"]) block_types = get_document_blocks() block_types_desc = _json.dumps( [ {"type": b["type"], "label": b["label"], "fields": b.get("fields", {})} for b in block_types ], ensure_ascii=False, ) placeholders = collect_placeholder_defaults(body.entity_type) if body.entity_type else {} placeholders_desc = _json.dumps(placeholders, ensure_ascii=False) system_prompt = ( _SUGGEST_HEADER + block_types_desc + _SUGGEST_MIDDLE + placeholders_desc + _SUGGEST_FOOTER ) try: result = await llm_complete( model="gpt-4o-mini", messages=[ {"role": "system", "content": system_prompt}, {"role": "user", "content": body.prompt}, ], temperature=0.3, max_tokens=2000, tenant_id=tenant_id, db=db, ) except Exception as exc: raise HTTPException( 502, detail={"detail": f"KI-Antwort fehlgeschlagen: {exc}", "code": "ai_unavailable"}, ) from exc raw_content = result.get("content") or "" try: parsed = _json.loads(_strip_code_fences(raw_content)) except (ValueError, TypeError) as exc: raise HTTPException( 502, detail={ "detail": "KI-Antwort war kein valides JSON", "code": "invalid_ai_response", }, ) from exc if not isinstance(parsed, dict): raise HTTPException( 502, detail={"detail": "KI-Antwort-Struktur ungueltig", "code": "invalid_ai_response"}, ) blocks = _sanitize_suggested_blocks(parsed.get("blocks")) notes = str(parsed.get("notes") or "") return DocumentSuggestResponse(blocks=blocks, notes=notes).model_dump()