'use client' /** * AgentTestTab — Top-Level für den 5-URL-Test eines Specialist-Agents. * Sections: * 1. Agent-Wähler + 5 URL-Slots + Start-Button * 2. Methodik-Erklärung (was wir tun, warum) * 3. Live-Event-Log * 4. Pro Slot: SlotCard (siehe AgentSlotCard.tsx) */ import React, { useEffect, useMemo, useRef, useState } from 'react' import type { AgentInfo, RunResult, SlotOutput, StreamEvent } from './_agentTypes' import { AgentSlotCard } from './AgentSlotCard' 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)) { const padded = [...parsed.urls.slice(0, MAX_SLOTS), ...new Array(MAX_SLOTS).fill('')].slice(0, MAX_SLOTS) setUrls(padded) } } } 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 (
{running && events.length > 0 && } {slotOutputs.length > 0 && (
{slotOutputs.map(({ slot, output }) => ( ))}
)}
) } function InputCard({ agents, agentId, setAgentId, selectedAgent, urls, setUrls, running, runId, startTest, error, }: { agents: AgentInfo[] agentId: string setAgentId: (s: string) => void selectedAgent?: AgentInfo urls: string[] setUrls: (urls: string[]) => void running: boolean runId: string startTest: () => void error: string }) { return (

Agent-Test (max. {MAX_SLOTS} URLs)

{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}
)}
) } function MethodikInfo() { return (
Methodik — wie geprüft wird
  1. Pattern-Checks — deterministische Regex-Tests gegen Pflichtangaben-Schema (z.B. § 5 TMG/DDG). Schnell, reproduzierbar. Hinweis: diese Pattern-IDs (z.B. IMP-MC-001) sind interne Test-IDs, nicht die Master-Control-IDs aus der Datenbank. BreakPilot hat 313k Atomic-Controls → 13.588 dedup. Master-Controls; davon ~1.778 für dieses Compliance-Agent-Tool ausgewählt. Die formale Verknüpfung Pattern-Check → Master-Control folgt in einem späteren Schritt (Sprint 1.12).
  2. Knowledge-Base — kuratierte Patterns aus anonymisierten Mandanten-FAQs.
  3. Auto-Learning-Pattern-Library — Labels die der LLM-Validator gefunden hat (z.B. „Telefonnr." statt „Telefon") werden persistiert. Beim nächsten Run sind sie deterministisch erkennbar — der LLM wird seltener gerufen.
  4. Semantic-Validator (LLM) — nur bei missing-Pflichtangabe: ein Aufruf des Self-Hosted-LLM (qwen3.5:35b-a3b auf macmini) prüft ob die Angabe doch da ist, nur unter abweichendem Label. Bei Treffer wird HIGH→LOW demoted und „Umbenennen zu Standard" empfohlen.
  5. LLM-Eskalation (Fallback) — wenn der Validator unsicher bleibt: OVH 120b, dann anonymisierter Claude-Cloud-Call. Aktuell deaktiviert (OVH-Key leer).
  6. Cross-Placement-Agent — erkennt deplatzierten Content (Copyright, Disclaimer, WEEE im Impressum) + empfiehlt Footer-Reiter „Legal".

Disclaimer: keine Aussagen wie „rechtssicher" oder „konform" — nur Findings + Empfehlungen + Herleitung. Verbotene Begriffe werden vom Linter aus Agent-Outputs entfernt.

) } function EventLog({ events }: { events: StreamEvent[] }) { return (
{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 || ''} {ev.word_count !== undefined && ( {' '}({ev.word_count} Wörter) )}
))}
) } 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' }