Files
breakpilot-compliance/admin-compliance/app/sdk/agent/_components/AgentTestTab.tsx
T
Benjamin Admin 3ef8c9b247
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
CI / nodejs-build (push) Successful in 2m24s
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
feat(agents): Frontend Methodik-First Layout
User-Vorgabe: pro Slot transparent zeigen WAS wir tun:
  1. Was wurde geprueft (MC-Coverage, collapsible)
  2. Speedometer mit Severity-Verteilung
  3. LLM-Eskalation-Log (wenn benutzt)
  4. Findings sortiert HIGH->LOW, je Card:
     - Methodik-Badge (MC / Regex / KB / LLM / Cross)
     - Gesetzliche Basis (Norm-Block, violett)
     - Befund (Zitat-Block, amber)
     - Empfehlung -> 'Pflicht-Massnahme' bei HIGH,
       'Best-Practice' bei MEDIUM/LOW, 'LLM-Vorschlag'
       bei LLM-Quelle
  5. Maszahmen-Plan (gerollupte Recommendations mit
     related_finding_ids + Aufwand)

Refactor: ein File AgentTestTab.tsx (519 LOC) -> 7 Files:
  _agentTypes.ts (Types + Methodik-Konstanten)
  AgentSpeedometer.tsx
  AgentMcCoverage.tsx
  AgentFindingCard.tsx
  AgentRecommendationCard.tsx
  AgentSlotCard.tsx
  AgentTestTab.tsx (Top-Level, schlank)

Plus Methodik-Info-Erklaerung am Tab-Anfang + Disclaimer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-09 07:53:24 +02:00

317 lines
9.9 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>Machine-Checks (MCs)</strong> deterministische
Pattern-Tests gegen Gesetzestext (z.B. § 5 TMG). Schnell,
reproduzierbar.
</li>
<li>
<strong>Knowledge-Base</strong> kuratierte Patterns aus
anonymisierten Mandanten-FAQs.
</li>
<li>
<strong>LLM-Eskalation</strong> nur bei unklaren MCs:
erst lokales qwen2.5:7b, bei Bedarf größeres OVH-Modell.
Claude (Cloud) erst nach Anonymisierung.
</li>
<li>
<strong>Cross-Doc-Vergleich</strong> Konsistenz zwischen
DSE, Cookie-Policy, Impressum (späterer Agent).
</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'
}