This commit is contained in:
2025-11-15 08:42:00 +01:00
parent 47a22ac2a6
commit afe3db934c
48 changed files with 30234 additions and 749 deletions

View File

@@ -0,0 +1,23 @@
# Konfiguration für externes Logging-Modul (MySQL/PostgreSQL über SQLAlchemy)
# Diese Datei folgt dem Schema der Konfigurationen unter
# 00_Globale_Richtlinien/Entworfener_Code/app/config/
# und kann später in die Haupt-App übernommen werden.
#
# Hinweise:
# - Secrets (user/password) sollten via Umgebungsvariablen überschrieben werden:
# LOG_EXT_USER, LOG_EXT_PASSWORD
# - Das Modul unterstützt Fallback ins interne SQLite-Logging, wenn aktiviert.
logging_external:
enabled: false # Modul aktivieren/deaktivieren
type: "postgresql" # "postgresql" | "mysql"
host: "localhost" # DB-Host
port: 5432 # 5432 für PostgreSQL, 3306 für MySQL
user: null # via ENV: LOG_EXT_USER (überschreibt diesen Wert)
password: null # via ENV: LOG_EXT_PASSWORD (überschreibt diesen Wert)
database: "logs" # Datenbankname
sslmode: "prefer" # nur für PostgreSQL relevant: disable|allow|prefer|require|verify-ca|verify-full
pool_size: 5 # Größe des Connection-Pools
connect_timeout: 10 # Verbindungs-Timeout in Sekunden
write_buffer_size: 100 # (reserviert für spätere Batch-Nutzung)
fallback_to_internal_on_error: true # Fallback in internes SQLite-Logging bei Fehlern

View File

@@ -0,0 +1,12 @@
# Konfiguration für internes Logging-Modul (SQLite)
# Diese Datei folgt dem selben Schema wie die Konfigurationen unter 00_Globale_Richtlinien/Entworfener_Code/app/config
# Sie kann später in die Haupt-App übernommen werden; Werte sind Default-Beispiele.
logging_internal:
enabled: true # Modul aktivieren/deaktivieren
db_path: "data/internal_logs.sqlite" # relativ zu app/
clean_database: false # Datenbank beim Start bereinigen (vorsichtig einsetzen)
retention_days: 30 # Aufbewahrungsdauer in Tagen
max_entries: 100000 # Max. Anzahl an Logeinträgen
vacuum_on_start: true # VACUUM nach Start/Cleanup
batch_write: 100 # zukünftige Batch-Größe (derzeit optional)

View File

@@ -0,0 +1,416 @@
"""
Agenten-API FastAPI Router für Aufgabenverarbeitung.
"""
from __future__ import annotations
import logging
import os
import threading
import time
import uuid
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional, TypedDict
import yaml
from fastapi import APIRouter, HTTPException, status
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class ExecutionSettings:
"""Konfiguration der Ausführungspipeline."""
mode: str = "async"
queue_ttl_seconds: int = 300
heartbeat_interval_seconds: int = 10
response_timeout_seconds: int = 30
@dataclass(frozen=True)
class LLMSettings:
"""Konfiguration des LLM-Clients."""
provider: str = "local_stub"
model: str = "local-agent"
api_base_url: Optional[str] = None
api_key: Optional[str] = None
request_timeout_seconds: int = 15
@dataclass(frozen=True)
class AgentAPIConfig:
"""Gebündelte Agenten-API Konfiguration."""
execution: ExecutionSettings
llm: LLMSettings
class TaskRequest(BaseModel):
"""Eingangsmodell für eine Agenten-Aufgabe."""
user_input: str = Field(..., description="Auslöser der Task in natürlicher Sprache.")
context: Optional[Dict[str, Any]] = Field(default=None, description="Optionale Kontextdaten des Aufrufs.")
metadata: Optional[Dict[str, Any]] = Field(default=None, description="Zusätzliche Metadaten zur Korrelation.")
sync: Optional[bool] = Field(default=None, description="Erzwingt synchrone Verarbeitung, falls gesetzt.")
class TaskData(TypedDict):
"""Interne Darstellung eines Tasks im Speicher."""
task_id: str
status: str
result: Optional[Dict[str, Any]]
error: Optional[str]
created_at: datetime
updated_at: datetime
expires_at: datetime
last_heartbeat: datetime
retry_after: int
request: TaskRequest
def _utcnow() -> datetime:
"""Gibt die aktuelle UTC-Zeit zurück."""
return datetime.now(timezone.utc)
def _to_iso(dt: datetime) -> str:
"""Formatiert Datumswerte als ISO8601-String mit Z-Suffix."""
return dt.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
def _positive_int(value: Any, default: int) -> int:
"""Stellt sicher, dass ein Integer positiv ist, andernfalls gilt der Default."""
try:
candidate = int(value)
if candidate > 0:
return candidate
except (TypeError, ValueError):
pass
return default
def _candidate_config_paths(module_path: Path) -> List[Path]:
"""Ermittelt potenzielle Konfigurationspfade gemäß Projektstandard."""
candidates: List[Path] = []
local_app_path = module_path.parent.parent
candidates.append(local_app_path / "config" / "agent_api.yaml")
for parent in module_path.parents:
candidate = parent / "00_Globale_Richtlinien" / "Entworfener_Code" / "app" / "config" / "agent_api.yaml"
if candidate not in candidates:
candidates.append(candidate)
return candidates
def _load_agent_config() -> AgentAPIConfig:
"""Lädt die Agenten-Konfiguration und führt sie mit Defaults zusammen."""
module_path = Path(__file__).resolve()
config_data: Dict[str, Any] = {}
for path in _candidate_config_paths(module_path):
if not path.exists():
continue
try:
with path.open("r", encoding="utf-8") as handle:
loaded = yaml.safe_load(handle) or {}
config_data = loaded
logger.debug("Agenten-API-Konfiguration aus %s geladen.", path)
break
except Exception as exc:
logger.warning("Fehler beim Laden der Agenten-API-Konfiguration aus %s: %s", path, exc)
execution_data = config_data.get("execution", {})
llm_data = config_data.get("llm", {})
mode = str(execution_data.get("mode", "async")).lower()
if mode not in {"async", "sync"}:
mode = "async"
execution = ExecutionSettings(
mode=mode,
queue_ttl_seconds=_positive_int(execution_data.get("queue_ttl_seconds"), default=300),
heartbeat_interval_seconds=_positive_int(execution_data.get("heartbeat_interval_seconds"), default=10),
response_timeout_seconds=_positive_int(execution_data.get("response_timeout_seconds"), default=30),
)
env_api_key = os.getenv("AGENT_API_LLM_KEY")
api_key = env_api_key if env_api_key else llm_data.get("api_key")
llm = LLMSettings(
provider=str(llm_data.get("provider", "local_stub")),
model=str(llm_data.get("model", "local-agent")),
api_base_url=llm_data.get("api_base_url"),
api_key=str(api_key) if api_key else None,
request_timeout_seconds=_positive_int(llm_data.get("request_timeout_seconds"), default=15),
)
return AgentAPIConfig(execution=execution, llm=llm)
CONFIG = _load_agent_config()
class TaskRegistry:
"""Thread-sichere Verwaltung der In-Memory-Tasks."""
def __init__(self) -> None:
self._tasks: Dict[str, TaskData] = {}
self._lock = threading.Lock()
def create(self, request: TaskRequest, ttl_seconds: int, retry_after: int) -> TaskData:
"""Erzeugt eine neue Task-Struktur und speichert sie."""
now = _utcnow()
task: TaskData = {
"task_id": str(uuid.uuid4()),
"status": "processing",
"result": None,
"error": None,
"created_at": now,
"updated_at": now,
"expires_at": now + timedelta(seconds=ttl_seconds),
"last_heartbeat": now,
"retry_after": max(retry_after, 1),
"request": request,
}
with self._lock:
self._tasks[task["task_id"]] = task
return task.copy()
def get(self, task_id: str) -> Optional[TaskData]:
"""Liefert eine Kopie der Task und aktualisiert Expirationen."""
with self._lock:
task = self._tasks.get(task_id)
if task is None:
return None
self._expire_task_locked(task)
return task.copy()
def get_request(self, task_id: str) -> Optional[TaskRequest]:
"""Liefert das ursprüngliche Request-Objekt."""
with self._lock:
task = self._tasks.get(task_id)
if task is None:
return None
return task["request"]
def heartbeat(self, task_id: str) -> None:
"""Aktualisiert den Heartbeat einer laufenden Task."""
with self._lock:
task = self._tasks.get(task_id)
if task is None or task["status"] != "processing":
return
now = _utcnow()
task["last_heartbeat"] = now
task["updated_at"] = now
def mark_succeeded(self, task_id: str, result: Dict[str, Any], ttl_seconds: int) -> Optional[TaskData]:
"""Markiert eine Task als abgeschlossen."""
with self._lock:
task = self._tasks.get(task_id)
if task is None:
return None
if task["status"] in {"cancelled", "expired"}:
return task.copy()
now = _utcnow()
task["status"] = "succeeded"
task["result"] = result
task["error"] = None
task["updated_at"] = now
task["last_heartbeat"] = now
task["expires_at"] = now + timedelta(seconds=ttl_seconds)
return task.copy()
def mark_failed(self, task_id: str, error: str, ttl_seconds: int) -> Optional[TaskData]:
"""Markiert eine Task als fehlgeschlagen."""
with self._lock:
task = self._tasks.get(task_id)
if task is None:
return None
now = _utcnow()
task["status"] = "failed"
task["result"] = {"error": error}
task["error"] = error
task["updated_at"] = now
task["last_heartbeat"] = now
task["expires_at"] = now + timedelta(seconds=ttl_seconds)
return task.copy()
def cancel(self, task_id: str) -> Optional[TaskData]:
"""Bricht eine Task ab und markiert sie als cancelled."""
with self._lock:
task = self._tasks.get(task_id)
if task is None:
return None
now = _utcnow()
if task["status"] in {"succeeded", "failed", "expired", "cancelled"}:
self._expire_task_locked(task)
return task.copy()
task["status"] = "cancelled"
task["error"] = "cancelled by client"
task["result"] = None
task["updated_at"] = now
task["last_heartbeat"] = now
task["expires_at"] = now
return task.copy()
def _expire_task_locked(self, task: TaskData) -> None:
"""Setzt den Status auf expired, falls das Ablaufdatum erreicht ist."""
now = _utcnow()
if now < task["expires_at"] or task["status"] == "expired":
return
task["status"] = "expired"
task["error"] = "expired"
task["updated_at"] = now
REGISTRY = TaskRegistry()
def _local_stub_result(request: TaskRequest) -> Dict[str, Any]:
"""Lokale Fallback-Implementierung ohne externe LLM-Abhängigkeit."""
return {
"output": f"{request.user_input} [local_stub]",
"provider": "local_stub",
"metadata": {
"mode": CONFIG.execution.mode,
"context_present": bool(request.context),
"metadata_present": bool(request.metadata),
},
}
def _invoke_llm(request: TaskRequest) -> Dict[str, Any]:
"""Ruft das konfigurierte LLM auf oder fällt auf die Stub-Implementierung zurück."""
api_key = CONFIG.llm.api_key
base_url = CONFIG.llm.api_base_url
if not api_key or not base_url:
return _local_stub_result(request)
try:
import httpx # type: ignore
except ModuleNotFoundError:
logger.warning("httpx nicht verfügbar, fallback auf lokalen Stub.")
return _local_stub_result(request)
headers: Dict[str, str] = {}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
payload = {
"model": CONFIG.llm.model,
"input": request.user_input,
"context": request.context or {},
"metadata": request.metadata or {},
}
timeout = CONFIG.llm.request_timeout_seconds
try:
with httpx.Client(timeout=timeout) as client:
response = client.post(str(base_url), json=payload, headers=headers)
response.raise_for_status()
data = response.json()
except Exception as exc:
logger.warning("LLM-Request fehlgeschlagen (%s), fallback auf Stub.", exc)
return _local_stub_result(request)
return {
"output": data.get("output") or data.get("result") or data,
"provider": CONFIG.llm.provider,
"metadata": {
"source": "llm",
"model": CONFIG.llm.model,
},
}
def _execute_request(request: TaskRequest) -> Dict[str, Any]:
"""Führt Vorverarbeitung aus und delegiert an das LLM."""
# Simulierter Pre-Processing-Schritt sowie Heartbeat-Update
time.sleep(0.01)
return _invoke_llm(request)
def _process_task(task_id: str) -> None:
"""Hintergrundverarbeitung einer Task."""
request = REGISTRY.get_request(task_id)
if request is None:
return
try:
REGISTRY.heartbeat(task_id)
result = _execute_request(request)
REGISTRY.heartbeat(task_id)
REGISTRY.mark_succeeded(task_id, result, CONFIG.execution.queue_ttl_seconds)
except Exception as exc:
logger.exception("Fehler bei der Bearbeitung von Task %s", task_id)
REGISTRY.mark_failed(task_id, str(exc), CONFIG.execution.queue_ttl_seconds)
def _serialize_task(task: TaskData, include_result: bool = True, include_detail: bool = True) -> Dict[str, Any]:
"""Konvertiert den internen Task-Status in eine API-Antwort."""
payload: Dict[str, Any] = {
"task_id": task["task_id"],
"status": task["status"],
"expires_at": _to_iso(task["expires_at"]),
}
if task["status"] == "processing":
payload["retry_after"] = task["retry_after"]
if include_result and task["result"] is not None and task["status"] in {"succeeded", "failed"}:
payload["result"] = task["result"]
if include_detail and task.get("error"):
payload["detail"] = task["error"]
if include_detail and task["status"] == "cancelled" and "detail" not in payload:
payload["detail"] = "cancelled"
return payload
agent_router = APIRouter(prefix="/agent/v1", tags=["agent-api"])
@agent_router.post("/tasks")
def submit_task(request: TaskRequest) -> JSONResponse:
"""Erstellt eine neue Task und verarbeitet sie je nach Modus synchron oder asynchron."""
effective_mode = CONFIG.execution.mode
if request.sync is not None:
effective_mode = "sync" if request.sync else "async"
ttl = CONFIG.execution.queue_ttl_seconds
retry_after = CONFIG.execution.heartbeat_interval_seconds
task = REGISTRY.create(request, ttl_seconds=ttl, retry_after=retry_after)
task_id = task["task_id"]
worker = threading.Thread(
target=_process_task,
args=(task_id,),
name=f"agent-task-{task_id}",
daemon=True,
)
worker.start()
if effective_mode == "sync":
worker.join(timeout=CONFIG.execution.response_timeout_seconds)
final_task = REGISTRY.get(task_id)
if final_task is None:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="task_not_found")
if final_task["status"] == "processing":
content = {
"task_id": task_id,
"status": "processing",
"detail": "still_running",
"expires_at": _to_iso(final_task["expires_at"]),
"retry_after": final_task["retry_after"],
}
return JSONResponse(status_code=status.HTTP_408_REQUEST_TIMEOUT, content=content)
return JSONResponse(status_code=status.HTTP_200_OK, content=_serialize_task(final_task))
return JSONResponse(status_code=status.HTTP_202_ACCEPTED, content=_serialize_task(task, include_result=False, include_detail=False))
@agent_router.get("/tasks/{task_id}")
def get_task_status(task_id: str) -> Dict[str, Any]:
"""Liefert den Status einer bestehenden Task."""
task = REGISTRY.get(task_id)
if task is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="task_not_found")
return _serialize_task(task)
@agent_router.post("/tasks/{task_id}/cancel")
def cancel_task(task_id: str) -> Dict[str, Any]:
"""Bricht eine laufende Task ab."""
task = REGISTRY.cancel(task_id)
if task is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="task_not_found")
return _serialize_task(task)
__all__ = ["agent_router"]

View File

@@ -1,6 +0,0 @@
"""Initialisierungspaket für das Agenten-API-Modul."""
from __future__ import annotations
from .router import agent_router
__all__ = ["agent_router"]

View File

@@ -1,130 +0,0 @@
"""Konfigurationslader für das Agenten-API-Modul."""
from __future__ import annotations
import copy
import os
import threading
from pathlib import Path
from typing import Any, Dict, Mapping, cast
import yaml
_ENV_LLM_KEY = "AGENT_API_LLM_KEY"
_DEFAULT_CONFIG: Dict[str, Any] = {
"agent_api": {
"metadata": {
"version": "0.1.0",
"description": "Agent Gateway für Worker + OpenAI-kompatible LLMs",
},
"http": {
"base_path": "/api/agent/v1",
"timeout_seconds": 60,
"rate_limit_per_minute": 120,
"enable_cors": True,
},
"auth": {
"api_key_header": "x-agent-api-key",
"allowed_keys": [],
"allow_unauthenticated": True,
},
"worker": {
"adapter": "inline",
"endpoint": None,
"timeout_seconds": 30,
},
"llm": {
"provider": "openai",
"base_url": "https://api.openai.com/v1",
"model": "gpt-4o-mini",
"temperature": 0.2,
"max_tokens": 1_200,
"api_key": None,
"request_timeout_seconds": 45,
"retry": {
"max_attempts": 3,
"backoff_seconds": 2,
},
},
"execution": {
"mode": "async",
"response_timeout_seconds": 30,
"queue_ttl_seconds": 300,
"heartbeat_interval_seconds": 10,
"allow_long_polling": True,
},
"logging": {
"enabled": True,
"log_payloads": False,
"redact_fields": ["user_input", "llm_response"],
},
}
}
_config_cache: Dict[str, Any] | None = None
_CACHE_LOCK = threading.Lock()
def _config_path() -> Path:
base_dir = Path(__file__).resolve().parents[2]
return base_dir / "config" / "agent_api.yaml"
def _load_file(path: Path) -> Dict[str, Any]:
if not path.exists():
return {}
with path.open("r", encoding="utf-8") as handle:
raw_data: Any = yaml.safe_load(handle) or {}
if not isinstance(raw_data, dict):
raise ValueError(f"Ungültiges Konfigurationsformat in {path}")
return cast(Dict[str, Any], raw_data)
def _deep_merge(base: Dict[str, Any], override: Mapping[str, Any]) -> Dict[str, Any]:
merged: Dict[str, Any] = copy.deepcopy(base)
for key, value in override.items():
if (
key in merged
and isinstance(merged[key], dict)
and isinstance(value, Mapping)
):
merged[key] = _deep_merge(merged[key], value) # type: ignore[arg-type]
else:
merged[key] = copy.deepcopy(value)
return merged
def _apply_env_overrides(config: Dict[str, Any]) -> None:
api_key = os.getenv(_ENV_LLM_KEY)
if api_key:
config.setdefault("agent_api", {}).setdefault("llm", {})["api_key"] = api_key
def _load_config_uncached() -> Dict[str, Any]:
file_data = _load_file(_config_path())
merged = _deep_merge(_DEFAULT_CONFIG, file_data)
_apply_env_overrides(merged)
return merged
def get_config(force_reload: bool = False) -> Dict[str, Any]:
"""
Liefert die Agenten-API-Konfiguration als Dictionary.
Args:
force_reload: Wenn True, wird die Datei erneut eingelesen.
Returns:
Dict[str, Any]: Zusammengeführte Konfiguration aus Defaults,
YAML-Datei und ENV-Overrides.
"""
global _config_cache
if force_reload:
with _CACHE_LOCK:
_config_cache = _load_config_uncached()
return copy.deepcopy(_config_cache)
if _config_cache is None:
with _CACHE_LOCK:
if _config_cache is None:
_config_cache = _load_config_uncached()
return copy.deepcopy(_config_cache)

View File

@@ -1,113 +0,0 @@
"""HTTP-basierter LLM-Client für das Agenten-API-Modul."""
from __future__ import annotations
import json
import time
from typing import Any, Dict, Mapping, Optional
import httpx
from .config import get_config
class LLMClient:
"""
Kapselt Aufrufe an ein OpenAI-kompatibles REST-Interface.
Die Implementierung unterstützt einfache Retries, Timeout-Handling sowie
einen deterministischen Stub, falls kein API-Key konfiguriert wurde.
"""
def __init__(self, config: Optional[Mapping[str, Any]] = None) -> None:
cfg = dict(config or get_config())
agent_cfg = cfg.get("agent_api", {})
llm_cfg: Dict[str, Any] = dict(agent_cfg.get("llm", {}))
self.base_url: str = str(llm_cfg.get("base_url", "https://api.openai.com/v1")).rstrip("/")
self.model: str = str(llm_cfg.get("model", "gpt-4o-mini"))
self.temperature: float = float(llm_cfg.get("temperature", 0.2))
self.max_tokens: int = int(llm_cfg.get("max_tokens", 1_200))
self.api_key: Optional[str] = llm_cfg.get("api_key")
self.request_timeout_seconds: float = float(llm_cfg.get("request_timeout_seconds", 45))
retry_cfg: Dict[str, Any] = dict(llm_cfg.get("retry", {}))
self.max_attempts: int = max(1, int(retry_cfg.get("max_attempts", 3)))
self.backoff_seconds: float = float(retry_cfg.get("backoff_seconds", 2))
def generate(self, prompt: Dict[str, Any]) -> Dict[str, Any]:
"""
Sendet den Prompt an den konfigurierten LLM-Endpunkt oder liefert eine Stub-Antwort.
Args:
prompt: Strukturierter Prompt, erzeugt durch den WorkerAdapter.
Returns:
Dict[str, Any]: Antwort-Payload, der den OpenAI-ähnlichen Response widerspiegelt.
"""
if not self.api_key:
return self._local_stub(prompt)
url = f"{self.base_url}/chat/completions"
payload = {
"model": self.model,
"messages": self._build_messages(prompt),
"temperature": self.temperature,
"max_tokens": self.max_tokens,
}
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
}
last_error: Optional[BaseException] = None
for attempt in range(1, self.max_attempts + 1):
try:
with httpx.Client(timeout=self.request_timeout_seconds) as client:
response = client.post(url, headers=headers, json=payload)
response.raise_for_status()
return response.json()
except (httpx.HTTPError, httpx.TimeoutException) as exc:
last_error = exc
if attempt >= self.max_attempts:
break
time.sleep(self.backoff_seconds)
if last_error:
raise RuntimeError(f"LLM request failed after {self.max_attempts} attempts") from last_error
raise RuntimeError("LLM request failed unexpectedly without additional context")
@staticmethod
def _build_messages(prompt: Mapping[str, Any]) -> Any:
user_input = str(prompt.get("user_input", ""))
context = prompt.get("context") or {}
metadata = prompt.get("metadata") or {}
parts = [
{"role": "system", "content": json.dumps({"context": context, "metadata": metadata})},
{"role": "user", "content": user_input},
]
return parts
@staticmethod
def _local_stub(prompt: Mapping[str, Any]) -> Dict[str, Any]:
user_input = prompt.get("user_input", "")
return {
"id": "local_stub_completion",
"object": "chat.completion",
"created": int(time.time()),
"model": "local-stub",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": f"[local_stub] Echo: {user_input}",
},
"finish_reason": "stop",
}
],
"usage": {
"prompt_tokens": len(json.dumps(prompt)),
"completion_tokens": len(str(user_input)),
"total_tokens": len(json.dumps(prompt)) + len(str(user_input)),
},
"meta": {"note": "local_stub"},
}

View File

@@ -1,49 +0,0 @@
"""Pydantic-Modelle für das Agenten-API-Modul."""
from __future__ import annotations
from datetime import datetime
from typing import Any, Dict, Optional
from pydantic import BaseModel, Field
class TaskCreate(BaseModel):
"""
Repräsentiert den Payload zur Erstellung eines neuen Agenten-Tasks.
"""
user_input: str = Field(..., description="Freitext-Anfrage oder Prompt des Nutzers.")
context: Optional[Dict[str, Any]] = Field(
None, description="Optionaler Kontext mit zusätzlichen strukturierten Daten."
)
metadata: Optional[Dict[str, Any]] = Field(
None, description="Beliebige Metadaten zur Nachverfolgung von Tasks."
)
sync: Optional[bool] = Field(
None,
description=(
"Optionaler Override, um den Task synchron zu verarbeiten, "
"selbst wenn die Ausführung standardmäßig asynchron erfolgt."
),
)
class TaskStatusResponse(BaseModel):
"""
Antwortmodell für den Status eines bestehenden Tasks.
"""
task_id: str = Field(..., description="Eindeutige Kennung des Tasks.")
status: str = Field(..., description="Aktueller Status des Tasks.")
result: Optional[Dict[str, Any]] = Field(
None,
description="Ergebnisdaten des Tasks, sobald verfügbar.",
)
expires_at: Optional[datetime] = Field(
None,
description="Zeitpunkt, an dem der Task automatisch ausläuft.",
)
retry_after: Optional[int] = Field(
None,
description="Empfohlene Wartezeit in Sekunden für den nächsten Statusabruf.",
)

View File

@@ -1,88 +0,0 @@
"""FastAPI-Router für das Agenten-API-Modul."""
from __future__ import annotations
from typing import Dict, Optional
from fastapi import APIRouter, HTTPException, Response, status
from fastapi.responses import JSONResponse
from .models import TaskCreate
from .service import AgentService
agent_router = APIRouter(prefix="/agent/v1", tags=["agent_api"])
_service = AgentService()
def _build_response(
payload: Dict[str, Optional[str]],
status_code: int,
retry_after: Optional[int] = None,
) -> JSONResponse:
headers = {}
if retry_after is not None:
headers["Retry-After"] = str(retry_after)
return JSONResponse(content=payload, status_code=status_code, headers=headers)
@agent_router.post(
"/tasks",
response_model_exclude_none=True,
summary="Neuen Task einreichen",
)
async def submit_task(task: TaskCreate) -> JSONResponse:
"""
Legt einen neuen Task an und startet die Verarbeitung.
"""
result = _service.submit_task(task)
response_payload = result.response.model_dump()
if result.detail:
response_payload["detail"] = result.detail
return _build_response(response_payload, result.status_code, result.response.retry_after)
@agent_router.get(
"/tasks/{task_id}",
response_model_exclude_none=True,
summary="Status eines Tasks abrufen",
)
async def get_status(task_id: str) -> JSONResponse:
"""
Liefert den Status eines Tasks.
"""
status_map = {
"processing": status.HTTP_202_ACCEPTED,
"succeeded": status.HTTP_200_OK,
"failed": status.HTTP_500_INTERNAL_SERVER_ERROR,
"cancelled": status.HTTP_409_CONFLICT,
"expired": status.HTTP_410_GONE,
}
try:
status_response = _service.get_status(task_id)
except KeyError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
status_code = status_map.get(status_response.status, status.HTTP_200_OK)
payload = status_response.model_dump()
if status_response.status in {"failed", "cancelled", "expired"}:
payload.setdefault("detail", status_response.status)
return _build_response(payload, status_code, status_response.retry_after)
@agent_router.post(
"/tasks/{task_id}/cancel",
response_model_exclude_none=True,
summary="Laufenden Task abbrechen",
)
async def cancel_task(task_id: str) -> JSONResponse:
"""
Markiert einen laufenden Task als abgebrochen.
"""
try:
status_response = _service.cancel_task(task_id)
except KeyError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
payload = status_response.model_dump()
payload.setdefault("detail", "task_cancelled")
return _build_response(payload, status.HTTP_200_OK, status_response.retry_after)

View File

@@ -1,310 +0,0 @@
"""Service-Schicht für das Agenten-API-Modul."""
from __future__ import annotations
import threading
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, Mapping, Optional
from .config import get_config
from .llm_client import LLMClient
from .models import TaskCreate, TaskStatusResponse
from .worker_adapter import WorkerAdapter
_PROCESSING_STATUS = "processing"
_SUCCEEDED_STATUS = "succeeded"
_FAILED_STATUS = "failed"
_CANCELLED_STATUS = "cancelled"
_EXPIRED_STATUS = "expired"
@dataclass
class _TaskEntry:
payload: TaskCreate
status: str = _PROCESSING_STATUS
result: Optional[Dict[str, Any]] = None
created_at: datetime = field(
default_factory=lambda: datetime.now(timezone.utc)
)
expires_at: datetime = field(
default_factory=lambda: datetime.now(timezone.utc) + timedelta(minutes=5)
)
last_heartbeat: datetime = field(
default_factory=lambda: datetime.now(timezone.utc)
)
detail: Optional[str] = None
retry_after: Optional[int] = None
error: Optional[str] = None
def to_response(self, task_id: str) -> TaskStatusResponse:
return TaskStatusResponse(
task_id=task_id,
status=self.status,
result=self.result,
expires_at=self.expires_at if self.expires_at.tzinfo else self.expires_at.replace(tzinfo=timezone.utc),
retry_after=self.retry_after,
)
@dataclass
class SubmitResult:
response: TaskStatusResponse
status_code: int
detail: Optional[str] = None
class AgentService:
"""
Koordiniert die Lebenszyklen von Tasks, Worker-Adapter und LLM-Client.
"""
def __init__(
self,
*,
worker_adapter: Optional[WorkerAdapter] = None,
llm_client: Optional[LLMClient] = None,
) -> None:
self._lock = threading.RLock()
self._tasks: Dict[str, _TaskEntry] = {}
self._worker = worker_adapter or WorkerAdapter()
self._llm_client = llm_client or LLMClient()
# ------------------------------------------------------------------ #
# Öffentliche API
# ------------------------------------------------------------------ #
def submit_task(self, payload: TaskCreate) -> SubmitResult:
"""
Legt einen neuen Task an und triggert die Verarbeitung entsprechend
der konfigurierten Ausführungsstrategie.
"""
config = self._current_config()
execution_cfg = self._execution_config(config)
queue_ttl = int(execution_cfg.get("queue_ttl_seconds", 300))
retry_after = int(execution_cfg.get("heartbeat_interval_seconds", 10))
timeout_seconds = float(execution_cfg.get("response_timeout_seconds", 30))
default_mode = str(execution_cfg.get("mode", "async")).lower()
mode = default_mode
if payload.sync is True:
mode = "sync"
elif payload.sync is False:
mode = "async"
now = datetime.now(timezone.utc)
task_id = str(uuid.uuid4())
entry = _TaskEntry(
payload=payload,
status=_PROCESSING_STATUS,
created_at=now,
expires_at=now + timedelta(seconds=queue_ttl),
last_heartbeat=now,
retry_after=retry_after,
)
with self._lock:
self._tasks[task_id] = entry
thread = threading.Thread(
target=self.process_task,
args=(task_id,),
name=f"agent-task-{task_id}",
daemon=True,
)
thread.start()
if mode == "async":
return SubmitResult(
response=entry.to_response(task_id),
status_code=202,
detail=None,
)
# synchroner Modus
thread.join(timeout_seconds)
if thread.is_alive():
return SubmitResult(
response=self._task_response(task_id),
status_code=408,
detail="still_running",
)
entry = self._task_entry(task_id)
if entry.status == _SUCCEEDED_STATUS:
return SubmitResult(
response=entry.to_response(task_id),
status_code=200,
detail=None,
)
if entry.status == _FAILED_STATUS:
return SubmitResult(
response=entry.to_response(task_id),
status_code=500,
detail=entry.detail or "task_failed",
)
if entry.status == _CANCELLED_STATUS:
return SubmitResult(
response=entry.to_response(task_id),
status_code=409,
detail=entry.detail or "task_cancelled",
)
if entry.status == _EXPIRED_STATUS:
return SubmitResult(
response=entry.to_response(task_id),
status_code=410,
detail=entry.detail or "task_expired",
)
return SubmitResult(
response=entry.to_response(task_id),
status_code=202,
detail=None,
)
def process_task(self, task_id: str) -> None:
"""
Führt den Worker- und LLM-Workflow für einen gegebenen Task aus.
"""
try:
entry = self._task_entry(task_id)
except KeyError:
return
if entry.status in {_CANCELLED_STATUS, _EXPIRED_STATUS}:
return
self.update_heartbeat(task_id)
try:
prepared = self._worker.pre_process(entry.payload)
prompt = self._worker.run_task(prepared)
llm_response = self._llm_client.generate(prompt)
result_payload = {
"prepared_prompt": prompt,
"llm_response": llm_response,
}
now = datetime.now(timezone.utc)
with self._lock:
stored = self._tasks.get(task_id)
if stored is None or stored.status in {_CANCELLED_STATUS, _EXPIRED_STATUS}:
return
stored.status = _SUCCEEDED_STATUS
stored.result = result_payload
stored.last_heartbeat = now
stored.detail = None
stored.retry_after = None
except Exception as exc: # pragma: no cover - Fehlerpfad
now = datetime.now(timezone.utc)
with self._lock:
stored = self._tasks.get(task_id)
if stored is None:
return
if stored.status not in {_CANCELLED_STATUS, _EXPIRED_STATUS}:
stored.status = _FAILED_STATUS
stored.result = {"error": str(exc)}
stored.detail = exc.__class__.__name__
stored.error = repr(exc)
stored.last_heartbeat = now
stored.retry_after = None
finally:
self._finalize_task(task_id)
def get_status(self, task_id: str) -> TaskStatusResponse:
"""
Liefert den aktuellen Status eines Tasks.
"""
entry = self._task_entry(task_id)
self._evaluate_expiration(task_id, entry)
return self._task_response(task_id)
def cancel_task(self, task_id: str) -> TaskStatusResponse:
"""
Markiert einen laufenden Task als abgebrochen.
"""
entry = self._task_entry(task_id)
with self._lock:
if entry.status in {_SUCCEEDED_STATUS, _FAILED_STATUS, _EXPIRED_STATUS}:
return entry.to_response(task_id)
entry.status = _CANCELLED_STATUS
entry.result = entry.result or {"detail": "Task cancelled by client"}
entry.detail = "task_cancelled"
entry.last_heartbeat = datetime.now(timezone.utc)
entry.retry_after = None
return self._task_response(task_id)
def update_heartbeat(self, task_id: str) -> None:
"""
Aktualisiert den Heartbeat eines Tasks und verlängert dessen TTL.
"""
entry = self._task_entry(task_id)
config = self._current_config()
execution_cfg = self._execution_config(config)
queue_ttl = int(execution_cfg.get("queue_ttl_seconds", 300))
now = datetime.now(timezone.utc)
with self._lock:
stored = self._tasks.get(task_id)
if stored is None:
return
stored.last_heartbeat = now
stored.expires_at = now + timedelta(seconds=queue_ttl)
# ------------------------------------------------------------------ #
# Interne Hilfsfunktionen
# ------------------------------------------------------------------ #
def _task_entry(self, task_id: str) -> _TaskEntry:
with self._lock:
entry = self._tasks.get(task_id)
if entry is None:
raise KeyError(f"Task '{task_id}' not found")
return entry
def _task_response(self, task_id: str) -> TaskStatusResponse:
entry = self._task_entry(task_id)
return entry.to_response(task_id)
def _finalize_task(self, task_id: str) -> None:
entry = self._task_entry(task_id)
self._evaluate_expiration(task_id, entry)
def _evaluate_expiration(self, task_id: str, entry: _TaskEntry) -> None:
now = datetime.now(timezone.utc)
if entry.status in {_SUCCEEDED_STATUS, _FAILED_STATUS, _CANCELLED_STATUS}:
return
if now > entry.expires_at:
with self._lock:
stored = self._tasks.get(task_id)
if stored is None:
return
stored.status = _EXPIRED_STATUS
stored.result = stored.result or {"detail": "Task expired"}
stored.detail = "task_expired"
stored.last_heartbeat = now
stored.retry_after = None
@staticmethod
def _current_config() -> Dict[str, Any]:
return get_config()
@staticmethod
def _execution_config(config: Mapping[str, Any]) -> Mapping[str, Any]:
agent_cfg = config.get("agent_api", {})
return agent_cfg.get("execution", {})
# ------------------------------------------------------------------ #
# Debug-/Test-Hilfen
# ------------------------------------------------------------------ #
def clear_tasks(self) -> None:
"""
Löscht alle gemerkten Tasks. Hilfreich für Tests.
"""
with self._lock:
self._tasks.clear()
def list_tasks(self) -> Dict[str, _TaskEntry]:
"""
Gibt einen Snapshot der aktuellen Tasks zurück.
"""
with self._lock:
return dict(self._tasks)

View File

@@ -1,50 +0,0 @@
"""Worker-Adapter für das Agenten-API-Modul."""
from __future__ import annotations
import datetime as _dt
from typing import Any, Dict
from .models import TaskCreate
class WorkerAdapter:
"""
Einfacher Inline-Worker-Adapter.
Die Implementierung dient als Stub für spätere Erweiterungen. Sie nimmt den
eingehenden Payload entgegen, reichert diesen mit Metadaten an und liefert
einen Prompt für den LLM-Client zurück.
"""
@staticmethod
def pre_process(payload: TaskCreate) -> Dict[str, Any]:
"""
Bereitet den Task-Payload für den LLM-Aufruf vor.
Args:
payload: Valider TaskCreate-Payload.
Returns:
Dict[str, Any]: Strukturierter Prompt inklusive Metadaten.
"""
timestamp = _dt.datetime.now(_dt.timezone.utc)
prepared_prompt: Dict[str, Any] = {
"user_input": payload.user_input,
"context": payload.context or {},
"metadata": payload.metadata or {},
"created_at": timestamp.isoformat(),
}
return prepared_prompt
@staticmethod
def run_task(prepared: Dict[str, Any]) -> Dict[str, Any]:
"""
Führt optionale Worker-Logik aus und liefert den Prompt für den LLM-Client.
Args:
prepared: Vom WorkerAdapter.pre_process erzeugter Prompt.
Returns:
Dict[str, Any]: Prompt für den LLM-Client.
"""
return prepared