diff --git a/admin-compliance/app/api/sdk/v1/specialist-agent/[[...path]]/route.ts b/admin-compliance/app/api/sdk/v1/specialist-agent/[[...path]]/route.ts new file mode 100644 index 00000000..1ffbc8dc --- /dev/null +++ b/admin-compliance/app/api/sdk/v1/specialist-agent/[[...path]]/route.ts @@ -0,0 +1,112 @@ +/** + * Specialist-Agent API Proxy + * Proxies /api/sdk/v1/specialist-agent/* → backend-compliance:8002/api/v1/specialist-agent/* + * + * Streaming routes (SSE /test/stream/{run_id}) pass through unmodified. + */ + +import { NextRequest, NextResponse } from 'next/server' + +const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002' + +async function proxyRequest( + request: NextRequest, + pathSegments: string[] | undefined, + method: string, +) { + const pathStr = pathSegments?.join('/') || '' + const searchParams = request.nextUrl.searchParams.toString() + const basePath = `${BACKEND_URL}/api/v1/specialist-agent` + const url = pathStr + ? `${basePath}/${pathStr}${searchParams ? `?${searchParams}` : ''}` + : `${basePath}${searchParams ? `?${searchParams}` : ''}` + + const isSSE = pathStr.startsWith('test/stream/') + + try { + const headers: HeadersInit = {} + if (!isSSE) headers['Content-Type'] = 'application/json' + + const fetchOptions: RequestInit = { + method, + headers, + signal: AbortSignal.timeout(isSSE ? 600000 : 60000), + } + if (method === 'POST' || method === 'PUT' || method === 'PATCH' || + method === 'DELETE') { + const body = await request.text() + if (body) fetchOptions.body = body + } + + const response = await fetch(url, fetchOptions) + + if (isSSE) { + return new NextResponse(response.body, { + status: response.status, + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + 'X-Accel-Buffering': 'no', + }, + }) + } + + if (!response.ok) { + const errText = await response.text() + let errJson + try { errJson = JSON.parse(errText) } + catch { errJson = { error: errText } } + return NextResponse.json( + { error: `Backend Error: ${response.status}`, ...errJson }, + { status: response.status }, + ) + } + + const ct = response.headers.get('content-type') || '' + if (ct.includes('application/json')) { + const data = await response.json() + return NextResponse.json(data) + } + // Binary asset (image/video/csv etc.) + const blob = await response.blob() + return new NextResponse(blob, { + status: response.status, + headers: { + 'Content-Type': ct || 'application/octet-stream', + 'Content-Disposition': + response.headers.get('content-disposition') || '', + }, + }) + } catch (e) { + console.error('specialist-agent proxy error:', e) + return NextResponse.json( + { error: 'Verbindung zum Backend fehlgeschlagen' }, + { status: 503 }, + ) + } +} + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ path?: string[] }> }, +) { + const { path } = await params + return proxyRequest(request, path, 'GET') +} + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ path?: string[] }> }, +) { + const { path } = await params + return proxyRequest(request, path, 'POST') +} + +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ path?: string[] }> }, +) { + const { path } = await params + return proxyRequest(request, path, 'DELETE') +} diff --git a/admin-compliance/app/sdk/agent/_components/AgentTestTab.tsx b/admin-compliance/app/sdk/agent/_components/AgentTestTab.tsx new file mode 100644 index 00000000..4dc40fde --- /dev/null +++ b/admin-compliance/app/sdk/agent/_components/AgentTestTab.tsx @@ -0,0 +1,440 @@ +'use client' + +import React, { useEffect, useMemo, useRef, useState } from 'react' + +type AgentInfo = { + agent_id: string + agent_version: string + doc_type: string + mc_count: number +} + +type Finding = { + check_id: string + agent: string + agent_version: string + field_id?: string + severity: 'HIGH' | 'MEDIUM' | 'LOW' | 'INFO' + title: string + norm?: string + evidence?: string + action?: string + confidence?: number + sources?: { source_type: string; source_id: string; detail?: string }[] +} + +type Recommendation = { + recommendation_id: string + title: string + body: string + severity: string + related_finding_ids: string[] + estimated_effort_hours: number +} + +type SlotOutput = { + agent: string + agent_version: string + findings: Finding[] + recommendations: Recommendation[] + mc_total: number + mc_ok: number + mc_na: number + mc_high: number + mc_medium: number + mc_low: number + duration_ms: number + confidence: number + escalation_log: { stage: string; model: string; success: boolean; duration_ms: number }[] +} + +type RunResult = { + run_id: string + agent_id: string + finished: boolean + results: Record + vault_url: string +} + +type StreamEvent = { + type: string + slot?: string + [key: string]: any +} + +const STORAGE_KEY = 'agent-test-state-v1' +const MAX_SLOTS = 5 + +export function AgentTestTab() { + const [agents, setAgents] = useState([]) + const [agentId, setAgentId] = useState('') + const [urls, setUrls] = useState(['', '', '', '', '']) + const [running, setRunning] = useState(false) + const [runId, setRunId] = useState('') + const [events, setEvents] = useState([]) + const [result, setResult] = useState(null) + const [error, setError] = useState('') + const eventSrcRef = useRef(null) + + // Restore state from localStorage + useEffect(() => { + try { + const s = localStorage.getItem(STORAGE_KEY) + if (s) { + const parsed = JSON.parse(s) + if (parsed.agentId) setAgentId(parsed.agentId) + if (Array.isArray(parsed.urls)) + setUrls(parsed.urls.slice(0, MAX_SLOTS).concat( + new Array(MAX_SLOTS).fill('')).slice(0, MAX_SLOTS)) + } + } catch { /* noop */ } + }, []) + useEffect(() => { + try { + localStorage.setItem(STORAGE_KEY, + JSON.stringify({ agentId, urls })) + } catch { /* quota */ } + }, [agentId, urls]) + + // Load agents + useEffect(() => { + fetch('/api/sdk/v1/specialist-agent/agents') + .then(r => r.json()) + .then(d => { + const list: AgentInfo[] = d.agents || [] + setAgents(list) + if (list.length && !agentId) setAgentId(list[0].agent_id) + }) + .catch(e => setError(`Agent-Liste fehlgeschlagen: ${e}`)) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + const startTest = async () => { + setError('') + setResult(null) + setEvents([]) + const cleanUrls = urls.map(u => u.trim()).filter(Boolean) + if (!agentId) { setError('Kein Agent ausgewählt.'); return } + if (cleanUrls.length === 0) { setError('Mind. eine URL angeben.'); return } + setRunning(true) + try { + const r = await fetch( + '/api/sdk/v1/specialist-agent/test/start', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + agent_id: agentId, + urls: cleanUrls, + }), + }, + ) + if (!r.ok) { + const j = await r.json().catch(() => ({})) + throw new Error(j.error || `HTTP ${r.status}`) + } + const data = await r.json() + setRunId(data.run_id) + openStream(data.run_id) + pollResult(data.run_id) + } catch (e: any) { + setError(e.message || String(e)) + setRunning(false) + } + } + + const openStream = (rid: string) => { + try { eventSrcRef.current?.close() } catch { /* noop */ } + const es = new EventSource( + `/api/sdk/v1/specialist-agent/test/stream/${rid}`, + ) + eventSrcRef.current = es + es.onmessage = (ev) => { + try { + const data: StreamEvent = JSON.parse(ev.data) + setEvents(prev => [...prev, data]) + if (data.type === 'stream_close' || data.type === 'run_complete') { + try { es.close() } catch { /* noop */ } + } + } catch { /* noop */ } + } + es.onerror = () => { try { es.close() } catch { /* noop */ } } + } + + const pollResult = async (rid: string) => { + for (let i = 0; i < 360; i++) { + try { + const r = await fetch( + `/api/sdk/v1/specialist-agent/run/${rid}/result`, + ) + if (r.ok) { + const d: RunResult = await r.json() + if (d.finished) { + setResult(d); setRunning(false); return + } + } + } catch { /* noop */ } + await new Promise(s => setTimeout(s, 2000)) + } + setRunning(false) + } + + const slotOutputs = useMemo(() => { + if (!result) return [] + const items: { slot: string; output: SlotOutput }[] = [] + for (const slot of Object.keys(result.results)) { + items.push({ slot, output: result.results[slot] }) + } + return items.sort((a, b) => a.slot.localeCompare(b.slot)) + }, [result]) + + const selectedAgent = agents.find(a => a.agent_id === agentId) + + return ( +
+
+

Agent-Test (max. {MAX_SLOTS} URLs)

+

+ Wählt einen Spezialisten-Agent und feuert ihn gegen 1-5 URLs gleichzeitig. + Pro URL Speedometer + Findings + Empfehlungen mit Quellen-Herkunft (MC / Regex / LLM-Stufe). + Keine Aussagen "rechtssicher" oder "garantiert" — alle solchen Wörter werden vor Ausgabe gelöscht. +

+
+
+ + +
+ {selectedAgent && ( +
+ Doc-Type: {selectedAgent.doc_type} +
+ )} +
+
+ {urls.map((u, i) => ( +
+ URL{i+1} + { + const next = [...urls]; next[i] = e.target.value + setUrls(next) + }} + placeholder="https://example.com/impressum" + className="flex-1 border rounded px-2 py-1 text-sm font-mono"/> +
+ ))} +
+
+ + {runId && ( + + Run-ID: {runId} + + )} +
+ {error && ( +
+ {error} +
+ )} +
+ + {running && events.length > 0 && ( +
+
+ {events.slice(-30).map((ev, i) => ( +
+ [{ev.type}]{' '} + {ev.slot && {ev.slot}}{' '} + {ev.severity && ( + + {ev.severity} + + )}{' '} + {ev.title || ev.error || ev.label || ev.model || ev.url || ''} +
+ ))} +
+
+ )} + + {slotOutputs.length > 0 && ( +
+ {slotOutputs.map(({ slot, output }) => ( + + ))} +
+ )} +
+ ) +} + +function SlotCard({ slot, output, runId }: { + slot: string + output: SlotOutput + runId: string +}) { + const [showAll, setShowAll] = useState(false) + const visibleFindings = showAll ? output.findings : output.findings.slice(0, 8) + return ( +
+
+

Slot: {slot}

+ + {output.duration_ms} ms · confidence {(output.confidence * 100).toFixed(0)}% + + + Artefakte ↗ + +
+ + {output.escalation_log.length > 0 && ( +
+ Eskalationen:{' '} + {output.escalation_log.map((e, i) => ( + + {e.stage}/{e.model} {e.success ? '✓' : '✗'} ({e.duration_ms} ms) + + ))} +
+ )} + {output.findings.length > 0 && ( +
+
+ Findings ({output.findings.length}) +
+ {visibleFindings.map(f => ( + + ))} + {output.findings.length > 8 && ( + + )} +
+ )} + {output.recommendations.length > 0 && ( +
+
+ Empfehlungen ({output.recommendations.length}, gerollupt) +
+ {output.recommendations.map(r => ( +
+
{r.title}
+
{r.body}
+
+ {r.related_finding_ids.length} Finding(s) · ~{r.estimated_effort_hours}h +
+
+ ))} +
+ )} +
+ ) +} + +function Speedometer({ total, ok, na, high, medium, low }: { + total: number + ok: number + na: number + high: number + medium: number + low: number +}) { + const safeTotal = Math.max(total, 1) + return ( +
+
{total} MCs geprüft
+
+ + + + + +
+
+ + + + + +
+
+ ) +} + +function Bar({ pct, color }: { pct: number; color: string }) { + return
+} + +function Legend({ color, label }: { color: string; label: string }) { + return ( + + + {label} + + ) +} + +function FindingRow({ f }: { f: Finding }) { + const color = severityHex(f.severity) + const sourceTags = (f.sources || []) + .map(s => s.source_type) + .filter((v, i, arr) => arr.indexOf(v) === i) + return ( +
+
+ {f.severity} + {f.check_id} + {sourceTags.map(t => ( + {t} + ))} +
+
{f.title}
+ {f.norm &&
{f.norm}
} + {f.evidence && ( +
„{f.evidence}"
+ )} + {f.action && ( +
+ → {f.action} +
+ )} +
+ ) +} + +function severityColor(sev: string) { + return sev === 'HIGH' ? 'text-red-600 font-semibold' : + sev === 'MEDIUM' ? 'text-amber-600 font-semibold' : + sev === 'LOW' ? 'text-blue-600' : 'text-gray-600' +} + +function severityHex(sev: string) { + return sev === 'HIGH' ? '#dc2626' : + sev === 'MEDIUM' ? '#f59e0b' : + sev === 'LOW' ? '#3b82f6' : '#94a3b8' +} diff --git a/admin-compliance/app/sdk/agent/page.tsx b/admin-compliance/app/sdk/agent/page.tsx index fd577ef6..821729e1 100644 --- a/admin-compliance/app/sdk/agent/page.tsx +++ b/admin-compliance/app/sdk/agent/page.tsx @@ -5,13 +5,15 @@ import { ScanResult } from './_components/ScanResult' import { ComplianceCheckTab } from './_components/ComplianceCheckTab' import { BannerCheckTab } from './_components/BannerCheckTab' import { ComplianceFAQ } from './_components/ComplianceFAQ' +import { AgentTestTab } from './_components/AgentTestTab' -type AnalysisTab = 'scan' | 'compliance-check' | 'banner-check' +type AnalysisTab = 'scan' | 'compliance-check' | 'banner-check' | 'agent-test' const TABS: { id: AnalysisTab; label: string; desc: string }[] = [ { id: 'scan', label: 'Website-Scan', desc: 'Rechtliche Dokumente finden + Dienstleister erkennen' }, { id: 'compliance-check', label: 'Compliance-Check', desc: 'Alle rechtlichen Dokumente zusammen pruefen' }, { id: 'banner-check', label: 'Banner-Check', desc: 'Cookie-Banner auf DSGVO-Konformitaet testen' }, + { id: 'agent-test', label: 'Agent-Test', desc: 'Specialist-Agent gegen 5 URLs isoliert testen' }, ] export default function AgentPage() { @@ -186,6 +188,7 @@ export default function AgentPage() { {tab === 'compliance-check' && } {tab === 'banner-check' && } + {tab === 'agent-test' && }
diff --git a/backend-compliance/compliance/api/__init__.py b/backend-compliance/compliance/api/__init__.py index 316b168a..525ace22 100644 --- a/backend-compliance/compliance/api/__init__.py +++ b/backend-compliance/compliance/api/__init__.py @@ -74,6 +74,7 @@ _ROUTER_MODULES = [ "founding_wizard_routes", "licenses_routes", "template_rule_routes", + "specialist_agent_routes", ] _loaded_count = 0 diff --git a/backend-compliance/compliance/api/specialist_agent_routes.py b/backend-compliance/compliance/api/specialist_agent_routes.py new file mode 100644 index 00000000..0ffb5af9 --- /dev/null +++ b/backend-compliance/compliance/api/specialist_agent_routes.py @@ -0,0 +1,378 @@ +"""SSE-Endpoint für den Agent-Test-Harness. + +User-Vorgabe 2026-06-08: pro Agent isoliert testen mit z.B. 5 URLs +gleichzeitig. Live-Stream der Events ins Frontend. + +Endpoints: + GET /specialist-agent/agents + POST /specialist-agent/test/start { agent_id, urls } + GET /specialist-agent/test/stream/{run_id} → SSE-Stream + GET /specialist-agent/run/{run_id}/artifacts + GET /specialist-agent/run/{run_id}/artifact/{relpath} +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import os +import uuid +from collections.abc import AsyncGenerator +from typing import Any + +import httpx +from fastapi import APIRouter, HTTPException +from fastapi.responses import FileResponse, StreamingResponse +from pydantic import BaseModel, Field + +from compliance.services.specialist_agents import REGISTRY, AgentInput +from compliance.services.specialist_agents._evidence_vault import ( + EvidenceVault, + delete_run as vault_delete_run, + list_runs as vault_list_runs, +) + +logger = logging.getLogger(__name__) + +CONSENT_TESTER_URL = os.environ.get( + "CONSENT_TESTER_URL", + "http://bp-compliance-consent-tester:8094", +) + +router = APIRouter(prefix="/specialist-agent", tags=["specialist-agent"]) + + +# In-memory event-queues pro run_id. Restart-fragil aber für ein +# Live-Test-Tool ausreichend (keine Persistenz nötig). +_run_queues: dict[str, asyncio.Queue] = {} +_run_states: dict[str, dict[str, Any]] = {} + + +class TestStartRequest(BaseModel): + agent_id: str + urls: list[str] = Field(default_factory=list, max_length=10) + raw_texts: list[str] = Field(default_factory=list, max_length=10) + business_scope: list[str] = Field(default_factory=list) + company_name: str = "" + origin_domain: str = "" + + +class TestStartResponse(BaseModel): + run_id: str + agent_id: str + slot_count: int + + +@router.get("/agents") +async def list_agents() -> dict[str, Any]: + """Liefert die registrierten Specialist-Agenten.""" + return {"agents": REGISTRY.list_agents()} + + +@router.post("/test/start", response_model=TestStartResponse) +async def start_test(req: TestStartRequest) -> TestStartResponse: + """Startet einen Multi-URL-Test gegen einen Agent. + + Liefert eine run_id zurück. Der Frontend-Client öffnet danach + einen SSE-Stream auf /test/stream/{run_id} um Events zu empfangen. + """ + agent = REGISTRY.get(req.agent_id) + if agent is None: + raise HTTPException(404, f"agent '{req.agent_id}' nicht registriert") + slots = max(len(req.urls), len(req.raw_texts)) + if slots == 0: + raise HTTPException(400, "urls oder raw_texts dürfen nicht leer sein") + + run_id = uuid.uuid4().hex[:16] + queue: asyncio.Queue = asyncio.Queue(maxsize=500) + _run_queues[run_id] = queue + _run_states[run_id] = { + "agent_id": req.agent_id, + "started": False, + "finished": False, + "slot_count": slots, + "results": {}, + } + vault = EvidenceVault(agent.agent_id, agent.agent_version, + run_id=run_id) + asyncio.create_task(_run_test_orchestrator(run_id, req, vault)) + return TestStartResponse( + run_id=run_id, + agent_id=req.agent_id, + slot_count=slots, + ) + + +@router.get("/test/stream/{run_id}") +async def stream_test(run_id: str) -> StreamingResponse: + """SSE-Stream der Events für einen laufenden Test.""" + if run_id not in _run_queues: + raise HTTPException(404, "run_id unbekannt") + return StreamingResponse( + _event_generator(run_id), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "X-Accel-Buffering": "no", # nginx + "Connection": "keep-alive", + }, + ) + + +async def _event_generator(run_id: str) -> AsyncGenerator[str, None]: + """Reads events from the queue until the run is finished.""" + queue = _run_queues[run_id] + # Initial hello + yield _format_sse({"type": "hello", "run_id": run_id}) + try: + while True: + try: + event = await asyncio.wait_for(queue.get(), timeout=30.0) + except asyncio.TimeoutError: + # heartbeat + yield _format_sse({"type": "heartbeat"}) + if _run_states.get(run_id, {}).get("finished"): + yield _format_sse({"type": "stream_close"}) + return + continue + yield _format_sse(event) + if event.get("type") in ("run_complete", "run_error"): + yield _format_sse({"type": "stream_close"}) + return + finally: + # Defer cleanup: keep state for 5 min so late GETs can read + # results from _run_states. The queue can be released earlier. + asyncio.get_event_loop().call_later( + 300, lambda: _run_queues.pop(run_id, None), + ) + + +def _format_sse(payload: dict) -> str: + """SSE event line format.""" + return f"data: {json.dumps(payload, default=str)}\n\n" + + +async def _emit(run_id: str, event: dict) -> None: + q = _run_queues.get(run_id) + if q is None: + return + try: + await q.put(event) + except Exception: + pass + + +async def _run_test_orchestrator( + run_id: str, + req: TestStartRequest, + vault: EvidenceVault, +) -> None: + """Kernlogik: pro URL / raw_text parallel den Agent feuern.""" + agent = REGISTRY.get(req.agent_id) + if agent is None: + await _emit(run_id, {"type": "run_error", + "error": "agent gone"}) + return + _run_states[run_id]["started"] = True + await _emit(run_id, { + "type": "run_started", + "agent_id": agent.agent_id, + "agent_version": agent.agent_version, + "slot_count": _run_states[run_id]["slot_count"], + }) + slot_jobs: list[asyncio.Task] = [] + # URLs first, then raw_texts. Slots numbered url1, url2, …, text1, … + for i, url in enumerate(req.urls, start=1): + slot = f"url{i}" + slot_jobs.append(asyncio.create_task( + _process_slot(run_id, slot, agent, url, "", req, vault), + )) + for j, raw in enumerate(req.raw_texts, start=1): + slot = f"text{j}" + slot_jobs.append(asyncio.create_task( + _process_slot(run_id, slot, agent, "", raw, req, vault), + )) + try: + await asyncio.gather(*slot_jobs, return_exceptions=True) + finally: + manifest = vault.finalize() + _run_states[run_id]["finished"] = True + await _emit(run_id, { + "type": "run_complete", + "vault_url": vault.url(), + "manifest_asset_count": len(manifest.get("assets") or []), + }) + + +async def _process_slot( + run_id: str, + slot: str, + agent, + url: str, + raw_text: str, + req: TestStartRequest, + vault: EvidenceVault, +) -> None: + """Holt den Text (URL oder raw), ruft Agent, vault-speichert Output.""" + label = url or f"text-slot-{slot}" + await _emit(run_id, {"type": "slot_started", "slot": slot, + "label": label}) + text = raw_text + fetch_err = "" + if url and not raw_text: + await _emit(run_id, {"type": "slot_fetching", + "slot": slot, "url": url}) + text, fetch_err = await _fetch_text(url) + if fetch_err: + await _emit(run_id, { + "type": "slot_fetch_error", + "slot": slot, + "error": fetch_err, + }) + if text: + vault.put_bytes("raw", slot, "source.txt", + text.encode("utf-8"), + mime="text/plain") + await _emit(run_id, { + "type": "slot_text_ready", + "slot": slot, + "char_count": len(text), + }) + agent_input = AgentInput( + doc_type=agent.doc_type, + text=text, + url=url, + business_scope=req.business_scope, + company_name=req.company_name, + origin_domain=req.origin_domain, + ) + await _emit(run_id, {"type": "slot_agent_running", "slot": slot}) + try: + output = await agent.evaluate(agent_input) + except Exception as e: + logger.exception("agent crashed slot=%s", slot) + await _emit(run_id, { + "type": "slot_agent_error", "slot": slot, + "error": f"{type(e).__name__}: {str(e)[:160]}", + }) + return + # Persist findings as JSON in vault + vault.put_json("finding", slot, "output.json", + json.loads(output.model_dump_json())) + # Update state for later /artifacts query + _run_states[run_id]["results"][slot] = json.loads( + output.model_dump_json(), + ) + # Stream finding-emitted events + for f in output.findings: + await _emit(run_id, { + "type": "finding", + "slot": slot, + "check_id": f.check_id, + "severity": f.severity, + "title": f.title, + "field_id": f.field_id, + }) + for esc in output.escalation_log: + await _emit(run_id, { + "type": "escalation", + "slot": slot, + "stage": esc.stage, + "model": esc.model, + "success": esc.success, + "duration_ms": esc.duration_ms, + }) + await _emit(run_id, { + "type": "slot_complete", + "slot": slot, + "duration_ms": output.duration_ms, + "mc_total": output.mc_total, + "mc_ok": output.mc_ok, + "mc_na": output.mc_na, + "mc_high": output.mc_high, + "mc_medium": output.mc_medium, + "mc_low": output.mc_low, + "findings_count": len(output.findings), + "recommendations_count": len(output.recommendations), + "confidence": output.confidence, + }) + + +async def _fetch_text(url: str) -> tuple[str, str]: + """Nutzt den consent-tester DSI-Discovery für Volltext.""" + try: + async with httpx.AsyncClient(timeout=120.0) as client: + resp = await client.post( + f"{CONSENT_TESTER_URL}/dsi-discovery", + json={"url": url, "max_documents": 5}, + timeout=120.0, + ) + if resp.status_code != 200: + return "", f"HTTP {resp.status_code}" + data = resp.json() + docs = data.get("documents", []) or [] + if not docs: + return "", "no documents discovered" + texts: list[str] = [] + for doc in docs: + t = (doc.get("full_text", "") or + doc.get("text_preview", "") or "") + if t and len(t) > 50: + texts.append(t) + return "\n\n".join(texts), "" + except Exception as e: + return "", f"{type(e).__name__}: {str(e)[:160]}" + + +# ── Run / Vault Queries ────────────────────────────────────────────── + + +@router.get("/run/{run_id}/result") +async def get_run_result(run_id: str) -> dict[str, Any]: + """Komplette Ergebnisse eines Runs (für Frontend-Refresh).""" + state = _run_states.get(run_id) + if state is None: + raise HTTPException(404, "run unbekannt") + return { + "run_id": run_id, + "agent_id": state["agent_id"], + "finished": state["finished"], + "results": state["results"], + "vault_url": f"/api/v1/specialist-agent/run/{run_id}/artifacts", + } + + +@router.get("/run/{run_id}/artifacts") +async def list_run_artifacts(run_id: str) -> dict[str, Any]: + """Listet die Assets eines Runs.""" + vault = EvidenceVault("?", "?", run_id=run_id) + return { + "run_id": run_id, + "manifest": vault._manifest, + } + + +@router.get("/run/{run_id}/artifact/{path:path}") +async def get_run_artifact(run_id: str, path: str): + """Liefert ein einzelnes Artefakt aus dem Vault.""" + vault = EvidenceVault("?", "?", run_id=run_id) + p = vault.asset_path(path) + if p is None: + raise HTTPException(404, "asset not found") + return FileResponse(str(p)) + + +@router.delete("/run/{run_id}") +async def delete_run(run_id: str) -> dict[str, bool]: + """DSR Art. 17: löscht den ganzen Run + Vault.""" + deleted_vault = vault_delete_run(run_id) + _run_queues.pop(run_id, None) + _run_states.pop(run_id, None) + return {"deleted": deleted_vault} + + +@router.get("/runs") +async def list_runs(limit: int = 20) -> dict[str, Any]: + """Listet die letzten Runs im Vault.""" + return {"runs": vault_list_runs(limit)} diff --git a/backend-compliance/tests/test_specialist_agent_routes.py b/backend-compliance/tests/test_specialist_agent_routes.py new file mode 100644 index 00000000..7af94aa4 --- /dev/null +++ b/backend-compliance/tests/test_specialist_agent_routes.py @@ -0,0 +1,129 @@ +"""Tests für SSE-Endpoints des Specialist-Agent-Test-Harness.""" + +from __future__ import annotations + +import asyncio +import json +from unittest.mock import AsyncMock, patch + +import pytest +from fastapi.testclient import TestClient + + +@pytest.fixture +def app(tmp_path, monkeypatch): + monkeypatch.setenv("EVIDENCE_VAULT_ROOT", str(tmp_path / "vault")) + from fastapi import FastAPI + from compliance.api.specialist_agent_routes import router + app = FastAPI() + app.include_router(router, prefix="/api/v1") + return app + + +@pytest.fixture +def client(app): + return TestClient(app) + + +def test_list_agents(client): + r = client.get("/api/v1/specialist-agent/agents") + assert r.status_code == 200 + data = r.json() + agent_ids = {a["agent_id"] for a in data["agents"]} + assert "impressum" in agent_ids + assert "cookie_policy" in agent_ids + + +def test_start_test_invalid_agent(client): + r = client.post("/api/v1/specialist-agent/test/start", + json={"agent_id": "ghost", + "raw_texts": ["test"]}) + assert r.status_code == 404 + + +def test_start_test_no_input(client): + r = client.post("/api/v1/specialist-agent/test/start", + json={"agent_id": "impressum"}) + assert r.status_code == 400 + + +def test_start_test_with_raw_text(client): + r = client.post("/api/v1/specialist-agent/test/start", + json={"agent_id": "impressum", + "raw_texts": ["Tesla Germany GmbH " + "Berlin Email: x@y.com " + "HRB 123 Charlottenburg"]}) + assert r.status_code == 200 + data = r.json() + assert data["agent_id"] == "impressum" + assert data["slot_count"] == 1 + assert data["run_id"] + + +def test_stream_unknown_run(client): + r = client.get("/api/v1/specialist-agent/test/stream/ghost") + assert r.status_code == 404 + + +def test_run_result_after_text_input(client, monkeypatch): + # Skip LLM + async def _no_cascade(*a, **kw): return None, [] + monkeypatch.setattr( + "compliance.services.specialist_agents.impressum.agent.cascade", + _no_cascade, + ) + r = client.post("/api/v1/specialist-agent/test/start", + json={"agent_id": "impressum", + "raw_texts": [ + "Tesla Germany GmbH\nLudwig-Prandtl-Strasse 25\n" + "12526 Berlin\nDeutschland\nEmail: x@y.com\n" + "Tel: +49 89 1250 16 800\n" + "Management: Elon Musk\n" + "HRB 218904 B Charlottenburg", + ]}) + run_id = r.json()["run_id"] + # Give async task time to finish (small text → fast) + for _ in range(40): + rr = client.get( + f"/api/v1/specialist-agent/run/{run_id}/result", + ) + if rr.json().get("finished"): + break + import time; time.sleep(0.05) + res = client.get(f"/api/v1/specialist-agent/run/{run_id}/result") + body = res.json() + assert body["finished"] + assert "text1" in body["results"] + out = body["results"]["text1"] + field_ids = {f["field_id"] for f in out["findings"]} + # Tesla pattern: German-label fehlt + USt fehlt + assert "vertretungsberechtigte_label_korrekt" in field_ids + + +def test_artifacts_listing(client, monkeypatch): + async def _no_cascade(*a, **kw): return None, [] + monkeypatch.setattr( + "compliance.services.specialist_agents.impressum.agent.cascade", + _no_cascade, + ) + r = client.post("/api/v1/specialist-agent/test/start", + json={"agent_id": "impressum", + "raw_texts": ["Tesla Germany GmbH " + "Berlin Email: x@y.com " + "HRB 123 Charlottenburg"]}) + run_id = r.json()["run_id"] + for _ in range(40): + rr = client.get( + f"/api/v1/specialist-agent/run/{run_id}/result", + ) + if rr.json().get("finished"): + break + import time; time.sleep(0.05) + arts = client.get( + f"/api/v1/specialist-agent/run/{run_id}/artifacts", + ) + assert arts.status_code == 200 + manifest = arts.json()["manifest"] + kinds = {a["kind"] for a in manifest["assets"]} + assert "finding" in kinds + assert "raw" in kinds