702e7a6333
CI / build-sha-integrity (push) Failing after 4s
CI / validate-canonical-controls (push) Successful in 11s
CI / loc-budget (push) Successful in 13s
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 2m21s
CI / test-go (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
CI / test-python-backend (push) Successful in 29s
CI / detect-changes (push) Successful in 8s
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 / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
Safetykon-Bug: 'Geschäftsführung:' (Sammelbegriff für GF einer GmbH)
matched das alte Pattern 'Geschäftsführer' nicht — False-Positive
IMPRESSUM-AGENT-VERTRETUNGSBERECHTIGTE_LABEL_KORREKT.
Pattern erweitert: Geschäftsführer|Geschäftsführung|Geschäftsführerin
+ Vorstand|Vorstandsvorsitzender + Inhaber|persönlich haftend.
Test test_safetykon_geschaeftsfuehrung_passes ergänzt (11/11 grün).
frontend: SlotCard zeigt jetzt Badge bei 0/0/0-Slots
('Dokument konnte nicht geladen werden') statt silent-fail, +
bei 0 Findings ein 'alle MCs OK'-Badge.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
455 lines
15 KiB
TypeScript
455 lines
15 KiB
TypeScript
'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<string, SlotOutput>
|
|
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<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))
|
|
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 (
|
|
<div className="space-y-4">
|
|
<div className="rounded-lg border bg-white p-4 space-y-3">
|
|
<h2 className="text-lg font-semibold">Agent-Test (max. {MAX_SLOTS} URLs)</h2>
|
|
<p className="text-xs text-gray-500">
|
|
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.
|
|
</p>
|
|
<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>
|
|
|
|
{running && events.length > 0 && (
|
|
<div className="rounded-lg border bg-gray-50 p-3 max-h-48 overflow-y-auto">
|
|
<div className="text-xs font-mono space-y-1">
|
|
{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 || ''}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{slotOutputs.length > 0 && (
|
|
<div className="space-y-3">
|
|
{slotOutputs.map(({ slot, output }) => (
|
|
<SlotCard key={slot} slot={slot} output={output} runId={runId}/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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)
|
|
const wasSkipped = output.mc_total > 0 &&
|
|
output.mc_ok === 0 && output.mc_na === 0 &&
|
|
output.mc_high === 0 && output.mc_medium === 0 && output.mc_low === 0
|
|
const allGreen = !wasSkipped && output.findings.length === 0
|
|
return (
|
|
<div className="rounded-lg border bg-white p-4 space-y-3">
|
|
<div className="flex items-baseline gap-3">
|
|
<h3 className="font-semibold text-gray-800">Slot: {slot}</h3>
|
|
<span className="text-xs text-gray-500">
|
|
{output.duration_ms} ms · confidence {(output.confidence * 100).toFixed(0)}%
|
|
</span>
|
|
{wasSkipped && (
|
|
<span className="text-xs bg-amber-100 text-amber-800 px-2 py-0.5 rounded">
|
|
Dokument konnte nicht geladen werden (leer/zu kurz)
|
|
</span>
|
|
)}
|
|
{allGreen && (
|
|
<span className="text-xs bg-emerald-100 text-emerald-800 px-2 py-0.5 rounded">
|
|
Keine Findings — alle anwendbaren MCs OK
|
|
</span>
|
|
)}
|
|
<a className="text-xs text-blue-600 hover:underline ml-auto"
|
|
href={`/api/sdk/v1/specialist-agent/run/${runId}/artifacts`}
|
|
target="_blank" rel="noreferrer">
|
|
Artefakte ↗
|
|
</a>
|
|
</div>
|
|
<Speedometer
|
|
total={output.mc_total}
|
|
ok={output.mc_ok}
|
|
na={output.mc_na}
|
|
high={output.mc_high}
|
|
medium={output.mc_medium}
|
|
low={output.mc_low}
|
|
/>
|
|
{output.escalation_log.length > 0 && (
|
|
<div className="text-xs text-gray-500">
|
|
Eskalationen:{' '}
|
|
{output.escalation_log.map((e, i) => (
|
|
<span key={i} className="mr-2">
|
|
{e.stage}/{e.model} {e.success ? '✓' : '✗'} ({e.duration_ms} ms)
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
{output.findings.length > 0 && (
|
|
<div className="space-y-1">
|
|
<div className="text-xs font-semibold uppercase text-gray-600">
|
|
Findings ({output.findings.length})
|
|
</div>
|
|
{visibleFindings.map(f => (
|
|
<FindingRow key={f.check_id} f={f}/>
|
|
))}
|
|
{output.findings.length > 8 && (
|
|
<button onClick={() => setShowAll(x => !x)}
|
|
className="text-xs text-blue-600 hover:underline">
|
|
{showAll ? 'Weniger anzeigen' : `Alle ${output.findings.length} anzeigen`}
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
{output.recommendations.length > 0 && (
|
|
<div className="space-y-1">
|
|
<div className="text-xs font-semibold uppercase text-gray-600">
|
|
Empfehlungen ({output.recommendations.length}, gerollupt)
|
|
</div>
|
|
{output.recommendations.map(r => (
|
|
<div key={r.recommendation_id}
|
|
className="border-l-2 border-emerald-400 bg-emerald-50 p-2 text-xs">
|
|
<div className="font-semibold">{r.title}</div>
|
|
<div className="text-gray-600">{r.body}</div>
|
|
<div className="text-[10px] text-gray-500 mt-1">
|
|
{r.related_finding_ids.length} Finding(s) · ~{r.estimated_effort_hours}h
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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 (
|
|
<div className="space-y-1">
|
|
<div className="text-xs text-gray-500">{total} MCs geprüft</div>
|
|
<div className="flex h-4 rounded overflow-hidden border">
|
|
<Bar pct={(ok / safeTotal) * 100} color="#10b981"/>
|
|
<Bar pct={(na / safeTotal) * 100} color="#94a3b8"/>
|
|
<Bar pct={(high / safeTotal) * 100} color="#dc2626"/>
|
|
<Bar pct={(medium / safeTotal) * 100} color="#f59e0b"/>
|
|
<Bar pct={(low / safeTotal) * 100} color="#3b82f6"/>
|
|
</div>
|
|
<div className="flex flex-wrap gap-2 text-xs">
|
|
<Legend color="#10b981" label={`OK ${ok}`}/>
|
|
<Legend color="#94a3b8" label={`n/a ${na}`}/>
|
|
<Legend color="#dc2626" label={`HIGH ${high}`}/>
|
|
<Legend color="#f59e0b" label={`MEDIUM ${medium}`}/>
|
|
<Legend color="#3b82f6" label={`LOW ${low}`}/>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function Bar({ pct, color }: { pct: number; color: string }) {
|
|
return <div style={{ width: `${pct}%`, background: color }}/>
|
|
}
|
|
|
|
function Legend({ color, label }: { color: string; label: string }) {
|
|
return (
|
|
<span className="inline-flex items-center gap-1">
|
|
<span style={{ background: color }} className="w-2 h-2 inline-block rounded"/>
|
|
<span>{label}</span>
|
|
</span>
|
|
)
|
|
}
|
|
|
|
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 (
|
|
<div className="p-2 border-l-2" style={{ borderColor: color }}>
|
|
<div className="flex items-baseline gap-2 text-xs">
|
|
<span style={{ color }} className="font-semibold">{f.severity}</span>
|
|
<code className="text-gray-500">{f.check_id}</code>
|
|
{sourceTags.map(t => (
|
|
<span key={t} className="px-1 bg-gray-100 rounded text-[10px]">{t}</span>
|
|
))}
|
|
</div>
|
|
<div className="text-sm">{f.title}</div>
|
|
{f.norm && <div className="text-[11px] text-gray-500">{f.norm}</div>}
|
|
{f.evidence && (
|
|
<div className="text-[11px] italic text-gray-600 mt-1">„{f.evidence}"</div>
|
|
)}
|
|
{f.action && (
|
|
<div className="text-[11px] text-emerald-700 mt-1">
|
|
→ {f.action}
|
|
</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'
|
|
}
|
|
|
|
function severityHex(sev: string) {
|
|
return sev === 'HIGH' ? '#dc2626' :
|
|
sev === 'MEDIUM' ? '#f59e0b' :
|
|
sev === 'LOW' ? '#3b82f6' : '#94a3b8'
|
|
}
|