feat(agent): Impressum-Tab auf Haupt-Engine + Profil/§36-Fixes

Ergebnis-Tab rendert jetzt result.results (Haupt-Doc-Check) statt des
abweichenden v3-Agenten — BMW korrekt statt False Positives:
- DocResultView: ein Dokument als Pflichtangaben-Tabelle (Label + gefundener
  Text + 3-Tier-Status), KEINE MC-IDs. ComplianceResultTabs speist Tabs aus
  result.results; ChecklistView-Bausteine exportiert + wiederverwendet.
- profile_extractor: Firmenname/Rechtsform = fruehester Treffer + ausge-
  schriebene Formen (Aktiengesellschaft) -> BMW AG statt "juris GmbH".
- 36 VSBG (MC-010): reines b2c -> POSSIBLY_APPLICABLE (Pruef-Hinweis) statt
  MEDIUM-FAIL; hart nur bei ecommerce. possibly_hint pro MC.
- McCoverage traegt label + found (Snippet); mc_possibly-Aggregat.
- AgentFindingCard/Methodik: interne check_id/mc_id nicht mehr angezeigt.

Tests: test_four_status (16) + Frontend-Vitest gruen; CI-Suite 206, v3/GT
unveraendert. Nur eigene Dateien (geteilter Tree).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-06-10 23:44:01 +02:00
parent a7dc12f30f
commit 3f23a64d5f
15 changed files with 450 additions and 187 deletions
@@ -58,9 +58,8 @@ export function AgentFindingCard({ f }: { f: Finding }) {
{statusLabel}
</span>
)}
<code className="text-[11px] text-gray-500">{f.check_id}</code>
{sources.map((s, i) => (
<MethodikBadge key={i} src={s.source_type} sourceId={s.source_id} />
<MethodikBadge key={i} src={s.source_type} />
))}
{f.confidence !== undefined && (
<span className="text-[10px] text-gray-500 ml-auto">
@@ -1,67 +0,0 @@
'use client'
/**
* "Was wurde geprüft" — listet alle MCs eines Agents mit ihrem Status.
* Standardmäßig collapsed; zeigt sofort, was Methodik des Agents war.
*/
import React, { useState } from 'react'
import type { McCoverage } from './_agentTypes'
const STATUS_COLOR: Record<string, string> = {
ok: '#10b981',
na: '#94a3b8',
skipped: '#cbd5e1',
high: '#dc2626',
medium: '#f59e0b',
low: '#3b82f6',
insufficient_evidence: '#64748b',
possibly_applicable: '#ca8a04',
}
const STATUS_LABEL: Record<string, string> = {
ok: 'OK',
na: 'n/a',
skipped: 'übersprungen',
high: 'HIGH',
medium: 'MEDIUM',
low: 'LOW',
insufficient_evidence: 'unklar',
possibly_applicable: 'evtl. relevant',
}
export function AgentMcCoverage({ coverage }: { coverage: McCoverage[] }) {
const [open, setOpen] = useState(false)
if (!coverage?.length) return null
return (
<div className="border rounded bg-slate-50">
<button
onClick={() => setOpen(o => !o)}
className="w-full text-left px-3 py-2 text-xs font-semibold uppercase text-gray-700 flex justify-between items-center"
>
<span>Was wurde geprüft? ({coverage.length} MCs)</span>
<span className="text-gray-400">{open ? '▾' : '▸'}</span>
</button>
{open && (
<div className="border-t bg-white p-2 space-y-0.5 max-h-60 overflow-y-auto">
{coverage.map(c => (
<div key={c.mc_id} className="flex items-center gap-2 text-xs">
<span
className="w-2 h-2 rounded-full inline-block"
style={{ background: STATUS_COLOR[c.status] || '#cbd5e1' }}
/>
<code className="text-gray-500">{c.mc_id}</code>
<span className="text-gray-700">
{STATUS_LABEL[c.status] || c.status}
</span>
{c.reason && (
<span className="text-gray-400 italic"> {c.reason}</span>
)}
</div>
))}
</div>
)}
</div>
)
}
@@ -0,0 +1,82 @@
'use client'
/**
* AgentPflichtTable — die geprüften Pflichtangaben als menschliche Tabelle:
* Status-Icon + Feldname + tatsächlich gefundener Text. Ersetzt die alte
* MC-ID-Liste.
*
* WICHTIG: zeigt NIE die mc_id (Reverse-Engineering-Schutz der MC-Bibliothek)
* — nur das menschliche `label`. Generisch für jeden Agenten verwendbar.
*/
import React from 'react'
import type { McCoverage } from './_agentTypes'
const DISP: Record<string, { icon: string; text: string; color: string }> = {
ok: { icon: '✓', text: 'vorhanden', color: '#16a34a' },
high: { icon: '✗', text: 'fehlt', color: '#dc2626' },
medium: { icon: '✗', text: 'fehlt', color: '#d97706' },
low: { icon: '✗', text: 'fehlt', color: '#2563eb' },
possibly_applicable: { icon: '?', text: 'zu prüfen', color: '#ca8a04' },
insufficient_evidence: { icon: '?', text: 'unklar', color: '#64748b' },
na: { icon: '', text: 'nicht anwendbar', color: '#94a3b8' },
skipped: { icon: '', text: 'nicht geprüft', color: '#cbd5e1' },
}
// Reihenfolge: Probleme zuerst, dann erfüllt, dann n/a.
const RANK: Record<string, number> = {
high: 0, medium: 1, low: 2, possibly_applicable: 3,
insufficient_evidence: 4, ok: 5, na: 6, skipped: 7,
}
export function AgentPflichtTable({ coverage }: { coverage: McCoverage[] }) {
if (!coverage?.length) return null
const rows = [...coverage].sort(
(a, b) => (RANK[a.status] ?? 9) - (RANK[b.status] ?? 9),
)
const count = (s: string) => coverage.filter(c => c.status === s).length
const ok = count('ok')
const fehlt = count('high') + count('medium') + count('low')
const pruefen = count('possibly_applicable') + count('insufficient_evidence')
const na = count('na') + count('skipped')
return (
<div className="border rounded overflow-hidden">
<div className="px-3 py-2 text-xs font-semibold uppercase text-gray-700 border-b bg-slate-50">
Pflichtangaben <span className="text-green-700">{ok} vorhanden</span>
{fehlt > 0 && <> · <span className="text-red-600">{fehlt} fehlt</span></>}
{pruefen > 0 && (
<> · <span className="text-yellow-700">{pruefen} zu prüfen</span></>
)}
{na > 0 && <> · <span className="text-gray-400">{na} n/a</span></>}
</div>
<div className="divide-y divide-gray-100">
{rows.map((c, i) => {
const d = DISP[c.status] || DISP.skipped
return (
<div key={i} className="flex items-start gap-2 px-3 py-1.5 text-xs">
<span
className="font-bold w-4 text-center shrink-0"
style={{ color: d.color }}
aria-label={d.text}
>
{d.icon}
</span>
<span className="font-medium text-gray-800 w-52 shrink-0">
{c.label || 'Angabe'}
</span>
<span className="text-gray-500 flex-1 min-w-0 break-words">
{c.status === 'ok' ? (
<span className="italic">{c.found || 'vorhanden'}</span>
) : (
<span style={{ color: d.color }}>{d.text}</span>
)}
</span>
</div>
)
})}
</div>
</div>
)
}
@@ -15,7 +15,7 @@ import React, { useState } from 'react'
import type { Severity, SlotOutput } from './_agentTypes'
import { AgentFindingCard } from './AgentFindingCard'
import { AgentMcCoverage } from './AgentMcCoverage'
import { AgentPflichtTable } from './AgentPflichtTable'
import { AgentRecommendationCard } from './AgentRecommendationCard'
import { AgentSpeedometer } from './AgentSpeedometer'
@@ -42,7 +42,7 @@ export function AgentResultView({ output }: { output: SlotOutput }) {
</div>
)}
<AgentMcCoverage coverage={output.mc_coverage} />
<AgentPflichtTable coverage={output.mc_coverage} />
<AgentSpeedometer
total={output.mc_total}
@@ -2,7 +2,7 @@
import React, { useState } from 'react'
interface CheckItem {
export interface CheckItem {
id: string
label: string
passed: boolean
@@ -14,7 +14,7 @@ interface CheckItem {
hint?: string
}
interface DocResult {
export interface DocResult {
label: string
url: string
doc_type: string
@@ -27,14 +27,14 @@ interface DocResult {
scenario?: string // regenerate | fix | import | skip
}
const SCENARIO_LABELS: Record<string, { label: string; color: string; bg: string }> = {
export const SCENARIO_LABELS: Record<string, { label: string; color: string; bg: string }> = {
regenerate: { label: 'Neugenerierung', color: 'text-red-700', bg: 'bg-red-100' },
fix: { label: 'Korrekturen', color: 'text-amber-700', bg: 'bg-amber-100' },
import: { label: 'Konform', color: 'text-green-700', bg: 'bg-green-100' },
missing: { label: 'Fehlt', color: 'text-gray-600', bg: 'bg-gray-100' },
}
const DOC_TYPE_LABELS: Record<string, string> = {
export const DOC_TYPE_LABELS: Record<string, string> = {
dse: 'DSI', agb: 'AGB', impressum: 'Impressum',
cookie: 'Cookie', widerruf: 'Widerruf', other: 'Sonstiges',
social_media: 'Social Media', dsfa: 'DSFA', joint_controller: 'Art. 26',
@@ -46,7 +46,7 @@ interface GroupedCheck {
children: CheckItem[]
}
function groupChecks(checks: CheckItem[]): GroupedCheck[] {
export function groupChecks(checks: CheckItem[]): GroupedCheck[] {
const l1 = checks.filter(c => (c.level ?? 1) === 1)
return l1.map(c => ({
check: c,
@@ -54,7 +54,7 @@ function groupChecks(checks: CheckItem[]): GroupedCheck[] {
}))
}
function CheckIcon({ passed, skipped, isInfo }: { passed: boolean; skipped?: boolean; isInfo?: boolean }) {
export function CheckIcon({ passed, skipped, isInfo }: { passed: boolean; skipped?: boolean; isInfo?: boolean }) {
if (skipped) {
return (
<svg className="w-4 h-4 text-gray-300 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -10,21 +10,16 @@
import React, { useState } from 'react'
import type { SlotOutput } from './_agentTypes'
import { AgentResultTab } from './AgentResultTab'
import { ChecklistView } from './ChecklistView'
import { ChecklistView, DOC_TYPE_LABELS, type DocResult } from './ChecklistView'
import { DocResultView } from './DocResultView'
import { MigrationPanel } from './MigrationPanel'
const TOPIC_LABELS: Record<string, string> = {
impressum: 'Impressum',
cookie: 'Cookie-Banner',
}
export function ComplianceResultTabs({ results }: { results: any }) {
const agentOutputs: Record<string, SlotOutput> = results.agent_outputs || {}
const topicKeys = Object.keys(agentOutputs)
const tabs = [...topicKeys, 'raw']
const [active, setActive] = useState<string>(tabs[0])
// Themen-Tabs aus der HAUPT-Engine (result.results) — nicht aus dem
// v3-Agent. Jedes Dokument = ein Tab mit der genauen Pflichtangaben-Tabelle.
const docs: DocResult[] = results.results || []
const tabs = docs.map((_: DocResult, i: number) => String(i)).concat('raw')
const [active, setActive] = useState<string>(tabs[0] ?? 'raw')
return (
<div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm space-y-4">
@@ -112,26 +107,30 @@ export function ComplianceResultTabs({ results }: { results: any }) {
</div>
)}
{/* Tab-Leiste — Themen-Agenten + Roh-Checkliste */}
{/* Tab-Leiste — ein Tab je Dokument (Haupt-Engine) + Übersicht */}
<div className="flex gap-1 border-b border-gray-200 flex-wrap">
{tabs.map(t => {
const count = t !== 'raw' ? (agentOutputs[t]?.findings?.length ?? 0) : 0
const tabClass = `px-3 py-1.5 text-sm font-medium border-b-2 -mb-px transition-colors flex items-center gap-1.5 ${
active === t
? 'border-purple-500 text-purple-700'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`
if (t === 'raw') {
return (
<button key={t} onClick={() => setActive(t)} className={tabClass}>
Alle Checks
</button>
)
}
const doc = docs[Number(t)]
const dot = doc.error ? 'bg-gray-300'
: doc.scenario === 'import' ? 'bg-green-500'
: doc.scenario === 'fix' ? 'bg-amber-500'
: doc.scenario === 'regenerate' ? 'bg-red-500' : 'bg-gray-400'
return (
<button
key={t}
onClick={() => setActive(t)}
className={`px-3 py-1.5 text-sm font-medium border-b-2 -mb-px transition-colors ${
active === t
? 'border-purple-500 text-purple-700'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
{t === 'raw' ? 'Alle Checks (roh)' : (TOPIC_LABELS[t] || t)}
{count > 0 && (
<span className="ml-1.5 text-xs bg-gray-100 text-gray-600 rounded-full px-1.5">
{count}
</span>
)}
<button key={t} onClick={() => setActive(t)} className={tabClass}>
<span className={`w-2 h-2 rounded-full ${dot}`} />
{DOC_TYPE_LABELS[doc.doc_type] || doc.doc_type}
</button>
)
})}
@@ -140,11 +139,8 @@ export function ComplianceResultTabs({ results }: { results: any }) {
{/* Tab-Inhalt */}
{active === 'raw' ? (
<ChecklistView results={results.results} />
) : agentOutputs[active] ? (
<AgentResultTab
topicLabel={TOPIC_LABELS[active] || active}
output={agentOutputs[active]}
/>
) : docs[Number(active)] ? (
<DocResultView doc={docs[Number(active)]} />
) : null}
{/* Check-Footer (themenübergreifend) */}
@@ -0,0 +1,144 @@
'use client'
/**
* DocResultView — EIN Dokument-Prüfergebnis der HAUPT-Engine als saubere,
* immer-offene Pflichtangaben-Tabelle: Verdikt + Gruppen + extrahierte Texte
* (matched_text) pro Prüfpunkt.
*
* Quelle = result.results[doc] (die genaue Haupt-Doc-Check-Engine), NICHT
* der v3-Agent. Zeigt menschliche Labels + gefundene Snippets, keine internen
* IDs. Wiederverwendet die Render-Bausteine aus ChecklistView.
*/
import React from 'react'
import {
CheckIcon,
type DocResult,
groupChecks,
SCENARIO_LABELS,
} from './ChecklistView'
function Snippet({ text }: { text: string }) {
return (
<div className="text-xs text-gray-500 mt-0.5 font-mono break-words">
{text}"
</div>
)
}
function ScoreBar({ label, pct, blue }: { label: string; pct: number; blue?: boolean }) {
const color = blue
? pct >= 80 ? 'bg-blue-400' : 'bg-blue-300'
: pct === 100 ? 'bg-green-500' : pct >= 50 ? 'bg-yellow-500' : 'bg-red-500'
return (
<div className="flex items-center gap-1.5">
<span className="text-[10px] text-gray-400">{label}</span>
<div className="w-12 h-1.5 bg-gray-200 rounded-full overflow-hidden">
<div className={`h-full rounded-full ${color}`} style={{ width: `${pct}%` }} />
</div>
<span className="text-gray-600 w-9 text-right">{pct}%</span>
</div>
)
}
export function DocResultView({ doc }: { doc: DocResult }) {
if (doc.error) {
return (
<div className="text-sm text-amber-700 bg-amber-50 rounded p-3">
{doc.error}
</div>
)
}
const grouped = groupChecks(doc.checks)
const l1 = doc.checks.filter(c => (c.level ?? 1) === 1)
const l1Score = l1.filter(c => c.severity !== 'INFO')
const l1Passed = l1Score.filter(c => c.passed).length
const l2 = doc.checks.filter(c => (c.level ?? 1) === 2 && !c.skipped)
const l2Passed = l2.filter(c => c.passed).length
const sc = doc.scenario ? SCENARIO_LABELS[doc.scenario] : null
return (
<div className="space-y-3">
{/* Verdikt-Kopf */}
<div className="flex items-center flex-wrap gap-3 border rounded-lg px-4 py-3 bg-slate-50">
{sc && (
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${sc.bg} ${sc.color}`}>
{sc.label}
</span>
)}
<span className="text-sm text-gray-700">
{l1Passed}/{l1Score.length} Pflichtangaben
{l2.length > 0 && <>, {l2Passed}/{l2.length} Detailprüfungen</>}
</span>
<div className="flex gap-3 ml-auto">
<ScoreBar label="Pflicht" pct={doc.completeness_pct} />
{l2.length > 0 && (
<ScoreBar label="Detail" pct={doc.correctness_pct ?? 0} blue />
)}
</div>
</div>
{/* Pflichtangaben-Tabelle */}
<div className="border rounded-lg divide-y divide-gray-100">
{grouped.map(g => {
const l1Info = g.check.severity === 'INFO' && !g.check.passed
return (
<div key={g.check.id} className="px-4 py-2">
<div className="flex items-start gap-2">
<CheckIcon passed={g.check.passed} isInfo={l1Info} />
<div className="flex-1 min-w-0">
<div className={`text-sm ${
g.check.passed ? 'text-gray-800'
: l1Info ? 'text-gray-500' : 'text-red-700 font-medium'
}`}>
{g.check.label}
</div>
{g.check.passed && g.check.matched_text && g.children.length === 0 && (
<Snippet text={g.check.matched_text} />
)}
{!g.check.passed && g.check.hint && (
<div className={`text-xs mt-0.5 ${l1Info ? 'text-gray-400' : 'text-red-600/80'}`}>
{g.check.hint}
</div>
)}
</div>
</div>
{g.children.length > 0 && (
<div className="ml-6 mt-1 space-y-1 border-l-2 border-gray-200 pl-3">
{g.children.map(ch => {
const chInfo = ch.severity === 'INFO' && !ch.passed && !ch.skipped
return (
<div key={ch.id} className="flex items-start gap-2">
<CheckIcon passed={ch.passed} skipped={ch.skipped} isInfo={chInfo} />
<div className="flex-1 min-w-0">
<div className={`text-xs ${
ch.skipped ? 'text-gray-400 italic'
: ch.passed ? 'text-gray-600'
: chInfo ? 'text-gray-400' : 'text-red-600 font-medium'
}`}>
{ch.label}{ch.skipped && ' (übersprungen)'}
</div>
{ch.passed && ch.matched_text && <Snippet text={ch.matched_text} />}
{!ch.passed && !ch.skipped && ch.hint && (
<div className={`text-xs mt-0.5 ${chInfo ? 'text-gray-400' : 'text-red-500/80'}`}>
{ch.hint}
</div>
)}
</div>
</div>
)
})}
</div>
)}
</div>
)
})}
</div>
{doc.word_count > 0 && (
<div className="text-xs text-gray-400">{doc.word_count} Wörter analysiert</div>
)}
</div>
)
}
@@ -0,0 +1,29 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { AgentPflichtTable } from '../AgentPflichtTable'
import type { McCoverage } from '../_agentTypes'
const COV: McCoverage[] = [
{ mc_id: 'IMP-MC-002', status: 'ok', label: 'Email-Adresse',
found: 'kundenbetreuung@bmw.de' },
{ mc_id: 'IMP-MC-010', status: 'possibly_applicable',
label: 'Verbraucher-Streitbeilegung-Hinweis' },
{ mc_id: 'IMP-MC-009', status: 'na', label: 'Verantwortlicher § 18 MStV' },
]
describe('AgentPflichtTable', () => {
it('zeigt Label + gefundenen Wert, aber KEINE mc_id', () => {
render(<AgentPflichtTable coverage={COV} />)
expect(screen.getByText('Email-Adresse')).toBeInTheDocument()
expect(screen.getByText('kundenbetreuung@bmw.de')).toBeInTheDocument()
// Reverse-Engineering-Schutz: mc_id darf NICHT erscheinen.
expect(screen.queryByText(/IMP-MC-/)).not.toBeInTheDocument()
})
it('Verdikt-Header zählt die Status', () => {
render(<AgentPflichtTable coverage={COV} />)
expect(screen.getByText(/1 vorhanden/)).toBeInTheDocument()
expect(screen.getByText(/1 zu prüfen/)).toBeInTheDocument()
})
})
@@ -69,27 +69,32 @@ describe('AgentResultTab', () => {
})
const DOC_RESULT = {
label: 'Impressum-Rohdaten', url: 'https://example.com/impressum',
doc_type: 'impressum', word_count: 50, completeness_pct: 80,
correctness_pct: 90, checks: [], findings_count: 2, error: '',
label: 'Impressum', url: 'https://example.com/impressum',
doc_type: 'impressum', word_count: 50, completeness_pct: 100,
correctness_pct: 100, findings_count: 0, error: '', scenario: 'import',
checks: [
{ id: 'name', label: 'Name des Anbieters', passed: true, severity: 'HIGH',
matched_text: 'Bayerische Motoren Werke Aktiengesellschaft', level: 1 },
{ id: 'email', label: 'E-Mail-Adresse', passed: true, severity: 'HIGH',
matched_text: 'kundenbetreuung@bmw.de', level: 1 },
],
}
describe('ComplianceResultTabs', () => {
it('zeigt den Impressum-Tab zuerst und wechselt auf die Roh-Checkliste', () => {
const result = {
agent_outputs: { impressum: IMPRESSUM_OUTPUT },
results: [DOC_RESULT],
}
it('rendert das Dokument-Tab der Haupt-Engine mit extrahierten Texten', () => {
// Themen-Tabs kommen aus result.results (Haupt-Engine), NICHT agent_outputs.
const result = { results: [DOC_RESULT] }
render(<ComplianceResultTabs results={result} />)
// beide Tabs vorhanden
// Dokument-Tab + Übersicht
expect(screen.getByRole('button', { name: /Impressum/ })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Alle Checks \(roh\)/ })).toBeInTheDocument()
// Impressum-Tab aktiv → Finding sichtbar
expect(screen.getByText('Pflichtangabe fehlt: Email-Adresse')).toBeInTheDocument()
// Wechsel auf die Roh-Checkliste
fireEvent.click(screen.getByRole('button', { name: /Alle Checks \(roh\)/ }))
// Impressum-Finding ist weg (umgeschaltet), Komponente intakt
expect(screen.queryByText('Pflichtangabe fehlt: Email-Adresse')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: /Alle Checks \(roh\)/ })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Alle Checks/ })).toBeInTheDocument()
// DocResultView: menschliches Label + gefundener Text sichtbar
expect(screen.getByText('Name des Anbieters')).toBeInTheDocument()
expect(screen.getByText(/Bayerische Motoren Werke/)).toBeInTheDocument()
// Wechsel auf die Übersicht
fireEvent.click(screen.getByRole('button', { name: /Alle Checks/ }))
expect(
screen.getByText(/Dokumenten-Pruefung/),
).toBeInTheDocument()
})
})
@@ -63,8 +63,10 @@ export interface Recommendation {
export interface McCoverage {
mc_id: string
status: 'ok' | 'na' | 'high' | 'medium' | 'low' | 'skipped' |
'insufficient_evidence'
'insufficient_evidence' | 'possibly_applicable'
reason?: string
label?: string // menschlicher Feldname (KEINE mc_id im Frontend zeigen)
found?: string // gefundener Text/Wert bei status=ok
}
export interface EscalationLog {