3ec6393919
CI / nodejs-build (push) Successful in 2m20s
CI / test-go (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
CI / test-python-backend (push) Has been skipped
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
CI / detect-changes (push) Successful in 7s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / build-sha-integrity (push) Failing after 4s
CI / validate-canonical-controls (push) Successful in 11s
CI / loc-budget (push) Successful in 14s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
User-Klarstellung 2026-06-09:
- 314.811 Atomic-Controls (compliance.canonical_controls)
- 13.588 Master-Controls nach RAG-Dedup (compliance.master_controls)
- ~1.778 Master-Controls fuer dieses Compliance-Tool selektiert
(vermutlich phases_covered = ['implementation', 'testing'])
- Frontend: https://macmini:3007/sdk/master-controls und
https://macmini:3007/sdk/control-library
Methodik-Box im Agent-Test-Tab aktualisiert mit korrekten Zahlen
+ Roadmap-Hinweis: Sprint 1.12 wird interne Pattern-IDs formal
mit Master-Controls verknuepfen.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
338 lines
11 KiB
TypeScript
338 lines
11 KiB
TypeScript
'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<AgentInfo[]>([])
|
|
const [agentId, setAgentId] = useState<string>('')
|
|
const [urls, setUrls] = useState<string[]>(['', '', '', '', ''])
|
|
const [running, setRunning] = useState(false)
|
|
const [runId, setRunId] = useState<string>('')
|
|
const [events, setEvents] = useState<StreamEvent[]>([])
|
|
const [result, setResult] = useState<RunResult | null>(null)
|
|
const [error, setError] = useState<string>('')
|
|
const eventSrcRef = useRef<EventSource | null>(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 (
|
|
<div className="space-y-4">
|
|
<InputCard
|
|
agents={agents}
|
|
agentId={agentId}
|
|
setAgentId={setAgentId}
|
|
selectedAgent={selectedAgent}
|
|
urls={urls}
|
|
setUrls={setUrls}
|
|
running={running}
|
|
runId={runId}
|
|
startTest={startTest}
|
|
error={error}
|
|
/>
|
|
|
|
<MethodikInfo />
|
|
|
|
{running && events.length > 0 && <EventLog events={events} />}
|
|
|
|
{slotOutputs.length > 0 && (
|
|
<div className="space-y-3">
|
|
{slotOutputs.map(({ slot, output }) => (
|
|
<AgentSlotCard
|
|
key={slot} slot={slot} output={output} runId={runId}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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 (
|
|
<div className="rounded-lg border bg-white p-4 space-y-3 shadow-sm">
|
|
<h2 className="text-lg font-semibold">Agent-Test (max. {MAX_SLOTS} URLs)</h2>
|
|
<div className="flex flex-wrap gap-3 items-end">
|
|
<div>
|
|
<label className="block text-xs font-medium text-gray-600">Agent</label>
|
|
<select
|
|
value={agentId}
|
|
onChange={e => setAgentId(e.target.value)}
|
|
className="border rounded px-2 py-1 text-sm"
|
|
>
|
|
{agents.map(a => (
|
|
<option key={a.agent_id} value={a.agent_id}>
|
|
{a.agent_id} v{a.agent_version} ({a.mc_count} MCs)
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
{selectedAgent && (
|
|
<div className="text-xs text-gray-500">
|
|
Doc-Type: <code>{selectedAgent.doc_type}</code>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="space-y-1">
|
|
{urls.map((u, i) => (
|
|
<div key={i} className="flex gap-2">
|
|
<span className="text-xs font-mono text-gray-500 w-8 pt-1.5">
|
|
URL{i + 1}
|
|
</span>
|
|
<input
|
|
value={u}
|
|
onChange={e => {
|
|
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"
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={startTest}
|
|
disabled={running}
|
|
className="bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 text-white text-sm px-4 py-2 rounded"
|
|
>
|
|
{running ? 'Laufend...' : 'Test starten'}
|
|
</button>
|
|
{runId && (
|
|
<span className="text-xs text-gray-500 self-center">
|
|
Run-ID: <code>{runId}</code>
|
|
</span>
|
|
)}
|
|
</div>
|
|
{error && (
|
|
<div className="bg-red-50 border-l-4 border-red-400 p-2 text-sm text-red-700">
|
|
{error}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function MethodikInfo() {
|
|
return (
|
|
<details className="rounded border bg-slate-50 px-3 py-2 text-xs text-gray-700">
|
|
<summary className="cursor-pointer font-semibold">
|
|
Methodik — wie geprüft wird
|
|
</summary>
|
|
<ol className="list-decimal ml-5 mt-2 space-y-1">
|
|
<li>
|
|
<strong>Pattern-Checks</strong> — deterministische Regex-Tests
|
|
gegen Pflichtangaben-Schema (z.B. § 5 TMG/DDG). Schnell,
|
|
reproduzierbar. <em>Hinweis:</em> diese Pattern-IDs (z.B.
|
|
<code>IMP-MC-001</code>) sind <strong>interne Test-IDs</strong>,
|
|
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).
|
|
</li>
|
|
<li>
|
|
<strong>Knowledge-Base</strong> — kuratierte Patterns aus
|
|
anonymisierten Mandanten-FAQs.
|
|
</li>
|
|
<li>
|
|
<strong>Auto-Learning-Pattern-Library</strong> — 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.
|
|
</li>
|
|
<li>
|
|
<strong>Semantic-Validator (LLM)</strong> — nur bei
|
|
missing-Pflichtangabe: ein Aufruf des Self-Hosted-LLM
|
|
(<code>qwen3.5:35b-a3b</code> 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.
|
|
</li>
|
|
<li>
|
|
<strong>LLM-Eskalation (Fallback)</strong> — wenn der
|
|
Validator unsicher bleibt: OVH 120b, dann anonymisierter
|
|
Claude-Cloud-Call. Aktuell deaktiviert (OVH-Key leer).
|
|
</li>
|
|
<li>
|
|
<strong>Cross-Placement-Agent</strong> — erkennt deplatzierten
|
|
Content (Copyright, Disclaimer, WEEE im Impressum) +
|
|
empfiehlt Footer-Reiter „Legal".
|
|
</li>
|
|
</ol>
|
|
<p className="mt-2 italic text-gray-500">
|
|
Disclaimer: keine Aussagen wie „rechtssicher" oder „konform" —
|
|
nur Findings + Empfehlungen + Herleitung. Verbotene Begriffe
|
|
werden vom Linter aus Agent-Outputs entfernt.
|
|
</p>
|
|
</details>
|
|
)
|
|
}
|
|
|
|
function EventLog({ events }: { events: StreamEvent[] }) {
|
|
return (
|
|
<div className="rounded border bg-gray-50 p-3 max-h-48 overflow-y-auto">
|
|
<div className="text-xs font-mono space-y-0.5">
|
|
{events.slice(-30).map((ev, i) => (
|
|
<div key={i}>
|
|
<span className="text-gray-400">[{ev.type}]</span>{' '}
|
|
{ev.slot && <span className="text-blue-600">{ev.slot}</span>}{' '}
|
|
{ev.severity && (
|
|
<span className={severityColor(ev.severity)}>{ev.severity}</span>
|
|
)}{' '}
|
|
{ev.title || ev.error || ev.label || ev.model || ev.url || ''}
|
|
{ev.word_count !== undefined && (
|
|
<span className="text-gray-500">
|
|
{' '}({ev.word_count} Wörter)
|
|
</span>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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'
|
|
}
|