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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Run registered v3 specialist agents and surface their structured
|
||||
AgentOutput per topic for the standardized result tabs.
|
||||
|
||||
Additive to the legacy B-wiring HTML (`_b18_wiring`): this does NOT
|
||||
replace it — it puts a clean, typed `AgentOutput` into
|
||||
`state["agent_outputs"][topic]`, which `_phase_f_persist` forwards into
|
||||
the API result so the frontend can render a per-topic tab.
|
||||
|
||||
Phase 1 ships only impressum; the topic map extends to cookie / vendor /
|
||||
… as those agents get wired (same contract, no code change here beyond
|
||||
the map). Once the tabs are the source of truth, B18's v1 path retires.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from compliance.services.specialist_agents import REGISTRY, AgentInput
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# topic key (matches state["doc_texts"]) -> registered agent_id
|
||||
_TOPIC_AGENTS: dict[str, str] = {
|
||||
"impressum": "impressum",
|
||||
}
|
||||
|
||||
_MIN_TEXT = 100
|
||||
|
||||
|
||||
def _derive_scope(profile_dict: dict) -> list[str]:
|
||||
"""Business-scope aus dem erkannten Profil — identisch zu B18, damit
|
||||
der Tab denselben Scope sieht wie die bestehende Auswertung. Das
|
||||
Rechtsform-Gate kommt in einer späteren Phase (eigene Klassifizierung)."""
|
||||
scope: set[str] = set()
|
||||
if profile_dict.get("has_online_shop"):
|
||||
scope.add("ecommerce")
|
||||
if profile_dict.get("is_regulated_profession"):
|
||||
scope.add("regulated_profession")
|
||||
if profile_dict.get("industry") in ("insurance", "Finance", "finance"):
|
||||
scope.add("insurance")
|
||||
return sorted(scope)
|
||||
|
||||
|
||||
async def run_agent_outputs(state: dict) -> None:
|
||||
"""Für jedes Topic mit registriertem v3-Agent + ausreichend Text:
|
||||
Agent laufen lassen und den strukturierten AgentOutput ablegen."""
|
||||
doc_texts = state.get("doc_texts") or {}
|
||||
profile_dict = state.get("profile_dict") or {}
|
||||
req = state.get("req")
|
||||
company_name = (
|
||||
(getattr(req, "company_name", None) or "")
|
||||
or (state.get("extracted_profile") or {}).get("company_name", "")
|
||||
or state.get("site_name", "")
|
||||
)
|
||||
origin_domain = (
|
||||
getattr(req, "origin_domain", None) or ""
|
||||
) or state.get("domain", "")
|
||||
scope = _derive_scope(profile_dict)
|
||||
|
||||
outputs: dict[str, dict] = state.get("agent_outputs") or {}
|
||||
for topic, agent_id in _TOPIC_AGENTS.items():
|
||||
text = (doc_texts.get(topic) or "").strip()
|
||||
if len(text) < _MIN_TEXT:
|
||||
continue
|
||||
agent = REGISTRY.get(agent_id)
|
||||
if agent is None:
|
||||
logger.warning("agent_outputs: agent '%s' not registered", agent_id)
|
||||
continue
|
||||
try:
|
||||
out = await agent.evaluate(AgentInput(
|
||||
doc_type=topic,
|
||||
text=text,
|
||||
business_scope=scope,
|
||||
company_name=company_name,
|
||||
origin_domain=origin_domain,
|
||||
))
|
||||
outputs[topic] = out.model_dump(mode="json")
|
||||
logger.info(
|
||||
"agent_outputs[%s]: %d findings, confidence %.2f",
|
||||
topic, len(out.findings), out.confidence,
|
||||
)
|
||||
except Exception as e: # noqa: BLE001 — best-effort, never break the run
|
||||
logger.warning("agent_outputs[%s] failed: %s", topic, e)
|
||||
|
||||
if outputs:
|
||||
state["agent_outputs"] = outputs
|
||||
@@ -16,6 +16,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from ._agent_outputs import run_agent_outputs
|
||||
from ._b1_wiring import run_b1
|
||||
from ._b3_wiring import run_b3
|
||||
from ._b4_wiring import run_b4
|
||||
@@ -95,6 +96,9 @@ async def run_compliance_check(check_id: str, req) -> None:
|
||||
run_b16(state) # Footer-Label-vs-URL-Slug-Drift
|
||||
await run_b17(state) # Audit-Walk-Video (Beweis-Aufzeichnung)
|
||||
await run_b18(state) # Impressum-Specialist-Agent (Pattern+LLM)
|
||||
# Strukturierter v3-AgentOutput pro Thema → standardisierte
|
||||
# Ergebnis-Tabs im Frontend (additiv zu B18-HTML).
|
||||
await run_agent_outputs(state)
|
||||
run_b19(state) # Cookie-Coherence (Salesforce-as-essential)
|
||||
await run_b20(state) # Legacy-URL-Discovery (Sitemap+Wayback)
|
||||
run_b22(state) # Cross-Domain-Legal-Doc-Hosting (Elli/LogPay)
|
||||
|
||||
@@ -93,6 +93,10 @@ def run_phase_f(state: dict) -> None:
|
||||
"legacy_urls": state.get("legacy_url_html", ""),
|
||||
},
|
||||
"legacy_url_inventory": state.get("legacy_url_inventory") or None,
|
||||
# Strukturierter v3-AgentOutput pro Thema (impressum, …) für die
|
||||
# standardisierten Ergebnis-Tabs im Frontend. Additiv; legacy
|
||||
# clients ignorieren unbekannte Felder.
|
||||
"agent_outputs": state.get("agent_outputs") or {},
|
||||
}
|
||||
|
||||
_compliance_check_jobs[check_id]["status"] = "completed"
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Phase 1: der Compliance-Check legt einen strukturierten v3-AgentOutput
|
||||
pro Thema in state['agent_outputs'][topic] ab (für die Ergebnis-Tabs).
|
||||
|
||||
Offline + deterministisch: die einzige LLM-Stelle im registrierten
|
||||
ImpressumAgent ist `validate_present` (Semantic-Validator) — gemockt.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
# Impressum mit Name+Anschrift+Geschäftsführer, aber OHNE Email, Telefon,
|
||||
# Handelsregister, USt-IdNr → erzwingt Findings (alle mit norm + action).
|
||||
IMPRESSUM_TEXT = (
|
||||
"Angaben gemäß § 5 TMG\n\n"
|
||||
"Musterfirma GmbH\n"
|
||||
"Musterstraße 1\n"
|
||||
"12345 Berlin\n\n"
|
||||
"Vertreten durch den Geschäftsführer: Max Mustermann\n\n"
|
||||
"Wir betreiben einen Online-Shop für Musterprodukte aller Art. "
|
||||
"Weitere Informationen finden Sie auf unserer Website.\n"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _llm_offline(monkeypatch):
|
||||
"""Semantic-Validator (LLM) neutralisieren → rein deterministischer Lauf."""
|
||||
async def _no_validate(*_a, **_kw):
|
||||
return {}
|
||||
monkeypatch.setattr(
|
||||
"compliance.services.specialist_agents.impressum.agent.validate_present",
|
||||
_no_validate,
|
||||
raising=False,
|
||||
)
|
||||
|
||||
|
||||
def test_run_agent_outputs_populates_structured_impressum():
|
||||
from compliance.api.agent_check._agent_outputs import run_agent_outputs
|
||||
|
||||
state = {
|
||||
"doc_texts": {"impressum": IMPRESSUM_TEXT},
|
||||
"profile_dict": {"has_online_shop": True},
|
||||
"req": None,
|
||||
"extracted_profile": {"company_name": "Musterfirma GmbH"},
|
||||
"site_name": "musterfirma.de",
|
||||
"domain": "musterfirma.de",
|
||||
}
|
||||
asyncio.run(run_agent_outputs(state))
|
||||
|
||||
out = (state.get("agent_outputs") or {}).get("impressum")
|
||||
assert out is not None, "impressum AgentOutput muss im Ergebnis liegen"
|
||||
assert out["agent"] == "impressum"
|
||||
assert isinstance(out["findings"], list)
|
||||
# Unvollständiges Impressum → mind. ein Finding, jedes mit Abstellmaßnahme
|
||||
assert out["findings"], "erwarte Findings für ein unvollständiges Impressum"
|
||||
assert all(f.get("action") for f in out["findings"]), \
|
||||
"jedes Finding trägt eine Abstellmaßnahme (action)"
|
||||
# Auditfest: Rechtsgrundlage + Quelle je Finding
|
||||
assert all(f.get("norm") for f in out["findings"])
|
||||
assert all(f.get("sources") for f in out["findings"])
|
||||
# Aggregat-Felder fürs Speedometer vorberechnet
|
||||
assert out["mc_total"] >= 1
|
||||
# Linter-sauber: keine verbotenen Disclaimer-Begriffe im Output
|
||||
blob = str(out).lower()
|
||||
for term in ("rechtssicher", "garantiert", "gesetzeskonform"):
|
||||
assert term not in blob
|
||||
|
||||
|
||||
def test_run_agent_outputs_skips_short_text():
|
||||
from compliance.api.agent_check._agent_outputs import run_agent_outputs
|
||||
|
||||
state = {
|
||||
"doc_texts": {"impressum": "zu kurz"},
|
||||
"profile_dict": {},
|
||||
"req": None,
|
||||
}
|
||||
asyncio.run(run_agent_outputs(state))
|
||||
assert not (state.get("agent_outputs") or {}).get("impressum")
|
||||
Reference in New Issue
Block a user