feat(agent): strukturierte Ergebnis-Tabs — Impressum (Phase 1)
Der Compliance-Check legt zusätzlich einen strukturierten v3-AgentOutput pro Thema in result.agent_outputs ab (additiv; B18-HTML + Firehose-Mail bleiben unangetastet). Frontend: standardisiertes Ergebnis-Tab statt Firehose — Impressum-Tab (AgentResultTab) + "Alle Checks (roh)" (ChecklistView). - backend: _agent_outputs.py ruft den registrierten v3-ImpressumAgent, gewired in _orchestrator nach B18, surfaced via _phase_f_persist. - frontend: AgentResultView (aus AgentSlotCard extrahiert, DRY), AgentResultTab, ComplianceResultTabs; ComplianceCheckTab 490->391 Zeilen. - Tests: backend 2 passed, frontend 2 passed; tsc 0 neue Fehler; check-loc 0. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* AgentResultTab — Inhalt eines Themen-Ergebnis-Tabs im Compliance-Check.
|
||||
* Themen-Header (Label + Konfidenz + Severity-Ampel) + der geteilte
|
||||
* AgentResultView. Standardisierter Rahmen, den jeder Themen-Agent
|
||||
* (Impressum, später Cookie/Vendor/Savings) füllt.
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
|
||||
import type { SlotOutput } from './_agentTypes'
|
||||
import { isOutputSkipped } from './_agentTypes'
|
||||
import { AgentResultView } from './AgentResultView'
|
||||
|
||||
export function AgentResultTab({
|
||||
topicLabel, output,
|
||||
}: {
|
||||
topicLabel: string
|
||||
output: SlotOutput
|
||||
}) {
|
||||
const wasSkipped = isOutputSkipped(output)
|
||||
const allGreen = !wasSkipped && output.findings.length === 0
|
||||
const high = output.findings.filter(f => f.severity === 'HIGH').length
|
||||
const medium = output.findings.filter(f => f.severity === 'MEDIUM').length
|
||||
const low = output.findings.filter(f => f.severity === 'LOW').length
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-white p-4 space-y-3 shadow-sm">
|
||||
<div className="flex items-baseline gap-3 flex-wrap">
|
||||
<h3 className="font-semibold text-gray-900">{topicLabel}</h3>
|
||||
<span className="text-xs text-gray-500">
|
||||
Konfidenz {(output.confidence * 100).toFixed(0)}%
|
||||
</span>
|
||||
{high > 0 && (
|
||||
<span className="text-xs bg-red-100 text-red-700 px-2 py-0.5 rounded font-semibold">
|
||||
{high} HIGH
|
||||
</span>
|
||||
)}
|
||||
{medium > 0 && (
|
||||
<span className="text-xs bg-amber-100 text-amber-800 px-2 py-0.5 rounded">
|
||||
{medium} MEDIUM
|
||||
</span>
|
||||
)}
|
||||
{low > 0 && (
|
||||
<span className="text-xs bg-blue-100 text-blue-700 px-2 py-0.5 rounded">
|
||||
{low} LOW
|
||||
</span>
|
||||
)}
|
||||
{allGreen && (
|
||||
<span className="text-xs bg-emerald-100 text-emerald-800 px-2 py-0.5 rounded">
|
||||
Alle anwendbaren MCs erfüllt
|
||||
</span>
|
||||
)}
|
||||
{wasSkipped && (
|
||||
<span className="text-xs bg-amber-100 text-amber-800 px-2 py-0.5 rounded">
|
||||
Dokument nicht geladen
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AgentResultView output={output} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* AgentResultView — der geteilte Render-Body eines AgentOutput:
|
||||
* MC-Coverage + Speedometer + Eskalationslog + Findings (HIGH→LOW) +
|
||||
* konsolidierte Maßnahmen. KEIN Header — den setzt der Consumer
|
||||
* (AgentSlotCard = Agent-Test-Slot, AgentResultTab = Themen-Tab).
|
||||
*
|
||||
* Dieser View ist die "Karten"-Darstellung für Themen mit wenigen
|
||||
* Findings (z.B. Impressum). Dichte Themen (Cookie, bis ~1000 Zeilen)
|
||||
* bekommen später einen eigenen Tabellen-View im gleichen Tab-Rahmen.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react'
|
||||
|
||||
import type { Severity, SlotOutput } from './_agentTypes'
|
||||
import { AgentFindingCard } from './AgentFindingCard'
|
||||
import { AgentMcCoverage } from './AgentMcCoverage'
|
||||
import { AgentRecommendationCard } from './AgentRecommendationCard'
|
||||
import { AgentSpeedometer } from './AgentSpeedometer'
|
||||
|
||||
const SEV_ORDER: Record<Severity, number> = {
|
||||
HIGH: 0, MEDIUM: 1, LOW: 2, INFO: 3,
|
||||
}
|
||||
|
||||
const INITIAL_VISIBLE = 12
|
||||
|
||||
export function AgentResultView({ output }: { output: SlotOutput }) {
|
||||
const [showAll, setShowAll] = useState(false)
|
||||
const sortedFindings = [...output.findings].sort(
|
||||
(a, b) => SEV_ORDER[a.severity] - SEV_ORDER[b.severity],
|
||||
)
|
||||
const visible = showAll
|
||||
? sortedFindings
|
||||
: sortedFindings.slice(0, INITIAL_VISIBLE)
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{output.notes && (
|
||||
<div className="text-xs text-amber-700 bg-amber-50 px-2 py-1 rounded">
|
||||
Hinweis: {output.notes}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AgentMcCoverage coverage={output.mc_coverage} />
|
||||
|
||||
<AgentSpeedometer
|
||||
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-600 border-l-2 border-violet-400 pl-2 space-y-0.5">
|
||||
<div className="font-semibold text-violet-700">
|
||||
LLM-Eskalation eingesetzt:
|
||||
</div>
|
||||
{output.escalation_log.map((e, i) => (
|
||||
<div key={i}>
|
||||
{e.stage} <code className="text-violet-700">{e.model}</code>{' '}
|
||||
· {e.duration_ms} ms{' '}
|
||||
{e.tokens_in ? `· ${e.tokens_in}→${e.tokens_out} tok` : ''}{' '}
|
||||
{e.success ? '✓' : `✗ ${e.error || ''}`}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sortedFindings.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-semibold uppercase text-gray-700">
|
||||
Findings ({sortedFindings.length}) — nach Schwere sortiert
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{visible.map(f => (
|
||||
<AgentFindingCard key={f.check_id} f={f} />
|
||||
))}
|
||||
</div>
|
||||
{sortedFindings.length > INITIAL_VISIBLE && (
|
||||
<button
|
||||
onClick={() => setShowAll(x => !x)}
|
||||
className="text-xs text-blue-600 hover:underline"
|
||||
>
|
||||
{showAll
|
||||
? 'Weniger anzeigen'
|
||||
: `Alle ${sortedFindings.length} anzeigen`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{output.recommendations.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-semibold uppercase text-gray-700">
|
||||
Maßnahmen-Plan ({output.recommendations.length} konsolidiert)
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{output.recommendations.map(r => (
|
||||
<AgentRecommendationCard key={r.recommendation_id} r={r} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,26 +1,16 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* SlotCard — ein Slot im Agent-Test mit Sections:
|
||||
* 1. Header (Slot-Name, duration, Vault-Link)
|
||||
* 2. Was wurde geprüft (MC-Coverage, collapsible)
|
||||
* 3. Speedometer
|
||||
* 4. Eskalationslog (wenn vorhanden)
|
||||
* 5. Findings (sortiert HIGH → LOW)
|
||||
* 6. Recommendations (gerollupt)
|
||||
* AgentSlotCard — ein Slot im Agent-Test: Slot-Header (Name, Dauer,
|
||||
* Konfidenz, Status-Badges, Artefakt-Link) + der geteilte
|
||||
* AgentResultView (Coverage/Speedometer/Findings/Maßnahmen).
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import React from 'react'
|
||||
|
||||
import type { SlotOutput, Severity } from './_agentTypes'
|
||||
import { AgentFindingCard } from './AgentFindingCard'
|
||||
import { AgentMcCoverage } from './AgentMcCoverage'
|
||||
import { AgentRecommendationCard } from './AgentRecommendationCard'
|
||||
import { AgentSpeedometer } from './AgentSpeedometer'
|
||||
|
||||
const SEV_ORDER: Record<Severity, number> = {
|
||||
HIGH: 0, MEDIUM: 1, LOW: 2, INFO: 3,
|
||||
}
|
||||
import type { SlotOutput } from './_agentTypes'
|
||||
import { isOutputSkipped } from './_agentTypes'
|
||||
import { AgentResultView } from './AgentResultView'
|
||||
|
||||
export function AgentSlotCard({
|
||||
slot, output, runId,
|
||||
@@ -29,15 +19,8 @@ export function AgentSlotCard({
|
||||
output: SlotOutput
|
||||
runId: string
|
||||
}) {
|
||||
const [showAll, setShowAll] = useState(false)
|
||||
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 wasSkipped = isOutputSkipped(output)
|
||||
const allGreen = !wasSkipped && output.findings.length === 0
|
||||
const sortedFindings = [...output.findings].sort(
|
||||
(a, b) => SEV_ORDER[a.severity] - SEV_ORDER[b.severity],
|
||||
)
|
||||
const visible = showAll ? sortedFindings : sortedFindings.slice(0, 12)
|
||||
return (
|
||||
<div className="rounded-lg border bg-white p-4 space-y-3 shadow-sm">
|
||||
<div className="flex items-baseline gap-3 flex-wrap">
|
||||
@@ -65,72 +48,7 @@ export function AgentSlotCard({
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{output.notes && (
|
||||
<div className="text-xs text-amber-700 bg-amber-50 px-2 py-1 rounded">
|
||||
Hinweis: {output.notes}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AgentMcCoverage coverage={output.mc_coverage} />
|
||||
|
||||
<AgentSpeedometer
|
||||
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-600 border-l-2 border-violet-400 pl-2 space-y-0.5">
|
||||
<div className="font-semibold text-violet-700">
|
||||
LLM-Eskalation eingesetzt:
|
||||
</div>
|
||||
{output.escalation_log.map((e, i) => (
|
||||
<div key={i}>
|
||||
{e.stage} <code className="text-violet-700">{e.model}</code>{' '}
|
||||
· {e.duration_ms} ms{' '}
|
||||
{e.tokens_in ? `· ${e.tokens_in}→${e.tokens_out} tok` : ''}{' '}
|
||||
{e.success ? '✓' : `✗ ${e.error || ''}`}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sortedFindings.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-semibold uppercase text-gray-700">
|
||||
Findings ({sortedFindings.length}) — nach Schwere sortiert
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{visible.map(f => (
|
||||
<AgentFindingCard key={f.check_id} f={f} />
|
||||
))}
|
||||
</div>
|
||||
{sortedFindings.length > 12 && (
|
||||
<button
|
||||
onClick={() => setShowAll(x => !x)}
|
||||
className="text-xs text-blue-600 hover:underline"
|
||||
>
|
||||
{showAll ? 'Weniger anzeigen' : `Alle ${sortedFindings.length} anzeigen`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{output.recommendations.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-semibold uppercase text-gray-700">
|
||||
Maßnahmen-Plan ({output.recommendations.length} konsolidiert)
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{output.recommendations.map(r => (
|
||||
<AgentRecommendationCard key={r.recommendation_id} r={r} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<AgentResultView output={output} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useCallback } from 'react'
|
||||
import { ChecklistView } from './ChecklistView'
|
||||
import { ComplianceResultTabs } from './ComplianceResultTabs'
|
||||
import { DocumentRow } from './DocumentRow'
|
||||
import { MigrationPanel } from './MigrationPanel'
|
||||
import { PreScanWizard, useScanContext, isContextComplete } from './PreScanWizard'
|
||||
import { DOCUMENT_TYPES, type DocTypeId } from './_document_types'
|
||||
import {
|
||||
@@ -354,106 +353,9 @@ export function ComplianceCheckTab() {
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-3 text-sm text-red-700">{error}</div>
|
||||
)}
|
||||
|
||||
{/* Results */}
|
||||
{/* Results — strukturierte Themen-Tabs (Impressum, …) + Roh-Checkliste */}
|
||||
{results && results.results && (
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm">
|
||||
{/* Business Profile */}
|
||||
{results.business_profile && (
|
||||
<div className="mb-4 p-3 bg-blue-50 border border-blue-200 rounded-lg text-xs">
|
||||
<div className="font-semibold text-blue-900 mb-1">Erkanntes Geschaeftsmodell</div>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-blue-700">
|
||||
<span>Typ: <strong>{results.business_profile.business_type?.toUpperCase()}</strong></span>
|
||||
<span>Branche: {results.business_profile.industry}</span>
|
||||
{results.business_profile.has_online_shop && <span className="text-amber-700">Online-Shop</span>}
|
||||
{results.business_profile.is_regulated_profession && <span className="text-amber-700">Regulierter Beruf ({results.business_profile.regulated_profession_type})</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Extracted Profile — pre-fill suggestion */}
|
||||
{results.extracted_profile?.company_profile && Object.keys(results.extracted_profile.company_profile).length > 0 && (
|
||||
<div className="mb-4 p-3 bg-emerald-50 border border-emerald-200 rounded-lg text-xs">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="font-semibold text-emerald-900">Aus Dokumenten extrahiert</span>
|
||||
<button className="text-emerald-700 hover:text-emerald-900 text-xs font-medium underline"
|
||||
onClick={() => { /* TODO: navigate to company profile with pre-fill */ }}>
|
||||
In Company Profile uebernehmen
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-emerald-700">
|
||||
{results.extracted_profile.company_profile.companyName && (
|
||||
<span>Firma: <strong>{results.extracted_profile.company_profile.companyName}</strong></span>
|
||||
)}
|
||||
{results.extracted_profile.company_profile.legalForm && (
|
||||
<span>Rechtsform: {results.extracted_profile.company_profile.legalForm.toUpperCase()}</span>
|
||||
)}
|
||||
{results.extracted_profile.company_profile.headquartersCity && (
|
||||
<span>Sitz: {results.extracted_profile.company_profile.headquartersZip} {results.extracted_profile.company_profile.headquartersCity}</span>
|
||||
)}
|
||||
{results.extracted_profile.company_profile.dpoEmail && (
|
||||
<span>DSB: {results.extracted_profile.company_profile.dpoEmail}</span>
|
||||
)}
|
||||
{results.extracted_profile.company_profile.ustIdNr && (
|
||||
<span>USt-IdNr: {results.extracted_profile.company_profile.ustIdNr}</span>
|
||||
)}
|
||||
</div>
|
||||
{results.extracted_profile.compliance_scope_hints?.length > 0 && (
|
||||
<div className="mt-2 pt-2 border-t border-emerald-200 text-emerald-600">
|
||||
<span className="font-medium">Scope-Hinweise: </span>
|
||||
{results.extracted_profile.compliance_scope_hints.map((h: any, i: number) => (
|
||||
<span key={i} className="inline-block bg-emerald-100 rounded px-1.5 py-0.5 mr-1 mb-1">
|
||||
{h.source}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Banner Check Result */}
|
||||
{results.banner_result && (
|
||||
<div className={`mb-4 p-3 rounded-lg border text-xs ${
|
||||
results.banner_result.violations > 0
|
||||
? 'bg-amber-50 border-amber-200'
|
||||
: results.banner_result.detected
|
||||
? 'bg-green-50 border-green-200'
|
||||
: 'bg-gray-50 border-gray-200'
|
||||
}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`w-2 h-2 rounded-full ${
|
||||
results.banner_result.violations > 0 ? 'bg-amber-500'
|
||||
: results.banner_result.detected ? 'bg-green-500' : 'bg-gray-400'
|
||||
}`} />
|
||||
<span className="font-semibold text-gray-900">
|
||||
Cookie-Banner-Check (automatisch)
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 text-gray-600 ml-4">
|
||||
{results.banner_result.detected ? (
|
||||
<>
|
||||
Banner erkannt{results.banner_result.provider ? ` (${results.banner_result.provider})` : ''}.
|
||||
{results.banner_result.violations > 0
|
||||
? ` ${results.banner_result.violations} Auffaelligkeit${results.banner_result.violations !== 1 ? 'en' : ''} gefunden.`
|
||||
: ' Keine Auffaelligkeiten.'}
|
||||
</>
|
||||
) : (
|
||||
'Kein Cookie-Banner erkannt oder Banner-Check nicht moeglich.'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ChecklistView results={results.results} />
|
||||
|
||||
{/* Email + Migration + Full-audit */}
|
||||
{results.email_status && (
|
||||
<div className="mt-3 text-xs text-gray-500 flex items-center gap-2">
|
||||
<span className={`w-2 h-2 rounded-full ${results.email_status === 'sent' ? 'bg-green-400' : 'bg-gray-300'}`} />
|
||||
E-Mail: {results.email_status === 'sent' ? 'Gesendet' : results.email_status}
|
||||
</div>
|
||||
)}
|
||||
{results.check_id && <MigrationPanel checkId={results.check_id} />}
|
||||
</div>
|
||||
<ComplianceResultTabs results={results} />
|
||||
)}
|
||||
|
||||
{/* History */}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* ComplianceResultTabs — standardisierte Ergebnis-Darstellung des
|
||||
* Compliance-Checks: Kopf-Boxen (erkanntes Profil + Banner) ÜBER einer
|
||||
* Tab-Leiste. Ein Tab je Themen-Agent (result.agent_outputs, P1: Impressum)
|
||||
* via AgentResultTab + ein "Alle Checks (roh)"-Tab mit der bisherigen
|
||||
* ChecklistView — so geht nichts verloren, während die Themen-Tabs wachsen.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react'
|
||||
|
||||
import type { SlotOutput } from './_agentTypes'
|
||||
import { AgentResultTab } from './AgentResultTab'
|
||||
import { ChecklistView } from './ChecklistView'
|
||||
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])
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm space-y-4">
|
||||
{/* Kopf-Boxen über den Tabs */}
|
||||
{results.business_profile && (
|
||||
<div className="p-3 bg-blue-50 border border-blue-200 rounded-lg text-xs">
|
||||
<div className="font-semibold text-blue-900 mb-1">Erkanntes Geschaeftsmodell</div>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-blue-700">
|
||||
<span>Typ: <strong>{results.business_profile.business_type?.toUpperCase()}</strong></span>
|
||||
<span>Branche: {results.business_profile.industry}</span>
|
||||
{results.business_profile.has_online_shop && <span className="text-amber-700">Online-Shop</span>}
|
||||
{results.business_profile.is_regulated_profession && <span className="text-amber-700">Regulierter Beruf ({results.business_profile.regulated_profession_type})</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{results.extracted_profile?.company_profile && Object.keys(results.extracted_profile.company_profile).length > 0 && (
|
||||
<div className="p-3 bg-emerald-50 border border-emerald-200 rounded-lg text-xs">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="font-semibold text-emerald-900">Aus Dokumenten extrahiert</span>
|
||||
<button className="text-emerald-700 hover:text-emerald-900 text-xs font-medium underline"
|
||||
onClick={() => { /* TODO: navigate to company profile with pre-fill */ }}>
|
||||
In Company Profile uebernehmen
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-emerald-700">
|
||||
{results.extracted_profile.company_profile.companyName && (
|
||||
<span>Firma: <strong>{results.extracted_profile.company_profile.companyName}</strong></span>
|
||||
)}
|
||||
{results.extracted_profile.company_profile.legalForm && (
|
||||
<span>Rechtsform: {results.extracted_profile.company_profile.legalForm.toUpperCase()}</span>
|
||||
)}
|
||||
{results.extracted_profile.company_profile.headquartersCity && (
|
||||
<span>Sitz: {results.extracted_profile.company_profile.headquartersZip} {results.extracted_profile.company_profile.headquartersCity}</span>
|
||||
)}
|
||||
{results.extracted_profile.company_profile.dpoEmail && (
|
||||
<span>DSB: {results.extracted_profile.company_profile.dpoEmail}</span>
|
||||
)}
|
||||
{results.extracted_profile.company_profile.ustIdNr && (
|
||||
<span>USt-IdNr: {results.extracted_profile.company_profile.ustIdNr}</span>
|
||||
)}
|
||||
</div>
|
||||
{results.extracted_profile.compliance_scope_hints?.length > 0 && (
|
||||
<div className="mt-2 pt-2 border-t border-emerald-200 text-emerald-600">
|
||||
<span className="font-medium">Scope-Hinweise: </span>
|
||||
{results.extracted_profile.compliance_scope_hints.map((h: any, i: number) => (
|
||||
<span key={i} className="inline-block bg-emerald-100 rounded px-1.5 py-0.5 mr-1 mb-1">
|
||||
{h.source}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{results.banner_result && (
|
||||
<div className={`p-3 rounded-lg border text-xs ${
|
||||
results.banner_result.violations > 0
|
||||
? 'bg-amber-50 border-amber-200'
|
||||
: results.banner_result.detected
|
||||
? 'bg-green-50 border-green-200'
|
||||
: 'bg-gray-50 border-gray-200'
|
||||
}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`w-2 h-2 rounded-full ${
|
||||
results.banner_result.violations > 0 ? 'bg-amber-500'
|
||||
: results.banner_result.detected ? 'bg-green-500' : 'bg-gray-400'
|
||||
}`} />
|
||||
<span className="font-semibold text-gray-900">
|
||||
Cookie-Banner-Check (automatisch)
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 text-gray-600 ml-4">
|
||||
{results.banner_result.detected ? (
|
||||
<>
|
||||
Banner erkannt{results.banner_result.provider ? ` (${results.banner_result.provider})` : ''}.
|
||||
{results.banner_result.violations > 0
|
||||
? ` ${results.banner_result.violations} Auffaelligkeit${results.banner_result.violations !== 1 ? 'en' : ''} gefunden.`
|
||||
: ' Keine Auffaelligkeiten.'}
|
||||
</>
|
||||
) : (
|
||||
'Kein Cookie-Banner erkannt oder Banner-Check nicht moeglich.'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab-Leiste — Themen-Agenten + Roh-Checkliste */}
|
||||
<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
|
||||
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>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Tab-Inhalt */}
|
||||
{active === 'raw' ? (
|
||||
<ChecklistView results={results.results} />
|
||||
) : agentOutputs[active] ? (
|
||||
<AgentResultTab
|
||||
topicLabel={TOPIC_LABELS[active] || active}
|
||||
output={agentOutputs[active]}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* Check-Footer (themenübergreifend) */}
|
||||
{results.email_status && (
|
||||
<div className="text-xs text-gray-500 flex items-center gap-2 border-t border-gray-100 pt-3">
|
||||
<span className={`w-2 h-2 rounded-full ${results.email_status === 'sent' ? 'bg-green-400' : 'bg-gray-300'}`} />
|
||||
E-Mail: {results.email_status === 'sent' ? 'Gesendet' : results.email_status}
|
||||
</div>
|
||||
)}
|
||||
{results.check_id && <MigrationPanel checkId={results.check_id} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
|
||||
import { AgentResultTab } from '../AgentResultTab'
|
||||
import { ComplianceResultTabs } from '../ComplianceResultTabs'
|
||||
import type { SlotOutput } from '../_agentTypes'
|
||||
|
||||
const IMPRESSUM_OUTPUT: SlotOutput = {
|
||||
agent: 'impressum',
|
||||
agent_version: '3.0',
|
||||
duration_ms: 42,
|
||||
confidence: 0.9,
|
||||
notes: '12 §5-TMG-MCs geprüft · 2 Pflichtangabe(n) offen',
|
||||
findings: [
|
||||
{
|
||||
check_id: 'IMP-kontakt_email', agent: 'impressum', agent_version: '3.0',
|
||||
field_id: 'kontakt_email', severity: 'HIGH',
|
||||
severity_reason: 'pflichtangabe_missing',
|
||||
title: 'Pflichtangabe fehlt: Email-Adresse',
|
||||
norm: '§ 5 Abs. 1 Nr. 2 TMG', evidence: '',
|
||||
action: 'Pflichtangabe ergänzen: Email-Adresse.', confidence: 0.9,
|
||||
sources: [{ source_type: 'regex', source_id: 'IMP-MC-002', confidence: 0.9 }],
|
||||
},
|
||||
{
|
||||
check_id: 'IMP-kontakt_telefon', agent: 'impressum', agent_version: '3.0',
|
||||
field_id: 'kontakt_telefon', severity: 'MEDIUM',
|
||||
severity_reason: 'pflichtangabe_missing',
|
||||
title: 'Pflichtangabe fehlt: Telefon',
|
||||
norm: '§ 5 Abs. 1 Nr. 2 TMG', evidence: '',
|
||||
action: 'Pflichtangabe ergänzen: Telefon.', confidence: 0.9,
|
||||
sources: [{ source_type: 'regex', source_id: 'IMP-MC-003', confidence: 0.9 }],
|
||||
},
|
||||
],
|
||||
recommendations: [
|
||||
{
|
||||
recommendation_id: 'rec1', title: 'Pflichtangaben ergänzen',
|
||||
body: 'Email und Telefon im Impressum ergänzen.', severity: 'HIGH',
|
||||
related_finding_ids: ['IMP-kontakt_email', 'IMP-kontakt_telefon'],
|
||||
estimated_effort_hours: 0.5,
|
||||
},
|
||||
],
|
||||
mc_coverage: [
|
||||
{ mc_id: 'IMP-MC-002', status: 'high', reason: 'kein Pattern-Treffer' },
|
||||
{ mc_id: 'IMP-MC-003', status: 'medium', reason: 'kein Pattern-Treffer' },
|
||||
{ mc_id: 'IMP-MC-001', status: 'ok', reason: 'Pattern-Treffer' },
|
||||
],
|
||||
escalation_log: [],
|
||||
mc_total: 3, mc_ok: 1, mc_na: 0, mc_high: 1, mc_medium: 1, mc_low: 0,
|
||||
}
|
||||
|
||||
describe('AgentResultTab', () => {
|
||||
it('rendert Findings nach Severity + Maßnahmen + Coverage', () => {
|
||||
render(<AgentResultTab topicLabel="Impressum" output={IMPRESSUM_OUTPUT} />)
|
||||
// Themen-Header + Severity-Ampel
|
||||
expect(screen.getByRole('heading', { name: 'Impressum' })).toBeInTheDocument()
|
||||
expect(screen.getByText('1 HIGH')).toBeInTheDocument()
|
||||
expect(screen.getByText('1 MEDIUM')).toBeInTheDocument()
|
||||
// Findings-Sektion mit Titeln
|
||||
expect(screen.getByText(/Findings \(2\)/)).toBeInTheDocument()
|
||||
expect(screen.getByText('Pflichtangabe fehlt: Email-Adresse')).toBeInTheDocument()
|
||||
expect(screen.getByText('Pflichtangabe fehlt: Telefon')).toBeInTheDocument()
|
||||
// Abstellmaßnahme (action) am HIGH-Finding
|
||||
expect(screen.getByText('Pflicht-Maßnahme')).toBeInTheDocument()
|
||||
expect(screen.getByText('Pflichtangabe ergänzen: Email-Adresse.')).toBeInTheDocument()
|
||||
// Konsolidierter Maßnahmen-Plan
|
||||
expect(screen.getByText(/Maßnahmen-Plan \(1 konsolidiert\)/)).toBeInTheDocument()
|
||||
expect(screen.getByText('Pflichtangaben ergänzen')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
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: '',
|
||||
}
|
||||
|
||||
describe('ComplianceResultTabs', () => {
|
||||
it('zeigt den Impressum-Tab zuerst und wechselt auf die Roh-Checkliste', () => {
|
||||
const result = {
|
||||
agent_outputs: { impressum: IMPRESSUM_OUTPUT },
|
||||
results: [DOC_RESULT],
|
||||
}
|
||||
render(<ComplianceResultTabs results={result} />)
|
||||
// beide Tabs vorhanden
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -151,3 +151,10 @@ export const SEVERITY_BG: Record<Severity, string> = {
|
||||
LOW: '#eff6ff',
|
||||
INFO: '#f8fafc',
|
||||
}
|
||||
|
||||
// Ein Output gilt als "übersprungen" (Dokument nicht ladbar), wenn MCs
|
||||
// existieren, aber keiner ausgewertet wurde.
|
||||
export function isOutputSkipped(o: SlotOutput): boolean {
|
||||
return o.mc_total > 0 && o.mc_ok === 0 && o.mc_na === 0 &&
|
||||
o.mc_high === 0 && o.mc_medium === 0 && o.mc_low === 0
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user