feat(impressum): Snapshot-Modul-Tab — ImpressumAgent auf gespeichertem Text
Snapshot-Detailseite wird zu Modul-Tabs (Cookies & Tracking | Impressum).
Backend GET /snapshots/{id}/impressum-check laeuft den v3 ImpressumAgent auf
dem gespeicherten Impressum-Text (kein Re-Crawl); Input-Erzeugung in
impressum_input_from_snapshot() ausgelagert (pure + getestet: Text/Scope/
company_name-Fallback/None-Pfad). Frontend laedt lazy beim Tab-Wechsel und
rendert mit dem bestehenden AgentResultTab (keine zweite Engine).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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<any>(null)
|
||||
const [check, setCheck] = useState<any>(null)
|
||||
const [check, setCheck] = useState<any>(null) // cookie-check
|
||||
const [impressum, setImpressum] = useState<any>(null) // impressum-check (lazy)
|
||||
const [impLoading, setImpLoading] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [tab, setTab] = useState<string>('')
|
||||
|
||||
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) => (
|
||||
<button key={key} onClick={() => setTab(key)}
|
||||
className={`px-3 py-1.5 text-sm border-b-2 -mb-px ${tab === key ? 'border-blue-600 text-blue-700 font-medium' : 'border-transparent text-gray-500 hover:text-gray-700'}`}>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-6xl space-y-4">
|
||||
@@ -56,15 +90,37 @@ export default function SnapshotDetail(
|
||||
<div className="text-sm text-gray-500">Lade Snapshot…</div>
|
||||
) : error || !snap ? (
|
||||
<div className="text-sm text-red-600">Snapshot nicht gefunden.</div>
|
||||
) : hasCookies ? (
|
||||
<>
|
||||
<CookieLibraryPanel snapshotId={snapshotId} data={check ?? undefined} />
|
||||
<CookieResultView snapshot={snap} cookieCategories={check?.cookie_categories} />
|
||||
</>
|
||||
) : (
|
||||
) : modules.length === 0 ? (
|
||||
<div className="text-sm text-gray-500">
|
||||
Dieser Snapshot enthält keine Cookie-/Vendor-Daten.
|
||||
Dieser Snapshot enthält keine auswertbaren Daten.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex gap-1 border-b border-gray-200">
|
||||
{modules.map(m => tabBtn(m.key, m.label))}
|
||||
</div>
|
||||
|
||||
{tab === 'cookie' && hasCookies && (
|
||||
<div className="space-y-4">
|
||||
<CookieLibraryPanel snapshotId={snapshotId} data={check ?? undefined} />
|
||||
<CookieResultView snapshot={snap} cookieCategories={check?.cookie_categories} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'impressum' && (
|
||||
impLoading ? (
|
||||
<div className="text-sm text-gray-500">Impressum-Analyse läuft…</div>
|
||||
) : impressum?.error ? (
|
||||
<div className="text-sm text-red-600">{impressum.error}</div>
|
||||
) : impressum && (impressum.findings?.length || impressum.mc_coverage?.length) ? (
|
||||
<AgentResultTab topicLabel="Impressum" output={impressum} />
|
||||
) : impressum ? (
|
||||
<div className="text-sm text-gray-500">
|
||||
{impressum.notes || 'Keine Impressum-Auswertung verfügbar.'}
|
||||
</div>
|
||||
) : null
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = "",
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user