diff --git a/admin-compliance/app/api/sdk/v1/agent/snapshots/[snapshotId]/impressum-check/route.ts b/admin-compliance/app/api/sdk/v1/agent/snapshots/[snapshotId]/impressum-check/route.ts new file mode 100644 index 00000000..9798f994 --- /dev/null +++ b/admin-compliance/app/api/sdk/v1/agent/snapshots/[snapshotId]/impressum-check/route.ts @@ -0,0 +1,34 @@ +/** + * Impressum-Analyse-Proxy + * GET /api/sdk/v1/agent/snapshots/{snapshotId}/impressum-check + * → backend /api/compliance/agent/snapshots/{snapshotId}/impressum-check + * + * Laeuft den v3 ImpressumAgent auf dem gespeicherten Impressum-Text + * (kein Re-Crawl) und liefert den AgentOutput (Findings/Massnahmen/Coverage). + */ + +import { NextRequest, NextResponse } from 'next/server' + +const BACKEND_URL = + process.env.BACKEND_API_URL || process.env.BACKEND_URL || + 'http://backend-compliance:8002' + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ snapshotId: string }> }, +) { + const { snapshotId } = await params + try { + const response = await fetch( + `${BACKEND_URL}/api/compliance/agent/snapshots/${snapshotId}/impressum-check`, + { signal: AbortSignal.timeout(120_000) }, + ) + const data = await response.json() + return NextResponse.json(data, { status: response.status }) + } catch { + return NextResponse.json( + { error: 'Impressum-Analyse fehlgeschlagen', findings: [] }, + { status: 503 }, + ) + } +} diff --git a/admin-compliance/app/sdk/agent/snapshots/[snapshotId]/page.tsx b/admin-compliance/app/sdk/agent/snapshots/[snapshotId]/page.tsx index 6b733b7a..59f1c9e8 100644 --- a/admin-compliance/app/sdk/agent/snapshots/[snapshotId]/page.tsx +++ b/admin-compliance/app/sdk/agent/snapshots/[snapshotId]/page.tsx @@ -2,24 +2,29 @@ /** * Snapshot-Detail — öffnet einen gespeicherten Check aus der Historie und - * zeigt die Ergebnis-Views aus den Rohdaten (kein Re-Crawl). Aktuell: - * Cookie-Auswertung. Impressum/AGB/… folgen als weitere Module hier. + * zeigt die Ergebnis-Views aus den Rohdaten (kein Re-Crawl), als Modul-Tabs: + * Cookies & Tracking + Impressum (DSE/AGB folgen). Impressum wird beim Öffnen + * des Tabs nachgeladen (ImpressumAgent auf dem gespeicherten Text). */ -import React, { use as useUnwrap, useEffect, useState } from 'react' +import React, { use as useUnwrap, useEffect, useMemo, useState } from 'react' import Link from 'next/link' import { CookieLibraryPanel } from '../../_components/CookieLibraryPanel' import { CookieResultView } from '../../_components/CookieResultView' +import { AgentResultTab } from '../../_components/AgentResultTab' export default function SnapshotDetail( { params }: { params: Promise<{ snapshotId: string }> }, ) { const { snapshotId } = useUnwrap(params) const [snap, setSnap] = useState(null) - const [check, setCheck] = useState(null) + const [check, setCheck] = useState(null) // cookie-check + const [impressum, setImpressum] = useState(null) // impressum-check (lazy) + const [impLoading, setImpLoading] = useState(false) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) + const [tab, setTab] = useState('') useEffect(() => { let cancelled = false @@ -27,15 +32,14 @@ export default function SnapshotDetail( .then(r => r.json()) .then(d => { if (cancelled) return - if (d?.error) setError(d.error) - else setSnap(d) + if (d?.error) setError(d.error); else setSnap(d) }) .catch(e => { if (!cancelled) setError(String(e)) }) .finally(() => { if (!cancelled) setLoading(false) }) return () => { cancelled = true } }, [snapshotId]) - // Library-Abgleich einmal laden (Findings + cookie_categories für beide Views). + // Cookie-Abgleich einmal laden (Findings + cookie_categories für beide Views). useEffect(() => { let cancelled = false fetch(`/api/sdk/v1/agent/snapshots/${snapshotId}/cookie-check`) @@ -45,7 +49,37 @@ export default function SnapshotDetail( return () => { cancelled = true } }, [snapshotId]) + const docs = snap?.doc_entries || [] const hasCookies = (snap?.cmp_vendors?.length ?? 0) > 0 + const hasImpressum = docs.some( + (e: any) => e.doc_type === 'impressum' && (e.text || e.content || '').length > 100) + + const modules = useMemo(() => [ + ...(hasCookies ? [{ key: 'cookie', label: 'Cookies & Tracking' }] : []), + ...(hasImpressum ? [{ key: 'impressum', label: 'Impressum' }] : []), + ], [hasCookies, hasImpressum]) + + useEffect(() => { + if (!tab && modules.length) setTab(modules[0].key) + }, [modules, tab]) + + // Impressum erst beim Öffnen des Tabs analysieren (ImpressumAgent, ggf. LLM). + useEffect(() => { + if (tab !== 'impressum' || impressum || impLoading) return + setImpLoading(true) + fetch(`/api/sdk/v1/agent/snapshots/${snapshotId}/impressum-check`) + .then(r => r.json()) + .then(setImpressum) + .catch(() => setImpressum({ error: 'Impressum-Analyse fehlgeschlagen', findings: [] })) + .finally(() => setImpLoading(false)) + }, [tab, snapshotId, impressum, impLoading]) + + const tabBtn = (key: string, label: string) => ( + + ) return (
@@ -56,15 +90,37 @@ export default function SnapshotDetail(
Lade Snapshot…
) : error || !snap ? (
Snapshot nicht gefunden.
- ) : hasCookies ? ( - <> - - - - ) : ( + ) : modules.length === 0 ? (
- Dieser Snapshot enthält keine Cookie-/Vendor-Daten. + Dieser Snapshot enthält keine auswertbaren Daten.
+ ) : ( + <> +
+ {modules.map(m => tabBtn(m.key, m.label))} +
+ + {tab === 'cookie' && hasCookies && ( +
+ + +
+ )} + + {tab === 'impressum' && ( + impLoading ? ( +
Impressum-Analyse läuft…
+ ) : impressum?.error ? ( +
{impressum.error}
+ ) : impressum && (impressum.findings?.length || impressum.mc_coverage?.length) ? ( + + ) : impressum ? ( +
+ {impressum.notes || 'Keine Impressum-Auswertung verfügbar.'} +
+ ) : null + )} + )}
) diff --git a/backend-compliance/compliance/api/agent_check/_agent_outputs.py b/backend-compliance/compliance/api/agent_check/_agent_outputs.py index 2e048893..8dae3a6d 100644 --- a/backend-compliance/compliance/api/agent_check/_agent_outputs.py +++ b/backend-compliance/compliance/api/agent_check/_agent_outputs.py @@ -53,6 +53,31 @@ def _derive_scope(profile_dict: dict) -> list[str]: return sorted(scope) +def impressum_input_from_snapshot(snap: dict) -> dict | None: + """Baut den ImpressumAgent-Input aus einem gespeicherten Snapshot (kein + Re-Crawl). Pure + testbar: zieht den Impressum-Text aus doc_entries, leitet + den Scope aus scan_context + Profil ab (identisch zur Live-Auswertung) und + nimmt site_label als company_name-Fallback. None, wenn kein Impressum-Text. + """ + docs = snap.get("doc_entries") or [] + text = next((e.get("text") or e.get("content") or "" + for e in docs if e.get("doc_type") == "impressum"), "") + if len((text or "").strip()) < _MIN_TEXT: + return None + profile = snap.get("profile") or {} + scope = sorted( + set(scan_context_to_scope(snap.get("scan_context"))) + | set(_derive_scope(profile)) + ) + return { + "doc_type": "impressum", + "text": text, + "business_scope": scope, + "company_name": (profile.get("company_name") or snap.get("site_label") or ""), + "origin_domain": snap.get("site_domain", ""), + } + + async def run_agent_outputs(state: dict) -> None: """Für jedes Topic mit registriertem v3-Agent + ausreichend Text: Agent laufen lassen, AgentOutput ablegen + als SSE topic-Event diff --git a/backend-compliance/compliance/api/agent_compliance_check_routes.py b/backend-compliance/compliance/api/agent_compliance_check_routes.py index bb7bdbeb..3c942c5b 100644 --- a/backend-compliance/compliance/api/agent_compliance_check_routes.py +++ b/backend-compliance/compliance/api/agent_compliance_check_routes.py @@ -268,6 +268,33 @@ async def snapshot_cookie_check(snapshot_id: str): db.close() +@router.get("/snapshots/{snapshot_id}/impressum-check") +async def snapshot_impressum_check(snapshot_id: str): + """Impressum-Analyse aus dem Snapshot (kein Re-Crawl): laeuft den v3 + ImpressumAgent auf dem gespeicherten Impressum-Text + Profil/Scope und + liefert den AgentOutput (Findings/Massnahmen/MC-Coverage) fuer den Tab.""" + from fastapi import HTTPException + from database import SessionLocal + from compliance.services.check_snapshot import load_snapshot + from compliance.services.specialist_agents import REGISTRY, AgentInput + from compliance.api.agent_check._agent_outputs import ( + impressum_input_from_snapshot, + ) + db = SessionLocal() + try: + snap = load_snapshot(db, snapshot_id) + if not snap: + raise HTTPException(status_code=404, detail="snapshot not found") + agent_input = impressum_input_from_snapshot(snap) + if not agent_input: + return {"findings": [], "recommendations": [], "mc_coverage": [], + "notes": "kein Impressum-Text im Snapshot", "confidence": 0.0} + out = await REGISTRY.get("impressum").evaluate(AgentInput(**agent_input)) + return out.model_dump(mode="json") + finally: + db.close() + + @router.get("/admin/benchmark") async def benchmark( industry: str = "", diff --git a/backend-compliance/compliance/tests/test_impressum_snapshot_input.py b/backend-compliance/compliance/tests/test_impressum_snapshot_input.py new file mode 100644 index 00000000..2fabad2b --- /dev/null +++ b/backend-compliance/compliance/tests/test_impressum_snapshot_input.py @@ -0,0 +1,47 @@ +"""impressum_input_from_snapshot — Snapshot → ImpressumAgent-Input (pure). + +Deckt die Glue des /snapshots/{id}/impressum-check-Endpoints ohne DB/LLM ab: +Text-Extraktion, Scope-Ableitung (Profil), company_name-Fallback, None-Pfad. +""" + +from __future__ import annotations + +from compliance.api.agent_check._agent_outputs import ( + impressum_input_from_snapshot, +) + + +def _snap(text: str = "x" * 200, **over) -> dict: + s = { + "doc_entries": [{"doc_type": "impressum", "text": text}], + "profile": {}, "scan_context": None, + "site_label": "BMW", "site_domain": "bmw.de", + } + s.update(over) + return s + + +def test_builds_input_from_impressum_text(): + inp = impressum_input_from_snapshot(_snap()) + assert inp is not None + assert inp["doc_type"] == "impressum" + assert inp["text"].startswith("x") + assert inp["company_name"] == "BMW" # site_label-Fallback + assert inp["origin_domain"] == "bmw.de" + assert isinstance(inp["business_scope"], list) + + +def test_none_when_no_or_short_impressum_text(): + assert impressum_input_from_snapshot(_snap(doc_entries=[])) is None + assert impressum_input_from_snapshot( + {"doc_entries": [{"doc_type": "impressum", "text": "zu kurz"}]}) is None + + +def test_scope_includes_profile_derived(): + inp = impressum_input_from_snapshot(_snap(profile={"has_online_shop": True})) + assert "ecommerce" in inp["business_scope"] + + +def test_company_name_prefers_profile_over_site_label(): + inp = impressum_input_from_snapshot(_snap(profile={"company_name": "ACME AG"})) + assert inp["company_name"] == "ACME AG"