feat(L4-L5): KI-Steuerung + XRechnung-Format-Layer (EN16931/CII)
Check Cross-Plugin Imports / check (push) Has been cancelled

L5 Format-Layer (User-Klaerung: Verkaufsmodul spaeter, Format JETZT):
- einvoice.py: EN16931/XRechnung CII-XML-Generator (ElementTree, XML-Escaping gratis), Pflichtfeld-Validierung mit BT/BG-Codes, Decimal-kommerzielles Rounding, Header-Tax-Breakdown pro VAT-Satz, Profile en16931|xrechnung (XRechnung 3.0)
- POST /einvoice/render (inline->XML), /einvoice/validate (422 mit BT-Fehlliste), /einvoice/render-for (Contract-Resolver einvoice_data() — Andockpunkt Verkaufsmodul, 404 no_data_source ohne Beitrag)

L4 KI-Steuerung:
- POST /documents/suggest: Natuerliche Sprache -> Block-Komposition via zentralem llm_complete (Cost-Tracking, Tenant-Budget), Registry-Sanitizing (ungueltige KI-Bloecke gefiltert, IDs serverseitig), Code-Fence-Stripping, 502 ai_unavailable/invalid_ai_response
- Frontend: KI-Vorschlag-Panel im PrintTemplateEditor (Sparkles-Icon, Prompt-Textarea, Bloecke werden angehaengt), i18n de/en

TDD: Rot 25 failed -> Gruen 25/25 (Validierung, XML-Struktur/Escaping/Summen, Contract-Mocks, API 200/422/403/404, Suggest Mock-LLM/Fence/502). tsc exit 0, Build OK, ruff clean. Doku: api-documentation.md, plugin-development-guide.md (einvoice_data-Contract), PROGRESS.md
This commit is contained in:
Agent Zero
2026-08-29 18:05:55 +02:00
parent b311ab7aa1
commit 559bba69a4
11 changed files with 1363 additions and 1 deletions
@@ -10,12 +10,14 @@ from __future__ import annotations
import io
import uuid as uuid_mod
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, UploadFile, status
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
@@ -27,10 +29,17 @@ from app.plugins.builtins.report_generator.document_blocks import (
)
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,
@@ -42,6 +51,11 @@ from app.plugins.builtins.report_generator.schemas import (
DocumentPreviewRequest,
DocumentPreviewResponse,
DocumentRenderRequest,
DocumentSuggestRequest,
DocumentSuggestResponse,
EInvoiceRenderForRequest,
EInvoiceRenderRequest,
EInvoiceValidationResponse,
LetterheadCreate,
LetterheadResponse,
LetterheadUpdate,
@@ -708,3 +722,234 @@ async def render_print_template(
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": "<block-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()