feat(audit-report): deterministischer Textreport je Audit (MD + PDF) + Bericht-Tab

Firmen-tauglicher Bericht aus den Snapshot-Modulergebnissen (kein Re-Crawl, kein
LLM): Einleitung, Testumfang+Methodik, Management-Summary (4-Status), Detail-
befunde je Modul, Maßnahmen, Rechtlicher Hinweis. Co-Pilot-Tonalität, Tracking-
statt Cookie-Rohzahl, Norm nur referenziert (kein Normtext).
- audit_report.py: assemble_report (pur) + render_markdown + render_pdf (reportlab)
- snapshot_check_routes: GET /report (struktur+md) + GET /report.pdf
- Frontend: AuditReportTab + Proxys (report, report/pdf) + "Bericht"-Tab
- Tests: 5 Assembler (compliance/tests → CI-geprüft) + 1 Vitest

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-06-13 14:50:45 +02:00
parent 7273245054
commit d720db07dd
8 changed files with 602 additions and 0 deletions
@@ -0,0 +1,40 @@
/**
* Audit-Report PDF — Proxy (streamt die PDF-Bytes durch)
* GET /api/sdk/v1/agent/snapshots/{snapshotId}/report/pdf
* → backend /api/compliance/agent/snapshots/{snapshotId}/report.pdf
*/
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 res = await fetch(
`${BACKEND_URL}/api/compliance/agent/snapshots/${snapshotId}/report.pdf`,
{ signal: AbortSignal.timeout(120_000) },
)
if (!res.ok) {
return NextResponse.json(
{ error: `PDF fehlgeschlagen (${res.status})` }, { status: res.status })
}
const buf = await res.arrayBuffer()
return new NextResponse(buf, {
status: 200,
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition':
res.headers.get('content-disposition') ||
'attachment; filename="audit-report.pdf"',
},
})
} catch {
return NextResponse.json({ error: 'PDF fehlgeschlagen' }, { status: 503 })
}
}
@@ -0,0 +1,29 @@
/**
* Audit-Report (strukturiert + Markdown) — Proxy
* GET /api/sdk/v1/agent/snapshots/{snapshotId}/report
* → backend /api/compliance/agent/snapshots/{snapshotId}/report
*/
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 res = await fetch(
`${BACKEND_URL}/api/compliance/agent/snapshots/${snapshotId}/report`,
{ signal: AbortSignal.timeout(120_000) },
)
const data = await res.json()
return NextResponse.json(data, { status: res.status })
} catch {
return NextResponse.json(
{ error: 'Report-Erzeugung fehlgeschlagen' }, { status: 503 })
}
}
@@ -0,0 +1,102 @@
'use client'
/**
* AuditReportTab — rendert den deterministischen Audit-Textreport eines
* Snapshots (Sektionen aus /report, kein Re-Crawl) + Download als PDF/Markdown.
* Bewusst ohne Markdown-Lib + ohne dangerouslySetInnerHTML (Befundtexte können
* Site-Inhalte enthalten → XSS-sicher über React-Textknoten).
*/
import React, { useEffect, useState } from 'react'
type Section = { title: string; level: number; body?: string }
type Report = { meta?: Record<string, unknown>; sections?: Section[]; totals?: Record<string, unknown> }
function Inline({ text }: { text: string }) {
// **fett** sicher rendern; _kursiv_-Marker entfernen.
const parts = text.split(/\*\*(.+?)\*\*/g)
return <>{parts.map((p, i) => (i % 2
? <strong key={i}>{p}</strong>
: <React.Fragment key={i}>{p.replace(/_/g, '')}</React.Fragment>))}</>
}
function Body({ body }: { body: string }) {
const out: React.ReactNode[] = []
let bullets: string[] = []
const flush = (k: string) => {
if (bullets.length) {
const items = bullets
out.push(<ul key={'u' + k} className="list-disc ml-5 space-y-1">
{items.map((b, j) => <li key={j} className="text-sm text-gray-700"><Inline text={b} /></li>)}
</ul>)
bullets = []
}
}
body.split('\n').map(l => l.trim()).filter(Boolean).forEach((l, i) => {
if (l.startsWith('- ')) { bullets.push(l.slice(2)) }
else { flush('p' + i); out.push(<p key={i} className="text-sm text-gray-700"><Inline text={l} /></p>) }
})
flush('end')
return <div className="space-y-1.5">{out}</div>
}
export function AuditReportTab({ snapshotId }: { snapshotId: string }) {
const [rep, setRep] = useState<Report | null>(null)
const [md, setMd] = useState('')
const [loading, setLoading] = useState(true)
const [err, setErr] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
fetch(`/api/sdk/v1/agent/snapshots/${snapshotId}/report`)
.then(r => r.json())
.then(d => {
if (cancelled) return
if (d?.error) setErr(d.error)
else { setRep(d.report); setMd(d.markdown || '') }
})
.catch(e => { if (!cancelled) setErr(String(e)) })
.finally(() => { if (!cancelled) setLoading(false) })
return () => { cancelled = true }
}, [snapshotId])
const downloadMd = () => {
const blob = new Blob([md], { type: 'text/markdown' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url; a.download = 'audit-report.md'; a.click()
URL.revokeObjectURL(url)
}
if (loading) return <div className="text-sm text-gray-500">Bericht wird erstellt</div>
if (err || !rep) return <div className="text-sm text-red-600">{err || 'Kein Bericht verfügbar.'}</div>
return (
<div className="space-y-4">
<div className="flex gap-2 flex-wrap">
<a href={`/api/sdk/v1/agent/snapshots/${snapshotId}/report/pdf`}
target="_blank" rel="noopener"
className="px-3 py-1.5 text-sm rounded-lg bg-blue-600 text-white hover:bg-blue-700">
PDF herunterladen
</a>
<button onClick={downloadMd}
className="px-3 py-1.5 text-sm rounded-lg border border-blue-200 text-blue-700 hover:bg-blue-50">
Markdown herunterladen
</button>
</div>
<div className="border border-gray-200 rounded-xl p-5 space-y-3 bg-white">
{(rep.sections || []).map((s, i) => s.level <= 2 ? (
<div key={i} className="space-y-1.5">
<h2 className="text-base font-semibold text-gray-900 border-b border-gray-100 pb-1">{s.title}</h2>
{s.body && <Body body={s.body} />}
</div>
) : (
<div key={i} className="space-y-1 ml-1">
<h3 className="text-sm font-semibold text-gray-800">{s.title}</h3>
{s.body && <Body body={s.body} />}
</div>
))}
</div>
</div>
)
}
@@ -0,0 +1,31 @@
import { describe, it, expect, vi, afterEach } from 'vitest'
import { render, screen } from '@testing-library/react'
import { AuditReportTab } from '../AuditReportTab'
function mockFetch(body: unknown) {
return vi.fn(async () => ({ ok: true, status: 200, json: async () => body })) as unknown as typeof fetch
}
describe('AuditReportTab', () => {
afterEach(() => { vi.restoreAllMocks() })
it('rendert Sektionen + Download-Buttons', async () => {
const data = {
report: {
sections: [
{ title: 'Einleitung', level: 2, body: 'Dieser Bericht fasst die Analyse zusammen.' },
{ title: 'Empfohlene Maßnahmen', level: 2, body: '- **[Hoch]** Tracking erst nach Consent laden.' },
],
},
markdown: '# Bericht\n',
}
vi.stubGlobal('fetch', mockFetch(data))
render(<AuditReportTab snapshotId="abc" />)
expect(await screen.findByText('Einleitung')).toBeInTheDocument()
expect(screen.getByText('Empfohlene Maßnahmen')).toBeInTheDocument()
expect(screen.getByText(/Tracking erst nach Consent/)).toBeInTheDocument()
expect(screen.getByText('PDF herunterladen')).toBeInTheDocument()
expect(screen.getByText('Markdown herunterladen')).toBeInTheDocument()
})
})
@@ -16,6 +16,7 @@ import { CookieDeclarationDiff } from '../../_components/CookieDeclarationDiff'
import { CookieResultView } from '../../_components/CookieResultView' import { CookieResultView } from '../../_components/CookieResultView'
import { AgentModuleTab } from '../../_components/AgentModuleTab' import { AgentModuleTab } from '../../_components/AgentModuleTab'
import { BrowserBehaviorView } from '../../_components/BrowserBehaviorView' import { BrowserBehaviorView } from '../../_components/BrowserBehaviorView'
import { AuditReportTab } from '../../_components/AuditReportTab'
export default function SnapshotDetail( export default function SnapshotDetail(
{ params }: { params: Promise<{ snapshotId: string }> }, { params }: { params: Promise<{ snapshotId: string }> },
@@ -64,6 +65,7 @@ export default function SnapshotDetail(
...(hasDoc('dse') ? [{ key: 'dse', label: 'Datenschutzerklärung' }] : []), ...(hasDoc('dse') ? [{ key: 'dse', label: 'Datenschutzerklärung' }] : []),
...(hasDoc('agb') ? [{ key: 'agb', label: 'AGB' }] : []), ...(hasDoc('agb') ? [{ key: 'agb', label: 'AGB' }] : []),
...(hasSite ? [{ key: 'browser', label: 'Browser-Verhalten' }] : []), ...(hasSite ? [{ key: 'browser', label: 'Browser-Verhalten' }] : []),
{ key: 'bericht', label: 'Bericht' },
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
], [snap]) ], [snap])
@@ -142,6 +144,10 @@ export default function SnapshotDetail(
{tab === 'browser' && ( {tab === 'browser' && (
<BrowserBehaviorView snapshotId={snapshotId} /> <BrowserBehaviorView snapshotId={snapshotId} />
)} )}
{tab === 'bericht' && (
<AuditReportTab snapshotId={snapshotId} />
)}
</> </>
)} )}
</div> </div>
@@ -215,3 +215,74 @@ async def snapshot_browser_behavior(snapshot_id: str):
return {"browser_matrix": matrix} return {"browser_matrix": matrix}
finally: finally:
db.close() db.close()
async def _gather_report(snapshot_id: str):
"""Lädt den Snapshot + sammelt ALLE Modul-Ergebnisse (kein Re-Crawl) für den
Audit-Report. Gibt (meta, modules) zurück."""
from database import SessionLocal
from compliance.services.check_snapshot import (
load_snapshot, load_browser_matrix,
)
from compliance.services.browser_cross_finding import build_cross_findings
db = SessionLocal()
try:
snap = load_snapshot(db, snapshot_id)
if not snap:
raise HTTPException(status_code=404, detail="snapshot not found")
meta = {
"site_label": snap.get("site_label"),
"site_domain": snap.get("site_domain"),
"created_at": snap.get("created_at"),
"check_id": snap.get("check_id"),
"scan_context": snap.get("scan_context"),
}
bm = load_browser_matrix(db, snapshot_id)
finally:
db.close()
docs = snap.get("doc_entries") or []
def _has(dt: str) -> bool:
return any(e.get("doc_type") == dt
and len(e.get("text") or e.get("content") or "") > 100
for e in docs)
modules: dict = {}
if snap.get("cmp_vendors"):
try:
modules["cookie"] = await snapshot_cookie_check(snapshot_id)
except Exception as e:
logger.warning("report cookie failed: %s", e)
for dt, agent in (("impressum", "impressum"), ("dse", "dse"), ("agb", "agb")):
if _has(dt):
try:
modules[dt] = await _run_doc_agent(snapshot_id, dt, agent)
except Exception as e:
logger.warning("report %s failed: %s", dt, e)
if bm:
modules["browser"] = {"browser_matrix": bm,
"cross_findings": build_cross_findings(bm)}
return meta, modules
@router.get("/snapshots/{snapshot_id}/report")
async def snapshot_report(snapshot_id: str):
"""Deterministischer Audit-Textreport (strukturiert + Markdown), aus den
Modul-Ergebnissen des Snapshots — kein Re-Crawl, kein LLM."""
from compliance.services.audit_report import assemble_report, render_markdown
meta, modules = await _gather_report(snapshot_id)
report = assemble_report(meta, modules)
return {"report": report, "markdown": render_markdown(report)}
@router.get("/snapshots/{snapshot_id}/report.pdf")
async def snapshot_report_pdf(snapshot_id: str):
"""Druckfertiges PDF des Audit-Reports (reportlab)."""
from fastapi import Response
from compliance.services.audit_report import assemble_report, render_pdf
meta, modules = await _gather_report(snapshot_id)
pdf = render_pdf(assemble_report(meta, modules))
dom = (meta.get("site_domain") or "report").replace("/", "_")
return Response(
content=pdf, media_type="application/pdf",
headers={"Content-Disposition": f'attachment; filename="audit-{dom}.pdf"'})
@@ -0,0 +1,258 @@
"""Audit-Textreport — deterministischer Section-Assembler + Markdown-Renderer.
Erzeugt aus den bereits vorhandenen Modul-Ergebnissen eines Snapshots (Cookie-,
Impressum-, DSE-, AGB-Check + Browser-Matrix/Cross-Findings) einen firmen-tauglichen
Bericht: Einleitung, Testumfang & Methodik, Management-Summary, Detailbefunde je
Modul, Maßnahmen, Rechtlicher Hinweis. KEIN Re-Crawl, KEIN LLM — reine Aufbereitung.
Leitplanken (siehe [[feedback_breakpilot_tonalitaet]], [[project_compliance_data_model]]):
- Co-Pilot-Ton: „Wir analysieren Sie entscheiden mit DSB/Anwalt." Keine Panik,
keine Bußgeld-Drohung als Aufmacher. Wahrscheinlichkeit statt Garantie.
- Keine Normtexte reproduzieren — nur Norm-Bezug benennen.
- Applicability ≠ Compliance, Unknown ≠ Fail: Konfidenz + Status transparent.
`assemble_report` ist PUR (dict→dict) → ohne DB/Netz unit-testbar. Erweitern =
neue Sektion in `_build_sections` ergänzen.
"""
from __future__ import annotations
from typing import Any, Optional
_SEV_ORDER = {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "LOW": 3, "INFO": 4}
_SEV_LABEL = {"CRITICAL": "Kritisch", "HIGH": "Hoch", "MEDIUM": "Mittel",
"LOW": "Niedrig", "INFO": "Hinweis"}
_MODULE_LABEL = {
"cookie": "Cookies & Tracking", "impressum": "Impressum",
"dse": "Datenschutzerklärung", "agb": "AGB",
"browser": "Browser-Verhalten (Cookie-Banner)",
}
_DISCLAIMER = (
"Dieser Bericht wurde von BreakPilot automatisiert erstellt und bewertet "
"Wahrscheinlichkeiten, keine Rechtsgewissheit. Er ersetzt keine "
"Rechtsberatung: Die finale Bewertung und Freigabe treffen Sie gemeinsam "
"mit Ihrem Datenschutzbeauftragten bzw. Ihrer Anwältin/Ihrem Anwalt. "
"Befunde mit niedriger Konfidenz oder unklarer Datenlage sind als Hinweis "
"zu verstehen, nicht als festgestellter Verstoß."
)
def _norm_finding(f: Any) -> dict:
"""Befund (dict/obj) → einheitliche Form für den Report."""
if not isinstance(f, dict):
f = getattr(f, "__dict__", {}) or {}
sev = (f.get("severity") or "MEDIUM").upper()
return {
"title": f.get("title") or f.get("text") or f.get("message") or "Befund",
"severity": sev if sev in _SEV_ORDER else "MEDIUM",
"status": f.get("status") or "",
"legal_ref": f.get("legal_ref") or f.get("legal_reference") or "",
"measure": f.get("measure") or f.get("recommendation") or "",
"confidence": f.get("confidence"),
}
def _module_findings(mod: Optional[dict]) -> list[dict]:
if not mod:
return []
out = [_norm_finding(f) for f in (mod.get("findings") or [])]
# Browser-Modul trägt seine Befunde in cross_findings.
for cf in (mod.get("cross_findings") or []):
n = _norm_finding(cf)
if cf.get("detail"):
n["title"] = f"{n['title']}{cf['detail']}"
out.append(n)
return out
def _sev_counts(findings: list[dict]) -> dict:
c = {"CRITICAL": 0, "HIGH": 0, "MEDIUM": 0, "LOW": 0, "INFO": 0}
for f in findings:
c[f["severity"]] = c.get(f["severity"], 0) + 1
return c
def _verdict(counts: dict) -> str:
if counts.get("CRITICAL") or counts.get("HIGH", 0) >= 3:
return ("Es bestehen mehrere Punkte mit erhöhtem Handlungsbedarf — eine "
"kurzfristige Klärung mit DSB/Anwalt ist empfehlenswert.")
if counts.get("HIGH"):
return ("Einzelne Punkte sollten priorisiert geprüft werden; das "
"Gesamtbild ist handhabbar.")
if counts.get("MEDIUM"):
return ("Überwiegend kleinere Hinweise — mit moderatem Aufwand "
"nachschärfbar.")
return ("Im Prüfumfang wurden keine wesentlichen Auffälligkeiten "
"festgestellt.")
def assemble_report(meta: dict, modules: dict) -> dict:
"""meta: {site_label, site_domain, created_at, check_id, scan_context}.
modules: {cookie|impressum|dse|agb|browser: <modul-ergebnis-dict oder None>}.
Rückgabe: {meta, generated_for, sections:[{title, level, body, findings?}]}."""
present = [k for k in ("cookie", "impressum", "dse", "agb", "browser")
if modules.get(k)]
all_findings = [f for k in present for f in _module_findings(modules.get(k))]
counts = _sev_counts(all_findings)
return {
"meta": meta,
"modules_present": present,
"totals": {"findings": len(all_findings), "by_severity": counts},
"sections": _build_sections(meta, modules, present, all_findings, counts),
}
def _build_sections(meta, modules, present, all_findings, counts) -> list[dict]:
site = meta.get("site_label") or meta.get("site_domain") or "die Website"
when = (meta.get("created_at") or "")[:16].replace("T", " ")
sec: list[dict] = []
sec.append({"title": "Einleitung", "level": 2, "body": (
f"Dieser Bericht fasst die automatisierte Compliance-Analyse von "
f"**{site}** ({meta.get('site_domain', '')}) zusammen, durchgeführt mit "
f"BreakPilot am {when or 'dem angegebenen Datum'}. Ziel ist ein "
f"verständlicher Überblick über mögliche datenschutz- und "
f"informationsrechtliche Handlungsfelder als Grundlage für die "
f"gemeinsame Bewertung mit Ihrem DSB bzw. Ihrer Rechtsberatung.")})
mods = ", ".join(_MODULE_LABEL[k] for k in present) or ""
sec.append({"title": "Testumfang & Methodik", "level": 2, "body": (
f"**Geprüfte Bereiche:** {mods}.\n\n"
f"**Vorgehen:** Automatisierte Erfassung der öffentlich erreichbaren "
f"Seiteninhalte und des Cookie-/Tracking-Verhaltens, Abgleich gegen eine "
f"kuratierte Wissensbasis sowie beim Cookie-Banner Messung des "
f"tatsächlichen Verhaltens in mehreren Browser-Engines. Bewertet wird "
f"das **nicht-essentielle Tracking** (technisch notwendige Cookies sind "
f"ausgenommen, § 25 Abs. 2 TDDDG).\n\n"
f"**Grenzen:** Geprüft wurde der erfasste Stand zum Analysezeitpunkt; "
f"nicht erfasste Bereiche, eingeloggte Strecken oder dynamische Inhalte "
f"sind nicht abschließend abgedeckt. Befunde sind Wahrscheinlichkeiten, "
f"keine abschließende rechtliche Feststellung.")})
sev_line = " · ".join(
f"{_SEV_LABEL[k]}: {counts[k]}" for k in ("CRITICAL", "HIGH", "MEDIUM", "LOW")
if counts.get(k))
sec.append({"title": "Management-Summary", "level": 2, "body": (
f"{_verdict(counts)}\n\n"
f"**Befunde gesamt:** {len(all_findings)}"
f"{' (' + sev_line + ')' if sev_line else ''}.\n\n"
f"Die Einstufung folgt dem Prinzip *Anwendbarkeit ≠ Verstoß*: Ein Befund "
f"markiert einen zu klärenden Punkt, kein automatisches Bußgeldrisiko.")})
det: list[dict] = [{"title": "Detailbefunde", "level": 2, "body": ""}]
for k in present:
fs = sorted(_module_findings(modules.get(k)),
key=lambda f: _SEV_ORDER.get(f["severity"], 2))
if not fs:
det.append({"title": _MODULE_LABEL[k], "level": 3,
"body": "Keine Auffälligkeiten im Prüfumfang."})
continue
lines = []
for f in fs:
extra = []
if f["status"]:
extra.append(f"Status: {f['status']}")
if f["legal_ref"]:
extra.append(f["legal_ref"])
tail = f" _( {' · '.join(extra)} )_" if extra else ""
lines.append(f"- **[{_SEV_LABEL[f['severity']]}]** {f['title']}{tail}")
det.append({"title": _MODULE_LABEL[k], "level": 3,
"body": "\n".join(lines)})
sec.extend(det)
measures = []
for f in sorted(all_findings, key=lambda f: _SEV_ORDER.get(f["severity"], 2)):
if f["measure"]:
measures.append(f"- **[{_SEV_LABEL[f['severity']]}]** {f['measure']}")
seen, uniq = set(), []
for m in measures:
if m not in seen:
seen.add(m)
uniq.append(m)
sec.append({"title": "Empfohlene Maßnahmen", "level": 2, "body": (
"\n".join(uniq) if uniq else
"Keine konkreten Maßnahmen erforderlich.")})
sec.append({"title": "Rechtlicher Hinweis", "level": 2, "body": _DISCLAIMER})
return sec
def render_markdown(report: dict) -> str:
meta = report.get("meta") or {}
site = meta.get("site_label") or meta.get("site_domain") or "Website"
when = (meta.get("created_at") or "")[:16].replace("T", " ")
out = [f"# Compliance-Audit-Bericht — {site}", ""]
sub = [meta.get("site_domain") or "", f"Analyse: {when}" if when else "",
f"Check-ID: {meta.get('check_id')}" if meta.get("check_id") else ""]
out.append(" · ".join(s for s in sub if s))
out.append("")
out.append("_Erstellt mit BreakPilot — finale Bewertung mit DSB/Anwalt._")
out.append("")
for s in report.get("sections") or []:
out.append(f"{'#' * s.get('level', 2)} {s['title']}")
out.append("")
if s.get("body"):
out.append(s["body"])
out.append("")
return "\n".join(out).strip() + "\n"
def render_pdf(report: dict) -> bytes:
"""Druckfertiges PDF (reportlab). Wandelt die Sektionen in gestylte Absätze;
unterstützt **fett**, _kursiv_ und Aufzählungen aus den Markdown-Bodies."""
import html
import re
from io import BytesIO
from reportlab.lib import colors
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.units import mm
from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer
meta = report.get("meta") or {}
site = meta.get("site_label") or meta.get("site_domain") or "Website"
buf = BytesIO()
doc = SimpleDocTemplate(
buf, pagesize=A4, topMargin=20 * mm, bottomMargin=18 * mm,
leftMargin=20 * mm, rightMargin=20 * mm,
title=f"Compliance-Audit-Bericht — {site}")
ss = getSampleStyleSheet()
h1 = ParagraphStyle("rh1", parent=ss["Title"], fontSize=18, spaceAfter=4)
h2 = ParagraphStyle("rh2", parent=ss["Heading2"], spaceBefore=12,
spaceAfter=4, textColor=colors.HexColor("#1d4ed8"))
h3 = ParagraphStyle("rh3", parent=ss["Heading3"], spaceBefore=6, spaceAfter=2)
body = ParagraphStyle("rbody", parent=ss["BodyText"], fontSize=10,
leading=14, spaceAfter=4)
small = ParagraphStyle("rsmall", parent=ss["BodyText"], fontSize=8,
textColor=colors.grey, spaceAfter=2)
def _inl(t: str) -> str:
t = html.escape(t)
t = re.sub(r"\*\*(.+?)\*\*", r"<b>\1</b>", t)
t = re.sub(r"_(.+?)_", r"<i>\1</i>", t)
return t
story = [Paragraph(f"Compliance-Audit-Bericht — {html.escape(site)}", h1)]
sub = " · ".join(x for x in [
meta.get("site_domain") or "",
(meta.get("created_at") or "")[:16].replace("T", " "),
f"Check-ID: {meta.get('check_id')}" if meta.get("check_id") else "",
] if x)
if sub:
story.append(Paragraph(html.escape(sub), small))
story.append(Paragraph(
"Erstellt mit BreakPilot — finale Bewertung mit DSB/Anwalt.", small))
story.append(Spacer(1, 8))
for s in report.get("sections") or []:
style = {2: h2, 3: h3}.get(s.get("level", 2), h2)
story.append(Paragraph(_inl(s["title"]), style))
for line in (s.get("body") or "").split("\n"):
line = line.strip()
if not line:
continue
if line.startswith("- "):
story.append(Paragraph("•&nbsp;&nbsp;" + _inl(line[2:]), body))
else:
story.append(Paragraph(_inl(line), body))
doc.build(story)
return buf.getvalue()
@@ -0,0 +1,65 @@
"""Audit-Report-Assembler (pur) — Sektionen, 4-Status-/Severity-Zählung,
Co-Pilot-Tonalität, kein Normtext."""
from compliance.services.audit_report import assemble_report, render_markdown
META = {"site_label": "BMW", "site_domain": "bmw.de",
"created_at": "2026-06-11T14:15:00", "check_id": "508983ec"}
MODULES = {
"cookie": {"findings": [
{"title": "Cookie als notwendig deklariert, real Marketing",
"severity": "HIGH", "legal_ref": "§ 25 TDDDG",
"measure": "Als einwilligungspflichtig (§ 25) einstufen."},
{"title": "Laufzeit überschreitet Empfehlung", "severity": "LOW"},
]},
"impressum": {"findings": [
{"title": "Vertretungsberechtigte fehlen", "severity": "MEDIUM",
"status": "APPLICABLE", "recommendation": "Vertretungsberechtigte ergänzen."},
], "status": "APPLICABLE", "confidence": 0.8},
"browser": {"cross_findings": [
{"title": "Tracking vor der Einwilligung — in allen Browsern",
"severity": "HIGH", "detail": "Chrome + Firefox setzen Tracker vor Consent",
"measure": "Tracking-Skripte erst nach aktiver Einwilligung laden."},
]},
}
def test_sections_present():
r = assemble_report(META, MODULES)
titles = [s["title"] for s in r["sections"]]
for t in ["Einleitung", "Testumfang & Methodik", "Management-Summary",
"Detailbefunde", "Empfohlene Maßnahmen", "Rechtlicher Hinweis"]:
assert t in titles, f"Sektion fehlt: {t}"
def test_severity_counts():
r = assemble_report(META, MODULES)
c = r["totals"]["by_severity"]
assert c["HIGH"] == 2 and c["MEDIUM"] == 1 and c["LOW"] == 1
assert r["totals"]["findings"] == 4
def test_markdown_has_header_findings_and_copilot_disclaimer():
md = render_markdown(assemble_report(META, MODULES))
assert "Compliance-Audit-Bericht — BMW" in md
assert "Tracking vor der Einwilligung" in md # Browser-Cross-Finding
assert "Vertretungsberechtigte ergänzen" in md # Maßnahme aus recommendation
assert "DSB" in md and "Anwalt" in md # Co-Pilot-Disclaimer
assert "Wahrscheinlichkeit" in md # keine Garantie
assert "BreakPilot" in md
def test_empty_modules_graceful():
r = assemble_report({"site_domain": "example.com"}, {})
assert r["totals"]["findings"] == 0
md = render_markdown(r)
assert "keine wesentlichen auffälligkeiten" in md.lower()
# Auch ohne Befunde: Disclaimer + Methodik vorhanden.
assert "Rechtlicher Hinweis" in md
def test_essential_cookie_framing_in_methodik():
# Tonalität/Recht: technisch notwendige Cookies ausgenommen (§ 25 Abs. 2).
md = render_markdown(assemble_report(META, MODULES))
assert "§ 25 Abs. 2" in md
assert "nicht-essentielle" in md.lower()