diff --git a/admin-compliance/app/sdk/agent/_components/AgentResultTab.tsx b/admin-compliance/app/sdk/agent/_components/AgentResultTab.tsx new file mode 100644 index 00000000..c90f311c --- /dev/null +++ b/admin-compliance/app/sdk/agent/_components/AgentResultTab.tsx @@ -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 ( +
+
+

{topicLabel}

+ + Konfidenz {(output.confidence * 100).toFixed(0)}% + + {high > 0 && ( + + {high} HIGH + + )} + {medium > 0 && ( + + {medium} MEDIUM + + )} + {low > 0 && ( + + {low} LOW + + )} + {allGreen && ( + + Alle anwendbaren MCs erfüllt + + )} + {wasSkipped && ( + + Dokument nicht geladen + + )} +
+ + +
+ ) +} diff --git a/admin-compliance/app/sdk/agent/_components/AgentResultView.tsx b/admin-compliance/app/sdk/agent/_components/AgentResultView.tsx new file mode 100644 index 00000000..bfa853d7 --- /dev/null +++ b/admin-compliance/app/sdk/agent/_components/AgentResultView.tsx @@ -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 = { + 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 ( +
+ {output.notes && ( +
+ Hinweis: {output.notes} +
+ )} + + + + + + {output.escalation_log.length > 0 && ( +
+
+ LLM-Eskalation eingesetzt: +
+ {output.escalation_log.map((e, i) => ( +
+ {e.stage} {e.model}{' '} + · {e.duration_ms} ms{' '} + {e.tokens_in ? `· ${e.tokens_in}→${e.tokens_out} tok` : ''}{' '} + {e.success ? '✓' : `✗ ${e.error || ''}`} +
+ ))} +
+ )} + + {sortedFindings.length > 0 && ( +
+
+ Findings ({sortedFindings.length}) — nach Schwere sortiert +
+
+ {visible.map(f => ( + + ))} +
+ {sortedFindings.length > INITIAL_VISIBLE && ( + + )} +
+ )} + + {output.recommendations.length > 0 && ( +
+
+ Maßnahmen-Plan ({output.recommendations.length} konsolidiert) +
+
+ {output.recommendations.map(r => ( + + ))} +
+
+ )} +
+ ) +} diff --git a/admin-compliance/app/sdk/agent/_components/AgentSlotCard.tsx b/admin-compliance/app/sdk/agent/_components/AgentSlotCard.tsx index cdc3c62c..b2bedd40 100644 --- a/admin-compliance/app/sdk/agent/_components/AgentSlotCard.tsx +++ b/admin-compliance/app/sdk/agent/_components/AgentSlotCard.tsx @@ -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 = { - 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 (
@@ -65,72 +48,7 @@ export function AgentSlotCard({
- {output.notes && ( -
- Hinweis: {output.notes} -
- )} - - - - - - {output.escalation_log.length > 0 && ( -
-
- LLM-Eskalation eingesetzt: -
- {output.escalation_log.map((e, i) => ( -
- {e.stage} {e.model}{' '} - · {e.duration_ms} ms{' '} - {e.tokens_in ? `· ${e.tokens_in}→${e.tokens_out} tok` : ''}{' '} - {e.success ? '✓' : `✗ ${e.error || ''}`} -
- ))} -
- )} - - {sortedFindings.length > 0 && ( -
-
- Findings ({sortedFindings.length}) — nach Schwere sortiert -
-
- {visible.map(f => ( - - ))} -
- {sortedFindings.length > 12 && ( - - )} -
- )} - - {output.recommendations.length > 0 && ( -
-
- Maßnahmen-Plan ({output.recommendations.length} konsolidiert) -
-
- {output.recommendations.map(r => ( - - ))} -
-
- )} +
) } diff --git a/admin-compliance/app/sdk/agent/_components/ComplianceCheckTab.tsx b/admin-compliance/app/sdk/agent/_components/ComplianceCheckTab.tsx index de2e2576..c8d8e6f7 100644 --- a/admin-compliance/app/sdk/agent/_components/ComplianceCheckTab.tsx +++ b/admin-compliance/app/sdk/agent/_components/ComplianceCheckTab.tsx @@ -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() {
{error}
)} - {/* Results */} + {/* Results — strukturierte Themen-Tabs (Impressum, …) + Roh-Checkliste */} {results && results.results && ( -
- {/* Business Profile */} - {results.business_profile && ( -
-
Erkanntes Geschaeftsmodell
-
- Typ: {results.business_profile.business_type?.toUpperCase()} - Branche: {results.business_profile.industry} - {results.business_profile.has_online_shop && Online-Shop} - {results.business_profile.is_regulated_profession && Regulierter Beruf ({results.business_profile.regulated_profession_type})} -
-
- )} - - {/* Extracted Profile — pre-fill suggestion */} - {results.extracted_profile?.company_profile && Object.keys(results.extracted_profile.company_profile).length > 0 && ( -
-
- Aus Dokumenten extrahiert - -
-
- {results.extracted_profile.company_profile.companyName && ( - Firma: {results.extracted_profile.company_profile.companyName} - )} - {results.extracted_profile.company_profile.legalForm && ( - Rechtsform: {results.extracted_profile.company_profile.legalForm.toUpperCase()} - )} - {results.extracted_profile.company_profile.headquartersCity && ( - Sitz: {results.extracted_profile.company_profile.headquartersZip} {results.extracted_profile.company_profile.headquartersCity} - )} - {results.extracted_profile.company_profile.dpoEmail && ( - DSB: {results.extracted_profile.company_profile.dpoEmail} - )} - {results.extracted_profile.company_profile.ustIdNr && ( - USt-IdNr: {results.extracted_profile.company_profile.ustIdNr} - )} -
- {results.extracted_profile.compliance_scope_hints?.length > 0 && ( -
- Scope-Hinweise: - {results.extracted_profile.compliance_scope_hints.map((h: any, i: number) => ( - - {h.source} - - ))} -
- )} -
- )} - - {/* Banner Check Result */} - {results.banner_result && ( -
0 - ? 'bg-amber-50 border-amber-200' - : results.banner_result.detected - ? 'bg-green-50 border-green-200' - : 'bg-gray-50 border-gray-200' - }`}> -
- 0 ? 'bg-amber-500' - : results.banner_result.detected ? 'bg-green-500' : 'bg-gray-400' - }`} /> - - Cookie-Banner-Check (automatisch) - -
-
- {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.' - )} -
-
- )} - - - - {/* Email + Migration + Full-audit */} - {results.email_status && ( -
- - E-Mail: {results.email_status === 'sent' ? 'Gesendet' : results.email_status} -
- )} - {results.check_id && } -
+ )} {/* History */} diff --git a/admin-compliance/app/sdk/agent/_components/ComplianceResultTabs.tsx b/admin-compliance/app/sdk/agent/_components/ComplianceResultTabs.tsx new file mode 100644 index 00000000..89b9aa15 --- /dev/null +++ b/admin-compliance/app/sdk/agent/_components/ComplianceResultTabs.tsx @@ -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 = { + impressum: 'Impressum', + cookie: 'Cookie-Banner', +} + +export function ComplianceResultTabs({ results }: { results: any }) { + const agentOutputs: Record = results.agent_outputs || {} + const topicKeys = Object.keys(agentOutputs) + const tabs = [...topicKeys, 'raw'] + const [active, setActive] = useState(tabs[0]) + + return ( +
+ {/* Kopf-Boxen über den Tabs */} + {results.business_profile && ( +
+
Erkanntes Geschaeftsmodell
+
+ Typ: {results.business_profile.business_type?.toUpperCase()} + Branche: {results.business_profile.industry} + {results.business_profile.has_online_shop && Online-Shop} + {results.business_profile.is_regulated_profession && Regulierter Beruf ({results.business_profile.regulated_profession_type})} +
+
+ )} + + {results.extracted_profile?.company_profile && Object.keys(results.extracted_profile.company_profile).length > 0 && ( +
+
+ Aus Dokumenten extrahiert + +
+
+ {results.extracted_profile.company_profile.companyName && ( + Firma: {results.extracted_profile.company_profile.companyName} + )} + {results.extracted_profile.company_profile.legalForm && ( + Rechtsform: {results.extracted_profile.company_profile.legalForm.toUpperCase()} + )} + {results.extracted_profile.company_profile.headquartersCity && ( + Sitz: {results.extracted_profile.company_profile.headquartersZip} {results.extracted_profile.company_profile.headquartersCity} + )} + {results.extracted_profile.company_profile.dpoEmail && ( + DSB: {results.extracted_profile.company_profile.dpoEmail} + )} + {results.extracted_profile.company_profile.ustIdNr && ( + USt-IdNr: {results.extracted_profile.company_profile.ustIdNr} + )} +
+ {results.extracted_profile.compliance_scope_hints?.length > 0 && ( +
+ Scope-Hinweise: + {results.extracted_profile.compliance_scope_hints.map((h: any, i: number) => ( + + {h.source} + + ))} +
+ )} +
+ )} + + {results.banner_result && ( +
0 + ? 'bg-amber-50 border-amber-200' + : results.banner_result.detected + ? 'bg-green-50 border-green-200' + : 'bg-gray-50 border-gray-200' + }`}> +
+ 0 ? 'bg-amber-500' + : results.banner_result.detected ? 'bg-green-500' : 'bg-gray-400' + }`} /> + + Cookie-Banner-Check (automatisch) + +
+
+ {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.' + )} +
+
+ )} + + {/* Tab-Leiste — Themen-Agenten + Roh-Checkliste */} +
+ {tabs.map(t => { + const count = t !== 'raw' ? (agentOutputs[t]?.findings?.length ?? 0) : 0 + return ( + + ) + })} +
+ + {/* Tab-Inhalt */} + {active === 'raw' ? ( + + ) : agentOutputs[active] ? ( + + ) : null} + + {/* Check-Footer (themenübergreifend) */} + {results.email_status && ( +
+ + E-Mail: {results.email_status === 'sent' ? 'Gesendet' : results.email_status} +
+ )} + {results.check_id && } +
+ ) +} diff --git a/admin-compliance/app/sdk/agent/_components/__tests__/AgentResultTab.test.tsx b/admin-compliance/app/sdk/agent/_components/__tests__/AgentResultTab.test.tsx new file mode 100644 index 00000000..5e522d46 --- /dev/null +++ b/admin-compliance/app/sdk/agent/_components/__tests__/AgentResultTab.test.tsx @@ -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() + // 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() + // 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() + }) +}) diff --git a/admin-compliance/app/sdk/agent/_components/_agentTypes.ts b/admin-compliance/app/sdk/agent/_components/_agentTypes.ts index aa353a66..7c3805b4 100644 --- a/admin-compliance/app/sdk/agent/_components/_agentTypes.ts +++ b/admin-compliance/app/sdk/agent/_components/_agentTypes.ts @@ -151,3 +151,10 @@ export const SEVERITY_BG: Record = { 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 +} diff --git a/backend-compliance/compliance/api/agent_check/_agent_outputs.py b/backend-compliance/compliance/api/agent_check/_agent_outputs.py new file mode 100644 index 00000000..45dba02b --- /dev/null +++ b/backend-compliance/compliance/api/agent_check/_agent_outputs.py @@ -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 diff --git a/backend-compliance/compliance/api/agent_check/_orchestrator.py b/backend-compliance/compliance/api/agent_check/_orchestrator.py index 48d58805..bb007897 100644 --- a/backend-compliance/compliance/api/agent_check/_orchestrator.py +++ b/backend-compliance/compliance/api/agent_check/_orchestrator.py @@ -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) diff --git a/backend-compliance/compliance/api/agent_check/_phase_f_persist.py b/backend-compliance/compliance/api/agent_check/_phase_f_persist.py index ca042847..21e55296 100644 --- a/backend-compliance/compliance/api/agent_check/_phase_f_persist.py +++ b/backend-compliance/compliance/api/agent_check/_phase_f_persist.py @@ -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" diff --git a/backend-compliance/compliance/tests/test_agent_outputs_impressum.py b/backend-compliance/compliance/tests/test_agent_outputs_impressum.py new file mode 100644 index 00000000..90ded826 --- /dev/null +++ b/backend-compliance/compliance/tests/test_agent_outputs_impressum.py @@ -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")