v4.1.0: DMS email documents, category-specific Nachweis linking, version system
- Save cover email body as DMS document with new 'email' context type - Show email body separately from attachments in email detail view - Add per-category DMS document assignment in quarterly confirmation (Studiennachweis, Einkommenssituation, Vermögenssituation) - Add VERSION file and context processor for automatic version display - Add MCP server, agent system, import/export, and new migrations - Update compose files and production environment template Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
363
app/stiftung/agent/tools.py
Normal file
363
app/stiftung/agent/tools.py
Normal file
@@ -0,0 +1,363 @@
|
||||
"""
|
||||
Tool-Registry für den AI Agent.
|
||||
|
||||
Wrappt bestehende Django-ORM-Abfragen (analog zu mcp_server/tools/lesen.py)
|
||||
mit direktem DB-Zugriff und PII-Filterung basierend auf Django-User-Berechtigungen.
|
||||
Schreib-Tools sind standardmäßig deaktiviert.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Hilfsfunktionen
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _get_role(user) -> str:
|
||||
"""Leitet MCP-Rolle aus Django-User ab."""
|
||||
if user.is_superuser or user.has_perm("stiftung.access_administration"):
|
||||
return "admin"
|
||||
return "readonly"
|
||||
|
||||
|
||||
def _serialize(obj: Any) -> Any:
|
||||
"""Serialisiert Django-Modell-Werte zu JSON-fähigen Typen."""
|
||||
if obj is None:
|
||||
return None
|
||||
if isinstance(obj, Decimal):
|
||||
return float(obj)
|
||||
if hasattr(obj, "isoformat"):
|
||||
return obj.isoformat()
|
||||
if hasattr(obj, "__str__"):
|
||||
return str(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def _apply_pii(data: dict, model_type: str, role: str) -> dict:
|
||||
"""Wendet PII-Filterung via mcp_server.privacy an."""
|
||||
from mcp_server.privacy import apply_privacy_filter
|
||||
return apply_privacy_filter(data, model_type, role)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Tool-Implementierungen (Read-Only)
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def tool_destinataer_suchen(user, suchbegriff: str = "", aktiv: bool | None = None, limit: int = 20) -> str:
|
||||
from django.db.models import Q
|
||||
from stiftung.models import Destinataer
|
||||
role = _get_role(user)
|
||||
limit = min(limit, 50)
|
||||
qs = Destinataer.objects.all()
|
||||
if suchbegriff:
|
||||
qs = qs.filter(
|
||||
Q(vorname__icontains=suchbegriff)
|
||||
| Q(nachname__icontains=suchbegriff)
|
||||
| Q(institution__icontains=suchbegriff)
|
||||
)
|
||||
if aktiv is not None:
|
||||
qs = qs.filter(aktiv=aktiv)
|
||||
qs = qs.order_by("nachname", "vorname")[:limit]
|
||||
results = []
|
||||
for obj in qs:
|
||||
item = {
|
||||
"id": str(obj.id),
|
||||
"vorname": obj.vorname,
|
||||
"nachname": obj.nachname,
|
||||
"familienzweig": obj.familienzweig,
|
||||
"aktiv": obj.aktiv,
|
||||
"ort": obj.ort,
|
||||
"email": obj.email,
|
||||
}
|
||||
results.append(_apply_pii(item, "destinataer", role))
|
||||
return json.dumps({"count": len(results), "destinataere": results}, default=_serialize, ensure_ascii=False)
|
||||
|
||||
|
||||
def tool_land_suchen(user, suchbegriff: str = "", limit: int = 20) -> str:
|
||||
from django.db.models import Q
|
||||
from stiftung.models import Land
|
||||
limit = min(limit, 50)
|
||||
qs = Land.objects.all()
|
||||
if suchbegriff:
|
||||
qs = qs.filter(
|
||||
Q(bezeichnung__icontains=suchbegriff)
|
||||
| Q(gemarkung__icontains=suchbegriff)
|
||||
| Q(ort__icontains=suchbegriff)
|
||||
)
|
||||
qs = qs.order_by("bezeichnung")[:limit]
|
||||
results = []
|
||||
for obj in qs:
|
||||
results.append({
|
||||
"id": str(obj.id),
|
||||
"bezeichnung": obj.bezeichnung,
|
||||
"gemarkung": getattr(obj, "gemarkung", ""),
|
||||
"ort": getattr(obj, "ort", ""),
|
||||
"flaeche_ha": _serialize(getattr(obj, "flaeche_ha", None)),
|
||||
"aktiv": getattr(obj, "aktiv", True),
|
||||
})
|
||||
return json.dumps({"count": len(results), "laendereien": results}, default=_serialize, ensure_ascii=False)
|
||||
|
||||
|
||||
def tool_konten_uebersicht(user) -> str:
|
||||
from stiftung.models import StiftungsKonto
|
||||
role = _get_role(user)
|
||||
konten = StiftungsKonto.objects.all().order_by("bezeichnung")
|
||||
results = []
|
||||
for k in konten:
|
||||
item = {
|
||||
"id": str(k.id),
|
||||
"bezeichnung": k.bezeichnung,
|
||||
"bank": getattr(k, "bank", ""),
|
||||
"kontonummer": getattr(k, "kontonummer", ""),
|
||||
"iban": getattr(k, "iban", ""),
|
||||
"aktiv": getattr(k, "aktiv", True),
|
||||
}
|
||||
results.append(_apply_pii(item, "konto", role))
|
||||
return json.dumps({"count": len(results), "konten": results}, default=_serialize, ensure_ascii=False)
|
||||
|
||||
|
||||
def tool_foerderungen_suchen(user, suchbegriff: str = "", status: str = "", limit: int = 20) -> str:
|
||||
from django.db.models import Q
|
||||
from stiftung.models import Foerderung
|
||||
limit = min(limit, 50)
|
||||
qs = Foerderung.objects.select_related("destinataer").all()
|
||||
if suchbegriff:
|
||||
qs = qs.filter(
|
||||
Q(bezeichnung__icontains=suchbegriff)
|
||||
| Q(destinataer__nachname__icontains=suchbegriff)
|
||||
| Q(destinataer__vorname__icontains=suchbegriff)
|
||||
)
|
||||
if status:
|
||||
qs = qs.filter(status=status)
|
||||
qs = qs.order_by("-erstellt_am")[:limit]
|
||||
results = []
|
||||
for obj in qs:
|
||||
results.append({
|
||||
"id": str(obj.id),
|
||||
"bezeichnung": getattr(obj, "bezeichnung", ""),
|
||||
"destinataer": str(obj.destinataer) if obj.destinataer else None,
|
||||
"betrag": _serialize(getattr(obj, "betrag", None)),
|
||||
"status": getattr(obj, "status", ""),
|
||||
"erstellt_am": _serialize(getattr(obj, "erstellt_am", None)),
|
||||
})
|
||||
return json.dumps({"count": len(results), "foerderungen": results}, default=_serialize, ensure_ascii=False)
|
||||
|
||||
|
||||
def tool_verwaltungskosten(user, jahr: int | None = None, limit: int = 20) -> str:
|
||||
from stiftung.models import Verwaltungskosten
|
||||
limit = min(limit, 50)
|
||||
qs = Verwaltungskosten.objects.all()
|
||||
if jahr:
|
||||
qs = qs.filter(datum__year=jahr)
|
||||
qs = qs.order_by("-datum")[:limit]
|
||||
results = []
|
||||
for obj in qs:
|
||||
results.append({
|
||||
"id": str(obj.id),
|
||||
"datum": _serialize(getattr(obj, "datum", None)),
|
||||
"bezeichnung": getattr(obj, "bezeichnung", ""),
|
||||
"betrag": _serialize(getattr(obj, "betrag", None)),
|
||||
"kategorie": getattr(obj, "kategorie", ""),
|
||||
})
|
||||
return json.dumps({"count": len(results), "verwaltungskosten": results}, default=_serialize, ensure_ascii=False)
|
||||
|
||||
|
||||
def tool_termine_anzeigen(user, limit: int = 10) -> str:
|
||||
from django.utils import timezone
|
||||
from stiftung.models import StiftungsKalenderEintrag
|
||||
now = timezone.now().date()
|
||||
qs = StiftungsKalenderEintrag.objects.filter(datum__gte=now).order_by("datum")[:limit]
|
||||
results = []
|
||||
for obj in qs:
|
||||
results.append({
|
||||
"id": str(obj.id),
|
||||
"titel": getattr(obj, "titel", ""),
|
||||
"datum": _serialize(getattr(obj, "datum", None)),
|
||||
"beschreibung": getattr(obj, "beschreibung", ""),
|
||||
"typ": getattr(obj, "typ", ""),
|
||||
})
|
||||
return json.dumps({"count": len(results), "termine": results}, default=_serialize, ensure_ascii=False)
|
||||
|
||||
|
||||
def tool_transaktionen_suchen(user, suchbegriff: str = "", limit: int = 20) -> str:
|
||||
from django.db.models import Q
|
||||
from stiftung.models import BankTransaction
|
||||
limit = min(limit, 50)
|
||||
qs = BankTransaction.objects.all()
|
||||
if suchbegriff:
|
||||
qs = qs.filter(
|
||||
Q(verwendungszweck__icontains=suchbegriff)
|
||||
| Q(auftraggeber__icontains=suchbegriff)
|
||||
)
|
||||
qs = qs.order_by("-buchungsdatum")[:limit]
|
||||
results = []
|
||||
for obj in qs:
|
||||
results.append({
|
||||
"id": str(obj.id),
|
||||
"datum": _serialize(getattr(obj, "buchungsdatum", None)),
|
||||
"betrag": _serialize(getattr(obj, "betrag", None)),
|
||||
"verwendungszweck": getattr(obj, "verwendungszweck", ""),
|
||||
"auftraggeber": getattr(obj, "auftraggeber", ""),
|
||||
})
|
||||
return json.dumps({"count": len(results), "transaktionen": results}, default=_serialize, ensure_ascii=False)
|
||||
|
||||
|
||||
def tool_dashboard(user) -> str:
|
||||
"""Gibt eine Übersicht über Schlüsselkennzahlen zurück."""
|
||||
from stiftung.models import Destinataer, Foerderung, Land, StiftungsKonto
|
||||
try:
|
||||
destinataere_aktiv = Destinataer.objects.filter(aktiv=True).count()
|
||||
destinataere_gesamt = Destinataer.objects.count()
|
||||
laendereien = Land.objects.count()
|
||||
konten = StiftungsKonto.objects.count()
|
||||
foerderungen_offen = Foerderung.objects.filter(status="offen").count() if hasattr(Foerderung, 'objects') else 0
|
||||
return json.dumps({
|
||||
"destinataere_aktiv": destinataere_aktiv,
|
||||
"destinataere_gesamt": destinataere_gesamt,
|
||||
"laendereien": laendereien,
|
||||
"konten": konten,
|
||||
"foerderungen_offen": foerderungen_offen,
|
||||
}, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
return json.dumps({"fehler": str(e)}, ensure_ascii=False)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Tool-Dispatch und Schema
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
TOOL_FUNCTIONS = {
|
||||
"destinataer_suchen": tool_destinataer_suchen,
|
||||
"land_suchen": tool_land_suchen,
|
||||
"konten_uebersicht": tool_konten_uebersicht,
|
||||
"foerderungen_suchen": tool_foerderungen_suchen,
|
||||
"verwaltungskosten": tool_verwaltungskosten,
|
||||
"termine_anzeigen": tool_termine_anzeigen,
|
||||
"transaktionen_suchen": tool_transaktionen_suchen,
|
||||
"dashboard": tool_dashboard,
|
||||
}
|
||||
|
||||
TOOL_SCHEMAS = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "destinataer_suchen",
|
||||
"description": "Sucht Destinatäre (Förderungsempfänger) nach Name oder Status.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"suchbegriff": {"type": "string", "description": "Vor-/Nachname oder Institution"},
|
||||
"aktiv": {"type": "boolean", "description": "true=nur Aktive, false=nur Inaktive"},
|
||||
"limit": {"type": "integer", "description": "Max. Anzahl Ergebnisse (Standard: 20)"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "land_suchen",
|
||||
"description": "Sucht Ländereien (Grundstücke) der Stiftung nach Bezeichnung oder Ort.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"suchbegriff": {"type": "string", "description": "Bezeichnung, Gemarkung oder Ort"},
|
||||
"limit": {"type": "integer", "description": "Max. Anzahl Ergebnisse"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "konten_uebersicht",
|
||||
"description": "Zeigt alle Stiftungskonten mit Bankverbindungen.",
|
||||
"parameters": {"type": "object", "properties": {}},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "foerderungen_suchen",
|
||||
"description": "Sucht Förderungen nach Bezeichnung oder Destinatär.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"suchbegriff": {"type": "string", "description": "Bezeichnung oder Destinatär-Name"},
|
||||
"status": {"type": "string", "description": "Status-Filter (z.B. 'offen', 'genehmigt')"},
|
||||
"limit": {"type": "integer"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "verwaltungskosten",
|
||||
"description": "Listet Verwaltungskosten, optional nach Jahr gefiltert.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"jahr": {"type": "integer", "description": "Filterjahr (z.B. 2025)"},
|
||||
"limit": {"type": "integer"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "termine_anzeigen",
|
||||
"description": "Zeigt bevorstehende Termine und Fristen der Stiftung.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"limit": {"type": "integer", "description": "Max. Anzahl Termine"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "transaktionen_suchen",
|
||||
"description": "Sucht Banktransaktionen nach Verwendungszweck oder Auftraggeber.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"suchbegriff": {"type": "string"},
|
||||
"limit": {"type": "integer"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "dashboard",
|
||||
"description": "Zeigt Schlüsselkennzahlen der Stiftung (Anzahl Destinatäre, Ländereien, Konten etc.).",
|
||||
"parameters": {"type": "object", "properties": {}},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def execute_tool(name: str, arguments: dict, user) -> str:
|
||||
"""Führt ein Tool aus und gibt das Ergebnis als String zurück."""
|
||||
fn = TOOL_FUNCTIONS.get(name)
|
||||
if fn is None:
|
||||
return json.dumps({"fehler": f"Unbekanntes Tool: {name}"}, ensure_ascii=False)
|
||||
try:
|
||||
return fn(user, **arguments)
|
||||
except TypeError as e:
|
||||
logger.warning("Tool %s Parameterfehler: %s", name, e)
|
||||
return json.dumps({"fehler": f"Ungültige Parameter: {e}"}, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
logger.error("Tool %s Fehler: %s", name, e, exc_info=True)
|
||||
return json.dumps({"fehler": f"Tool-Ausführung fehlgeschlagen: {e}"}, ensure_ascii=False)
|
||||
Reference in New Issue
Block a user