feat(L4-L5): KI-Steuerung + XRechnung-Format-Layer (EN16931/CII)
Check Cross-Plugin Imports / check (push) Has been cancelled
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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user