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'
|
'use client'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SlotCard — ein Slot im Agent-Test mit Sections:
|
* AgentSlotCard — ein Slot im Agent-Test: Slot-Header (Name, Dauer,
|
||||||
* 1. Header (Slot-Name, duration, Vault-Link)
|
* Konfidenz, Status-Badges, Artefakt-Link) + der geteilte
|
||||||
* 2. Was wurde geprüft (MC-Coverage, collapsible)
|
* AgentResultView (Coverage/Speedometer/Findings/Maßnahmen).
|
||||||
* 3. Speedometer
|
|
||||||
* 4. Eskalationslog (wenn vorhanden)
|
|
||||||
* 5. Findings (sortiert HIGH → LOW)
|
|
||||||
* 6. Recommendations (gerollupt)
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState } from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
import type { SlotOutput, Severity } from './_agentTypes'
|
import type { SlotOutput } from './_agentTypes'
|
||||||
import { AgentFindingCard } from './AgentFindingCard'
|
import { isOutputSkipped } from './_agentTypes'
|
||||||
import { AgentMcCoverage } from './AgentMcCoverage'
|
import { AgentResultView } from './AgentResultView'
|
||||||
import { AgentRecommendationCard } from './AgentRecommendationCard'
|
|
||||||
import { AgentSpeedometer } from './AgentSpeedometer'
|
|
||||||
|
|
||||||
const SEV_ORDER: Record<Severity, number> = {
|
|
||||||
HIGH: 0, MEDIUM: 1, LOW: 2, INFO: 3,
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AgentSlotCard({
|
export function AgentSlotCard({
|
||||||
slot, output, runId,
|
slot, output, runId,
|
||||||
@@ -29,15 +19,8 @@ export function AgentSlotCard({
|
|||||||
output: SlotOutput
|
output: SlotOutput
|
||||||
runId: string
|
runId: string
|
||||||
}) {
|
}) {
|
||||||
const [showAll, setShowAll] = useState(false)
|
const wasSkipped = isOutputSkipped(output)
|
||||||
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
|
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 (
|
return (
|
||||||
<div className="rounded-lg border bg-white p-4 space-y-3 shadow-sm">
|
<div className="rounded-lg border bg-white p-4 space-y-3 shadow-sm">
|
||||||
<div className="flex items-baseline gap-3 flex-wrap">
|
<div className="flex items-baseline gap-3 flex-wrap">
|
||||||
@@ -65,72 +48,7 @@ export function AgentSlotCard({
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{output.notes && (
|
<AgentResultView output={output} />
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import React, { useState, useCallback } from 'react'
|
import React, { useState, useCallback } from 'react'
|
||||||
import { ChecklistView } from './ChecklistView'
|
import { ComplianceResultTabs } from './ComplianceResultTabs'
|
||||||
import { DocumentRow } from './DocumentRow'
|
import { DocumentRow } from './DocumentRow'
|
||||||
import { MigrationPanel } from './MigrationPanel'
|
|
||||||
import { PreScanWizard, useScanContext, isContextComplete } from './PreScanWizard'
|
import { PreScanWizard, useScanContext, isContextComplete } from './PreScanWizard'
|
||||||
import { DOCUMENT_TYPES, type DocTypeId } from './_document_types'
|
import { DOCUMENT_TYPES, type DocTypeId } from './_document_types'
|
||||||
import {
|
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>
|
<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 && (
|
{results && results.results && (
|
||||||
<div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm">
|
<ComplianceResultTabs results={results} />
|
||||||
{/* 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>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* History */}
|
{/* 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',
|
LOW: '#eff6ff',
|
||||||
INFO: '#f8fafc',
|
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
|
import logging
|
||||||
|
|
||||||
|
from ._agent_outputs import run_agent_outputs
|
||||||
from ._b1_wiring import run_b1
|
from ._b1_wiring import run_b1
|
||||||
from ._b3_wiring import run_b3
|
from ._b3_wiring import run_b3
|
||||||
from ._b4_wiring import run_b4
|
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
|
run_b16(state) # Footer-Label-vs-URL-Slug-Drift
|
||||||
await run_b17(state) # Audit-Walk-Video (Beweis-Aufzeichnung)
|
await run_b17(state) # Audit-Walk-Video (Beweis-Aufzeichnung)
|
||||||
await run_b18(state) # Impressum-Specialist-Agent (Pattern+LLM)
|
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)
|
run_b19(state) # Cookie-Coherence (Salesforce-as-essential)
|
||||||
await run_b20(state) # Legacy-URL-Discovery (Sitemap+Wayback)
|
await run_b20(state) # Legacy-URL-Discovery (Sitemap+Wayback)
|
||||||
run_b22(state) # Cross-Domain-Legal-Doc-Hosting (Elli/LogPay)
|
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_urls": state.get("legacy_url_html", ""),
|
||||||
},
|
},
|
||||||
"legacy_url_inventory": state.get("legacy_url_inventory") or None,
|
"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"
|
_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