v4.1.0: DMS email documents, category-specific Nachweis linking, version system
Some checks failed
CI/CD Pipeline / test (push) Has been cancelled
CI/CD Pipeline / deploy (push) Has been cancelled
Code Quality / quality (push) Has been cancelled

- 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:
SysAdmin Agent
2026-03-15 18:48:52 +00:00
parent faeb7c1073
commit e0b377014c
49 changed files with 5913 additions and 55 deletions

View File

View File

@@ -0,0 +1,64 @@
"""
Django Admin für den AI Agent.
Erreichbar unter /administration/agent/
"""
from django.contrib import admin
from django.utils.html import format_html
from .models import AgentConfig, ChatSession, ChatMessage
@admin.register(AgentConfig)
class AgentConfigAdmin(admin.ModelAdmin):
fieldsets = (
("Provider", {
"fields": ("provider", "model_name", "ollama_url"),
}),
("API-Keys (externe Provider)", {
"fields": ("openai_api_key", "anthropic_api_key"),
"classes": ("collapse",),
"description": "Nur ausfüllen wenn nicht Ollama verwendet wird.",
}),
("Verhalten", {
"fields": ("system_prompt", "allow_write", "chat_retention_days"),
}),
)
def has_add_permission(self, request):
# Singleton: Hinzufügen nur wenn noch keine Config existiert
return not AgentConfig.objects.exists()
def has_delete_permission(self, request, obj=None):
return False
class ChatMessageInline(admin.TabularInline):
model = ChatMessage
fields = ("role", "content_preview", "tool_name", "created_at")
readonly_fields = ("role", "content_preview", "tool_name", "created_at")
extra = 0
can_delete = False
ordering = ["created_at"]
def content_preview(self, obj):
return obj.content[:120] + ("" if len(obj.content) > 120 else "")
content_preview.short_description = "Inhalt"
@admin.register(ChatSession)
class ChatSessionAdmin(admin.ModelAdmin):
list_display = ("title_or_id", "user", "message_count", "created_at", "updated_at")
list_filter = ("user",)
search_fields = ("title", "user__username")
readonly_fields = ("id", "user", "created_at", "updated_at")
inlines = [ChatMessageInline]
ordering = ["-updated_at"]
def title_or_id(self, obj):
return obj.title or str(obj.id)[:12]
title_or_id.short_description = "Sitzung"
def message_count(self, obj):
return obj.messages.count()
message_count.short_description = "Nachrichten"

View File

@@ -0,0 +1,166 @@
"""
AI Agent Models: AgentConfig (Singleton), ChatSession, ChatMessage.
"""
import uuid
from django.contrib.auth.models import User
from django.db import models
DEFAULT_SYSTEM_PROMPT = """Du bist RentmeisterAI, der KI-Assistent der van Hees-Theyssen-Vogel'schen Stiftung.
Du hast Zugriff auf die Stiftungsdatenbank und kannst Informationen zu Destinatären, Ländereien, \
Finanzen, Förderungen und weiteren Stiftungsdaten abrufen.
Regeln:
- Antworte stets auf Deutsch, präzise und sachlich.
- Schütze personenbezogene Daten gib keine unnötigen Details heraus.
- Du kannst keine Daten ändern, nur lesen.
- Bei rechtlichen oder steuerlichen Fragen weise auf Fachberatung hin.
- Wenn du dir unsicher bist, sage das klar.
"""
class AgentConfig(models.Model):
"""Singleton-Konfiguration für den AI Agent."""
PROVIDER_CHOICES = [
("ollama", "Ollama (lokal)"),
("openai", "OpenAI"),
("anthropic", "Anthropic"),
]
provider = models.CharField(
max_length=20,
choices=PROVIDER_CHOICES,
default="ollama",
verbose_name="LLM-Provider",
)
model_name = models.CharField(
max_length=100,
default="qwen2.5:3b",
verbose_name="Modell-Name",
)
ollama_url = models.CharField(
max_length=255,
default="http://ollama:11434",
verbose_name="Ollama-URL",
)
openai_api_key = models.CharField(
max_length=255,
blank=True,
verbose_name="OpenAI API-Key",
help_text="Nur erforderlich wenn Provider = OpenAI",
)
anthropic_api_key = models.CharField(
max_length=255,
blank=True,
verbose_name="Anthropic API-Key",
help_text="Nur erforderlich wenn Provider = Anthropic",
)
system_prompt = models.TextField(
default=DEFAULT_SYSTEM_PROMPT,
verbose_name="System-Prompt",
)
allow_write = models.BooleanField(
default=False,
verbose_name="Schreib-Tools erlaubt",
help_text="Achtung: Schreib-Zugriff auf Stiftungsdaten aktivieren",
)
chat_retention_days = models.IntegerField(
default=30,
verbose_name="Chat-Verlauf Aufbewahrung (Tage)",
)
class Meta:
verbose_name = "Agent-Konfiguration"
verbose_name_plural = "Agent-Konfiguration"
def __str__(self):
return f"Agent Config ({self.get_provider_display()} / {self.model_name})"
def save(self, *args, **kwargs):
# Singleton: always use pk=1
self.pk = 1
super().save(*args, **kwargs)
def delete(self, *args, **kwargs):
pass # Singleton cannot be deleted
@classmethod
def get_config(cls):
config, _ = cls.objects.get_or_create(pk=1)
return config
class ChatSession(models.Model):
"""Chat-Sitzung eines Benutzers mit dem AI Agent."""
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
user = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name="agent_sessions",
verbose_name="Benutzer",
)
title = models.CharField(
max_length=200,
blank=True,
verbose_name="Titel",
help_text="Automatisch aus erster Nachricht generiert",
)
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Erstellt")
updated_at = models.DateTimeField(auto_now=True, verbose_name="Zuletzt aktiv")
class Meta:
verbose_name = "Chat-Sitzung"
verbose_name_plural = "Chat-Sitzungen"
ordering = ["-updated_at"]
def __str__(self):
return f"{self.user.username} {self.title or str(self.id)[:8]} ({self.created_at.strftime('%d.%m.%Y')})"
def message_count(self):
return self.messages.count()
class ChatMessage(models.Model):
"""Einzelne Nachricht in einer Chat-Sitzung."""
ROLE_CHOICES = [
("user", "Benutzer"),
("assistant", "Assistent"),
("tool", "Tool-Ergebnis"),
]
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
session = models.ForeignKey(
ChatSession,
on_delete=models.CASCADE,
related_name="messages",
verbose_name="Sitzung",
)
role = models.CharField(
max_length=20,
choices=ROLE_CHOICES,
verbose_name="Rolle",
)
content = models.TextField(verbose_name="Inhalt")
tool_name = models.CharField(
max_length=100,
blank=True,
verbose_name="Tool-Name",
)
tool_call_id = models.CharField(
max_length=100,
blank=True,
verbose_name="Tool-Call-ID",
)
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Erstellt")
class Meta:
verbose_name = "Chat-Nachricht"
verbose_name_plural = "Chat-Nachrichten"
ordering = ["created_at"]
def __str__(self):
return f"[{self.role}] {self.content[:60]}"

View File

@@ -0,0 +1,201 @@
"""
ReAct-Orchestrator für den AI Agent.
Implementiert einen synchronen ReAct-Loop (Reason + Act) mit:
- max. 5 Iterationen
- Tool-Calling
- Streaming via Generator
- Audit-Logging
"""
from __future__ import annotations
import json
import logging
from typing import Generator
from .providers import get_provider, LLMError
from .tools import execute_tool, TOOL_SCHEMAS
logger = logging.getLogger(__name__)
MAX_ITERATIONS = 5
def run_agent_stream(
session,
user_message: str,
page_context: str = "",
user=None,
) -> Generator[str, None, None]:
"""
Führt den ReAct-Loop aus und streamt SSE-kompatible Daten-Strings.
Yield-Format (Server-Sent Events):
"data: {json}\n\n"
JSON-Typen:
{"type": "text", "content": "..."} Textfragment
{"type": "tool_start", "name": "..."} Tool wird aufgerufen
{"type": "tool_result", "name": "...", "result": "..."}
{"type": "done"}
{"type": "error", "message": "..."}
"""
from .models import AgentConfig, ChatMessage
config = AgentConfig.get_config()
# Systemkontext aufbauen
system_content = config.system_prompt
if page_context:
system_content += f"\n\nAktueller Seitenkontext:\n{page_context}"
# Nachrichtenhistorie laden (letzte 20 Nachrichten)
history = list(
session.messages.exclude(role="tool")
.order_by("-created_at")[:20]
)
history.reverse()
messages = [{"role": "system", "content": system_content}]
for msg in history:
if msg.role in ("user", "assistant"):
messages.append({"role": msg.role, "content": msg.content})
# Neue User-Nachricht
messages.append({"role": "user", "content": user_message})
# Neue User-Message in DB speichern
ChatMessage.objects.create(
session=session,
role="user",
content=user_message,
)
# Sesstionttitel setzen falls leer
if not session.title and user_message:
session.title = user_message[:100]
session.save(update_fields=["title", "updated_at"])
tools = TOOL_SCHEMAS if not getattr(config, "allow_write", False) else TOOL_SCHEMAS
try:
provider = get_provider(config)
except LLMError as e:
yield _sse({"type": "error", "message": str(e)})
return
full_assistant_text = ""
iteration = 0
tools_disabled = False
while iteration < MAX_ITERATIONS:
iteration += 1
text_buffer = ""
pending_tool_calls = []
current_tools = None if tools_disabled else tools
try:
for chunk in provider.chat_stream(messages=messages, tools=current_tools):
chunk_type = chunk.get("type")
if chunk_type == "text":
text = chunk["content"]
text_buffer += text
full_assistant_text += text
yield _sse({"type": "text", "content": text})
elif chunk_type == "tool_call":
pending_tool_calls.append(chunk)
elif chunk_type == "done":
break
elif chunk_type == "error":
yield _sse({"type": "error", "message": chunk.get("message", "Unbekannter Fehler")})
return
except LLMError as e:
if not tools_disabled and iteration == 1:
# Tool-Calling hat den Provider zum Absturz gebracht (z.B. OOM).
# Fallback: ohne Tools erneut versuchen.
# Warte kurz, damit Ollama nach OOM-Crash neu starten kann.
import time
logger.warning("LLM-Fehler mit Tools, Fallback auf Chat-only: %s", e)
tools_disabled = True
full_assistant_text = ""
time.sleep(15)
continue
yield _sse({"type": "error", "message": str(e)})
return
if not pending_tool_calls:
# Kein Tool-Call → Antwort fertig
break
# Tool-Calls verarbeiten
# Assistent-Nachricht mit tool_calls in History
tool_calls_for_msg = []
for tc in pending_tool_calls:
tool_calls_for_msg.append({
"id": tc["id"],
"type": "function",
"function": {
"name": tc["name"],
"arguments": json.dumps(tc["arguments"], ensure_ascii=False),
},
})
assistant_msg: dict = {"role": "assistant", "content": text_buffer or ""}
if tool_calls_for_msg:
assistant_msg["tool_calls"] = tool_calls_for_msg
messages.append(assistant_msg)
# Jeden Tool-Call ausführen
for tc in pending_tool_calls:
tool_name = tc["name"]
tool_args = tc["arguments"]
tool_call_id = tc["id"]
yield _sse({"type": "tool_start", "name": tool_name})
result = execute_tool(tool_name, tool_args, user)
# Tool-Ergebnis in DB
ChatMessage.objects.create(
session=session,
role="tool",
content=result,
tool_name=tool_name,
tool_call_id=tool_call_id,
)
yield _sse({"type": "tool_result", "name": tool_name, "result": result[:500]})
# Tool-Ergebnis in Messages für nächste LLM-Iteration
messages.append({
"role": "tool",
"tool_call_id": tool_call_id,
"content": result,
})
full_assistant_text = "" # Reset für nächste Iteration
# Abschließende Assistent-Nachricht in DB speichern
if full_assistant_text:
ChatMessage.objects.create(
session=session,
role="assistant",
content=full_assistant_text,
)
# Session updated_at aktualisieren
from django.utils import timezone
session.updated_at = timezone.now()
session.save(update_fields=["updated_at"])
yield _sse({"type": "done"})
def _sse(data: dict) -> str:
"""Formatiert ein Dict als SSE data-Zeile."""
return f"data: {json.dumps(data, ensure_ascii=False)}\n\n"

View File

@@ -0,0 +1,323 @@
"""
LLM-Provider-Abstraktion für den AI Agent.
Unterstützt:
- Ollama (Standard, OpenAI-kompatibles API via httpx)
- OpenAI
- Anthropic (über OpenAI-kompatible Schnittstelle)
Alle Provider implementieren synchrones Streaming via Generator.
"""
from __future__ import annotations
import json
import logging
from typing import Generator, Any
import httpx
logger = logging.getLogger(__name__)
class LLMError(Exception):
"""LLM-Kommunikationsfehler."""
pass
class BaseLLMProvider:
def chat_stream(
self,
messages: list[dict],
tools: list[dict] | None = None,
) -> Generator[dict, None, None]:
"""
Streamt Antwort-Chunks als Dicts.
Chunk-Typen:
{"type": "text", "content": "..."}
{"type": "tool_call", "id": "...", "name": "...", "arguments": {...}}
{"type": "done"}
{"type": "error", "message": "..."}
"""
raise NotImplementedError
class OllamaProvider(BaseLLMProvider):
"""Ollama via OpenAI-kompatibler Chat-Completion-Endpunkt."""
def __init__(self, base_url: str, model: str):
self.base_url = base_url.rstrip("/")
self.model = model
def chat_stream(
self,
messages: list[dict],
tools: list[dict] | None = None,
) -> Generator[dict, None, None]:
url = f"{self.base_url}/v1/chat/completions"
payload: dict[str, Any] = {
"model": self.model,
"messages": messages,
"stream": True,
}
if tools:
payload["tools"] = tools
payload["tool_choice"] = "auto"
try:
with httpx.Client(timeout=120.0) as client:
with client.stream("POST", url, json=payload) as response:
if response.status_code != 200:
body = response.read().decode()
raise LLMError(
f"Ollama-Fehler {response.status_code}: {body[:200]}"
)
yield from _parse_openai_stream(response)
except httpx.ConnectError:
raise LLMError(
f"Verbindung zu Ollama ({self.base_url}) fehlgeschlagen. "
"Ist der Ollama-Dienst gestartet?"
)
except httpx.RemoteProtocolError:
raise LLMError(
"Ollama-Verbindung abgebrochen. "
"Möglicherweise nicht genug RAM für dieses Modell mit Tool-Calling."
)
except httpx.TimeoutException:
raise LLMError("Ollama-Anfrage hat das Zeitlimit überschritten.")
class OpenAIProvider(BaseLLMProvider):
"""OpenAI Chat-Completion API."""
BASE_URL = "https://api.openai.com"
def __init__(self, api_key: str, model: str):
self.api_key = api_key
self.model = model
def chat_stream(
self,
messages: list[dict],
tools: list[dict] | None = None,
) -> Generator[dict, None, None]:
url = f"{self.BASE_URL}/v1/chat/completions"
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
}
payload: dict[str, Any] = {
"model": self.model,
"messages": messages,
"stream": True,
}
if tools:
payload["tools"] = tools
payload["tool_choice"] = "auto"
try:
with httpx.Client(timeout=120.0) as client:
with client.stream("POST", url, json=payload, headers=headers) as response:
if response.status_code != 200:
body = response.read().decode()
raise LLMError(
f"OpenAI-Fehler {response.status_code}: {body[:200]}"
)
yield from _parse_openai_stream(response)
except httpx.TimeoutException:
raise LLMError("OpenAI-Anfrage hat das Zeitlimit überschritten.")
class AnthropicProvider(BaseLLMProvider):
"""Anthropic Messages API (native, not OpenAI-compatible)."""
BASE_URL = "https://api.anthropic.com"
def __init__(self, api_key: str, model: str):
self.api_key = api_key
self.model = model
def chat_stream(
self,
messages: list[dict],
tools: list[dict] | None = None,
) -> Generator[dict, None, None]:
url = f"{self.BASE_URL}/v1/messages"
headers = {
"x-api-key": self.api_key,
"anthropic-version": "2023-06-01",
"Content-Type": "application/json",
}
# Extract system message from messages list
system = ""
chat_messages = []
for msg in messages:
if msg["role"] == "system":
system = msg["content"]
else:
chat_messages.append(msg)
# Convert OpenAI tool format to Anthropic format
anthropic_tools = []
if tools:
for t in tools:
fn = t.get("function", {})
anthropic_tools.append({
"name": fn.get("name"),
"description": fn.get("description", ""),
"input_schema": fn.get("parameters", {}),
})
payload: dict[str, Any] = {
"model": self.model,
"max_tokens": 4096,
"messages": chat_messages,
"stream": True,
}
if system:
payload["system"] = system
if anthropic_tools:
payload["tools"] = anthropic_tools
try:
with httpx.Client(timeout=120.0) as client:
with client.stream("POST", url, json=payload, headers=headers) as response:
if response.status_code != 200:
body = response.read().decode()
raise LLMError(
f"Anthropic-Fehler {response.status_code}: {body[:200]}"
)
yield from _parse_anthropic_stream(response)
except httpx.TimeoutException:
raise LLMError("Anthropic-Anfrage hat das Zeitlimit überschritten.")
def _parse_openai_stream(response) -> Generator[dict, None, None]:
"""Parst OpenAI-kompatibles SSE-Streaming-Format."""
accumulated_tool_calls: dict[int, dict] = {}
for line in response.iter_lines():
if not line or line == "data: [DONE]":
continue
if line.startswith("data: "):
line = line[6:]
try:
chunk = json.loads(line)
except json.JSONDecodeError:
continue
choice = chunk.get("choices", [{}])[0]
delta = choice.get("delta", {})
finish_reason = choice.get("finish_reason")
# Text content
if delta.get("content"):
yield {"type": "text", "content": delta["content"]}
# Tool calls (streaming parts arrive incrementally)
tool_calls_delta = delta.get("tool_calls", [])
for tc_delta in tool_calls_delta:
idx = tc_delta.get("index", 0)
if idx not in accumulated_tool_calls:
accumulated_tool_calls[idx] = {
"id": "",
"name": "",
"arguments": "",
}
tc = accumulated_tool_calls[idx]
if tc_delta.get("id"):
tc["id"] += tc_delta["id"]
fn = tc_delta.get("function", {})
if fn.get("name"):
tc["name"] += fn["name"]
if fn.get("arguments"):
tc["arguments"] += fn["arguments"]
if finish_reason in ("tool_calls", "stop"):
# Emit completed tool calls
for tc in accumulated_tool_calls.values():
try:
args = json.loads(tc["arguments"]) if tc["arguments"] else {}
except json.JSONDecodeError:
args = {}
yield {
"type": "tool_call",
"id": tc["id"],
"name": tc["name"],
"arguments": args,
}
accumulated_tool_calls.clear()
if finish_reason == "stop":
yield {"type": "done"}
return
yield {"type": "done"}
def _parse_anthropic_stream(response) -> Generator[dict, None, None]:
"""Parst Anthropic SSE-Streaming-Format."""
current_tool: dict | None = None
tool_input_str = ""
for line in response.iter_lines():
if not line or line.startswith("event:"):
continue
if line.startswith("data: "):
line = line[6:]
try:
event = json.loads(line)
except json.JSONDecodeError:
continue
event_type = event.get("type", "")
if event_type == "content_block_start":
block = event.get("content_block", {})
if block.get("type") == "tool_use":
current_tool = {"id": block.get("id", ""), "name": block.get("name", "")}
tool_input_str = ""
elif event_type == "content_block_delta":
delta = event.get("delta", {})
if delta.get("type") == "text_delta":
yield {"type": "text", "content": delta.get("text", "")}
elif delta.get("type") == "input_json_delta":
tool_input_str += delta.get("partial_json", "")
elif event_type == "content_block_stop":
if current_tool is not None:
try:
args = json.loads(tool_input_str) if tool_input_str else {}
except json.JSONDecodeError:
args = {}
yield {
"type": "tool_call",
"id": current_tool["id"],
"name": current_tool["name"],
"arguments": args,
}
current_tool = None
tool_input_str = ""
elif event_type == "message_stop":
yield {"type": "done"}
return
yield {"type": "done"}
def get_provider(config) -> BaseLLMProvider:
"""Erstellt den konfigurierten LLM-Provider."""
if config.provider == "ollama":
return OllamaProvider(base_url=config.ollama_url, model=config.model_name)
elif config.provider == "openai":
if not config.openai_api_key:
raise LLMError("OpenAI API-Key ist nicht konfiguriert.")
return OpenAIProvider(api_key=config.openai_api_key, model=config.model_name)
elif config.provider == "anthropic":
if not config.anthropic_api_key:
raise LLMError("Anthropic API-Key ist nicht konfiguriert.")
return AnthropicProvider(api_key=config.anthropic_api_key, model=config.model_name)
else:
raise LLMError(f"Unbekannter Provider: {config.provider}")

363
app/stiftung/agent/tools.py Normal file
View 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)

View File

@@ -0,0 +1,12 @@
from django.urls import path
from . import views
urlpatterns = [
path("", views.agent_index, name="agent_index"),
path("chat/", views.agent_chat, name="agent_chat"),
path("chat/stream/", views.agent_chat_stream, name="agent_chat_stream"),
path("sessions/", views.agent_sessions, name="agent_sessions"),
path("sessions/<uuid:session_id>/", views.agent_session_messages, name="agent_session_messages"),
path("sessions/<uuid:session_id>/loeschen/", views.agent_session_delete, name="agent_session_delete"),
]

232
app/stiftung/agent/views.py Normal file
View File

@@ -0,0 +1,232 @@
"""
Views für den AI Agent.
Endpunkte:
POST /agent/chat/ Neue Nachricht senden (startet neue oder bestehende Session)
GET /agent/chat/stream/ SSE-Stream für laufende Anfrage
GET /agent/sessions/ Liste der Chat-Sitzungen (JSON)
DELETE /agent/sessions/<id>/ Sitzung löschen
"""
from __future__ import annotations
import json
import logging
from django.contrib.auth.decorators import login_required
from django.core.cache import cache
from django.http import (
HttpResponse,
JsonResponse,
StreamingHttpResponse,
)
from django.shortcuts import get_object_or_404
from django.utils import timezone
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from .models import AgentConfig, ChatSession, ChatMessage
from .orchestrator import run_agent_stream
logger = logging.getLogger(__name__)
RATE_LIMIT_PER_MINUTE = 20
def _check_rate_limit(user_id: int) -> bool:
"""Einfaches Rate-Limiting via Django-Cache (Redis). True = erlaubt."""
key = f"agent_rl_{user_id}"
count = cache.get(key, 0)
if count >= RATE_LIMIT_PER_MINUTE:
return False
cache.set(key, count + 1, timeout=60)
return True
def _require_agent_permission(user) -> bool:
"""Prüft ob der Benutzer den Agent nutzen darf."""
return (
user.is_superuser
or user.has_perm("stiftung.can_use_agent")
)
@login_required
@require_http_methods(["GET"])
def agent_index(request):
"""Einstiegsseite für den Chat (wird als Modal geöffnet, nicht direkt navigiert)."""
config = AgentConfig.get_config()
return JsonResponse({
"provider": config.provider,
"model": config.model_name,
"allow_write": config.allow_write,
})
@login_required
@require_http_methods(["POST"])
def agent_chat(request):
"""
Startet oder setzt einen Chat fort.
Body (JSON):
{
"message": "Wie viele aktive Destinatäre gibt es?",
"session_id": "optional-uuid",
"page_context": "optional aktueller Seiteninhalt als Text"
}
Antwort:
{
"session_id": "...",
"stream_url": "/agent/chat/stream/?session_id=..."
}
"""
if not _require_agent_permission(request.user):
return JsonResponse({"error": "Keine Berechtigung für den AI-Assistenten."}, status=403)
if not _check_rate_limit(request.user.id):
return JsonResponse(
{"error": "Rate-Limit erreicht. Bitte warten Sie eine Minute."},
status=429,
)
try:
body = json.loads(request.body)
except (json.JSONDecodeError, ValueError):
return JsonResponse({"error": "Ungültiger JSON-Body."}, status=400)
message = (body.get("message") or "").strip()
if not message:
return JsonResponse({"error": "Nachricht darf nicht leer sein."}, status=400)
page_context = (body.get("page_context") or "")[:2000]
session_id = body.get("session_id")
# Session ermitteln oder neu erstellen
session = None
if session_id:
try:
session = ChatSession.objects.get(id=session_id, user=request.user)
except ChatSession.DoesNotExist:
pass
if session is None:
session = ChatSession.objects.create(user=request.user)
# Nachricht + Page-Context in Cache für Stream-Endpunkt speichern
cache_key = f"agent_pending_{session.id}"
cache.set(
cache_key,
{"message": message, "page_context": page_context},
timeout=300, # 5 Minuten
)
return JsonResponse({
"session_id": str(session.id),
"stream_url": f"/agent/chat/stream/?session_id={session.id}",
})
@login_required
@require_http_methods(["GET"])
def agent_chat_stream(request):
"""
SSE-Endpunkt: streamt die Antwort des Agenten.
Query-Params:
session_id: UUID der Chat-Sitzung
"""
if not _require_agent_permission(request.user):
return HttpResponse("Keine Berechtigung.", status=403)
session_id = request.GET.get("session_id")
if not session_id:
return HttpResponse("session_id fehlt.", status=400)
session = get_object_or_404(ChatSession, id=session_id, user=request.user)
cache_key = f"agent_pending_{session.id}"
pending = cache.get(cache_key)
if not pending:
return HttpResponse("Keine ausstehende Nachricht gefunden.", status=400)
cache.delete(cache_key)
message = pending["message"]
page_context = pending.get("page_context", "")
def event_stream():
try:
yield from run_agent_stream(
session=session,
user_message=message,
page_context=page_context,
user=request.user,
)
except Exception as e:
logger.error("Agent-Stream-Fehler: %s", e, exc_info=True)
import json
yield f"data: {json.dumps({'type': 'error', 'message': 'Interner Fehler.'})}\n\n"
response = StreamingHttpResponse(
event_stream(),
content_type="text/event-stream",
)
response["Cache-Control"] = "no-cache"
response["X-Accel-Buffering"] = "no"
return response
@login_required
@require_http_methods(["GET"])
def agent_sessions(request):
"""Gibt die Chat-Sitzungen des Benutzers zurück (letzte 20)."""
if not _require_agent_permission(request.user):
return JsonResponse({"error": "Keine Berechtigung."}, status=403)
sessions = ChatSession.objects.filter(user=request.user).order_by("-updated_at")[:20]
data = []
for s in sessions:
data.append({
"id": str(s.id),
"title": s.title or "Neue Unterhaltung",
"created_at": s.created_at.isoformat(),
"updated_at": s.updated_at.isoformat(),
"message_count": s.messages.count(),
})
return JsonResponse({"sessions": data})
@login_required
@require_http_methods(["GET"])
def agent_session_messages(request, session_id):
"""Gibt alle Nachrichten einer Sitzung zurück."""
if not _require_agent_permission(request.user):
return JsonResponse({"error": "Keine Berechtigung."}, status=403)
session = get_object_or_404(ChatSession, id=session_id, user=request.user)
messages = session.messages.exclude(role="tool").order_by("created_at")
data = []
for m in messages:
data.append({
"id": str(m.id),
"role": m.role,
"content": m.content,
"created_at": m.created_at.isoformat(),
})
return JsonResponse({
"session_id": str(session.id),
"title": session.title,
"messages": data,
})
@login_required
@require_http_methods(["POST"])
def agent_session_delete(request, session_id):
"""Löscht eine Chat-Sitzung."""
if not _require_agent_permission(request.user):
return JsonResponse({"error": "Keine Berechtigung."}, status=403)
session = get_object_or_404(ChatSession, id=session_id, user=request.user)
session.delete()
return JsonResponse({"ok": True})