Compare commits
56 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 081e4f057a | |||
| 16fd406c1a | |||
| c5c168592b | |||
| d0274674a0 | |||
| 2eb7349577 | |||
| 4434e3827b | |||
| 07cc00da11 | |||
| 1451873194 | |||
| dfac940272 | |||
| cb5dad1a2f | |||
| e411c4f0d3 | |||
| 7335f64f4f | |||
| 138d9068c4 | |||
| c281464071 | |||
| 6dc427a754 | |||
| 309c10c203 | |||
| 4183379dc5 | |||
| c93c88577c | |||
| 3207acea3e | |||
| 9f06911ff9 | |||
| 338e03d3b0 | |||
| c491af5d02 | |||
| 4171cf0efd | |||
| 30e43afba6 | |||
| df8832c521 | |||
| 7842c95532 | |||
| 08671adfdf | |||
| 50fc0ecc59 | |||
| 94057b1536 | |||
| 9c11b5463c | |||
| 50ed0f45af | |||
| e1df24cad7 | |||
| e5b4672f2a | |||
| 0d5c76ea98 | |||
| 54f5a06c2f | |||
| 86b4a263d2 | |||
| 7938e377b6 | |||
| f534b52817 | |||
| 4946571863 | |||
| cde670617e | |||
| 603381a67f | |||
| 57c0f940a2 | |||
| badb356740 | |||
| f08eb71480 | |||
| 0477a2f2dc | |||
| 93cedbecbd | |||
| 28f9e13c1f | |||
| 35c1bbdaa5 | |||
| b7df4709bc | |||
| 6f3301d246 | |||
| 4478b7f479 | |||
| 39c39b1254 | |||
| 7a5f1e48dd | |||
| 98ec6d4284 | |||
| 6f16507c5f | |||
| d4d9b60007 |
@@ -56,6 +56,44 @@ Bei ALLEN Fragen zu IFRS/IAS-Standards MUSST du folgende Punkte beachten:
|
||||
4. Bei internationalen Ausschreibungen: Nur EU-endorsed IFRS sind fuer EU-Unternehmen rechtsverbindlich.
|
||||
5. Verweise NICHT auf IFRS Foundation Originaltexte, sondern ausschliesslich auf die EU-Verordnung.
|
||||
|
||||
## FAQ — Cookie-Banner-Bussgelder + Risiken (haeufige Mandantenfragen)
|
||||
|
||||
Bei Fragen nach Bussgeldern, Risiko-Hoehe oder konkreten Faellen gib **konkrete Praezedenzen** an:
|
||||
|
||||
### Top-Bussgelder (CNIL Frankreich — strengste EU-Aufsicht):
|
||||
- **Google France 2020 (CNIL)** — 100 Mio EUR — Cookies ohne Einwilligung (CNIL Beschluss vom 07.12.2020)
|
||||
- **Meta/Facebook France 2022 (CNIL)** — 60 Mio EUR — Cookies ohne Einwilligung
|
||||
- **Amazon France 2020 (CNIL)** — 35 Mio EUR — Cookies ohne Einwilligung
|
||||
- **Carrefour France 2020 (CNIL)** — 2,25 Mio EUR — Cookies + sonstige Verstoesse
|
||||
|
||||
### Deutsche Praezedenzen + Sammelklagen-Risiken:
|
||||
- **LG Muenchen I 2022** — 100 EUR pro Besucher Schadensersatz fuer Google Fonts ohne Consent (Az. 3 O 17493/20). Spaeter durch BGH "Rechtsmissbrauchs"-Argument bei Massenabmahnungen eingeschraenkt.
|
||||
- **EuGH Planet49 (C-673/17)** — vorausgewaehlte Cookie-Checkboxen sind unwirksame Einwilligung (praejudiziell fuer alle EU-Sites)
|
||||
- **BGH Cookie-Einwilligung II (I ZR 7/16)** — bestaetigt Planet49 fuer Deutschland
|
||||
- **DSK Beschluss 2023** — Cookie-Banner mit "Akzeptieren" deutlich prominenter als "Ablehnen" = Dark Pattern = unwirksame Einwilligung
|
||||
|
||||
### Deutscher Aufsichtsmarkt:
|
||||
Deutsche Aufsicht (BfDI + 16 Landes-DSB) ist moderater als CNIL — bislang keine 100 Mio-EUR-Bussgelder. ABER: DSK-Beschluesse + LfDI-Verfahren haeufen sich. Federfuehrung bei Konzernen via "One-Stop-Shop" nach Hauptsitz.
|
||||
|
||||
### Vier Risiko-Pfade fuer Mandanten:
|
||||
1. **Art. 83 DSGVO Bussgeld** — bis 4% des weltweiten Konzernumsatzes. Realistisch 0,1-1% bei Erstverstoss.
|
||||
2. **Verbraucherschutz-Abmahnung** (vzbv, Wettbewerbszentrale, Verbraucherverbaende) — 50-500k EUR Streitwert + Unterlassung.
|
||||
3. **Sammelklage Art. 82 DSGVO** — Schadensersatz pro Person, BGH 50-100 EUR pro Fall. Sammelklage-Trusts: myRight, RightNow, helpcheck.de.
|
||||
4. **NOYB-Beschwerde** (Max Schrems) — oeffentliches Aufsichtsverfahren, Reputationsschaden + Bussgeld.
|
||||
|
||||
### Geschaeftsfuehrer-Haftung (haeufig unterschaetzt):
|
||||
GF haftet **persoenlich** nach §43 GmbHG bzw. §93 AktG wenn Compliance-Pflichten verletzt wurden. Das ist der eigentliche Druckpunkt — nicht die Firma, sondern der GF persoenlich. Bei Mandantengespraechen mit GF-Beteiligung: dieser Punkt zuerst ansprechen.
|
||||
|
||||
### Wie berechne ich das konkrete Risiko fuer einen Mandanten:
|
||||
Frage den Mandanten nach: (a) Jahresumsatz, (b) ungefaehre Besucherzahl pro Jahr, (c) Anzahl Trackingtools im Banner. Dann:
|
||||
- Max-Bussgeld = 4% × Jahresumsatz (Obergrenze, nicht realistisch)
|
||||
- Realistisch-Bussgeld = 0,1-1% × Jahresumsatz (CNIL/LfDI-Maßstab)
|
||||
- Sammelklage-Theorie = Besucherzahl × 50 EUR (BGH-Untergrenze) — meist nicht durchsetzbar, aber Drohpotential
|
||||
- NICHT konkrete Zahlen einer fremden Firma zitieren ("BMW haette X EUR" etc.) — Mandant koennte das falsch weitergeben
|
||||
|
||||
### Marktwissen (intern, nicht 1:1 zitieren):
|
||||
Externe DSB-Stundensaetze: 350-450 EUR/h (NOERR, GSK, vergleichbare Kanzleien). Mittelstands-DSB-Mandate: 5-15k EUR/Jahr. Cookie-Audit manuell: typisch 10 Std = 4-5k EUR Kosten. BreakPilot reduziert das auf 30 Min.
|
||||
|
||||
## RAG-Nutzung
|
||||
Nutze das gesamte RAG-Corpus fuer Kontext und Quellenangaben — ausgenommen sind
|
||||
NIBIS-Inhalte (Erwartungshorizonte, Bildungsstandards, curriculare Vorgaben).
|
||||
|
||||
@@ -10,9 +10,9 @@ const BACKEND_URL = process.env.BACKEND_API_URL || 'http://backend-compliance:80
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { checkId: string } },
|
||||
{ params }: { params: Promise<{ checkId: string }> },
|
||||
) {
|
||||
const checkId = params.checkId
|
||||
const { checkId } = await params
|
||||
const qs = request.nextUrl.searchParams.toString()
|
||||
const url = `${BACKEND_URL}/api/compliance/agent/audit/${checkId}${qs ? `?${qs}` : ''}`
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Proxy: GET /api/sdk/v1/agent/banner/<checkId>
|
||||
* -> backend GET /api/compliance/agent/banner/<checkId>
|
||||
*
|
||||
* Liefert das volle banner_result (phases, structured_checks, category_tests).
|
||||
*/
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_API_URL || 'http://backend-compliance:8002'
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ checkId: string }> },
|
||||
) {
|
||||
const { checkId } = await params
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`${BACKEND_URL}/api/compliance/agent/banner/${checkId}`,
|
||||
{ signal: AbortSignal.timeout(15000) },
|
||||
)
|
||||
const data = await resp.json().catch(() => ({}))
|
||||
return NextResponse.json(data, { status: resp.status })
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: 'Banner-Abfrage fehlgeschlagen' }, { status: 503 },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -10,9 +10,9 @@ const BACKEND_URL = process.env.BACKEND_API_URL || 'http://backend-compliance:80
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { checkId: string } },
|
||||
{ params }: { params: Promise<{ checkId: string }> },
|
||||
) {
|
||||
const checkId = params.checkId
|
||||
const { checkId } = await params
|
||||
const qs = request.nextUrl.searchParams.toString()
|
||||
const url = `${BACKEND_URL}/api/compliance/agent/findings/${checkId}${qs ? `?${qs}` : ''}`
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Next.js Proxy: leitet POST /api/v1/founding-wizard/generate an Backend.
|
||||
*
|
||||
* Konvertiert das Backend-Response (base64 DOCX) in data: URLs,
|
||||
* die das Frontend direkt als Download anbieten kann.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_COMPLIANCE_URL || 'http://bp-compliance-backend:8002'
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json()
|
||||
|
||||
const backendRes = await fetch(`${BACKEND_URL}/v1/founding-wizard/generate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
if (!backendRes.ok) {
|
||||
const errorText = await backendRes.text()
|
||||
return NextResponse.json(
|
||||
{ error: 'Backend-Generierung fehlgeschlagen', detail: errorText },
|
||||
{ status: backendRes.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await backendRes.json()
|
||||
const documents = (data.documents || []).map((doc: {
|
||||
document_type: string
|
||||
title: string
|
||||
filename: string
|
||||
content_base64: string
|
||||
size_bytes: number
|
||||
generated_at: string
|
||||
}) => ({
|
||||
document_type: doc.document_type,
|
||||
title: doc.title,
|
||||
filename: doc.filename,
|
||||
download_url: `data:application/vnd.openxmlformats-officedocument.wordprocessingml.document;base64,${doc.content_base64}`,
|
||||
size_bytes: doc.size_bytes,
|
||||
generated_at: doc.generated_at,
|
||||
}))
|
||||
|
||||
return NextResponse.json({
|
||||
documents,
|
||||
warnings: data.warnings || [],
|
||||
})
|
||||
} catch (e: unknown) {
|
||||
const message = e instanceof Error ? e.message : 'Unbekannter Fehler'
|
||||
return NextResponse.json(
|
||||
{ error: 'Proxy-Fehler', detail: message },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2,30 +2,40 @@
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { ChecklistView } from './ChecklistView'
|
||||
import { PreScanWizard, useScanContext, isContextComplete } from './PreScanWizard'
|
||||
import { safeSetItem } from './storageHelpers'
|
||||
|
||||
interface DocEntry {
|
||||
id: string
|
||||
type: string
|
||||
label: string
|
||||
url: string
|
||||
text: string // P-Paste: User kopiert Doc-Text direkt rein
|
||||
mode: 'url' | 'text' // welcher Input wird aktiv genutzt
|
||||
}
|
||||
|
||||
const DOC_TYPES = [
|
||||
{ id: 'dse', label: 'DSI (Datenschutzinformation)' },
|
||||
{ id: 'dse', label: 'Datenschutzerklärung / DSI' },
|
||||
{ id: 'cookie', label: 'Cookie-Richtlinie' },
|
||||
{ id: 'impressum', label: 'Impressum' },
|
||||
{ id: 'agb', label: 'AGB' },
|
||||
{ id: 'nutzungsbedingungen', label: 'Nutzungsbedingungen' },
|
||||
{ id: 'widerruf', label: 'Widerrufsbelehrung' },
|
||||
{ id: 'social_media', label: 'DSE Social Media (Art. 26)' },
|
||||
{ id: 'dsfa', label: 'DSFA (Art. 35)' },
|
||||
{ id: 'agb', label: 'AGB / Nutzungsbedingungen' },
|
||||
{ id: 'impressum', label: 'Impressum' },
|
||||
{ id: 'cookie', label: 'Cookie-Richtlinie' },
|
||||
{ id: 'widerruf', label: 'Widerrufsbelehrung' },
|
||||
{ id: 'dsa', label: 'DSA / Digital Services Act' },
|
||||
{ id: 'legal_notice', label: 'Rechtliche Hinweise (IP, Forward-Looking)' },
|
||||
{ id: 'lizenzhinweise', label: 'Lizenzhinweise Dritter (OSS)' },
|
||||
{ id: 'other', label: 'Sonstiges' },
|
||||
]
|
||||
|
||||
function newEntry(): DocEntry {
|
||||
return { id: crypto.randomUUID().slice(0, 8), type: 'dse', label: '', url: '' }
|
||||
return { id: crypto.randomUUID().slice(0, 8), type: 'dse', label: '',
|
||||
url: '', text: '', mode: 'url' }
|
||||
}
|
||||
|
||||
export function DocCheckTab() {
|
||||
const [scanContext, setScanContext] = useScanContext()
|
||||
const [entries, setEntries] = useState<DocEntry[]>(() => {
|
||||
if (typeof window === 'undefined') return [newEntry()]
|
||||
try { const s = localStorage.getItem('doc-check-entries'); return s ? JSON.parse(s) : [newEntry()] } catch { return [newEntry()] }
|
||||
@@ -74,7 +84,7 @@ export function DocCheckTab() {
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const validEntries = entries.filter(e => e.url.trim())
|
||||
const validEntries = entries.filter(e => e.url.trim() || e.text.trim())
|
||||
if (validEntries.length === 0) return
|
||||
|
||||
setLoading(true)
|
||||
@@ -89,11 +99,17 @@ export function DocCheckTab() {
|
||||
body: JSON.stringify({
|
||||
entries: validEntries.map(e => ({
|
||||
doc_type: e.type,
|
||||
label: e.label || e.url.split('/').pop() || 'Dokument',
|
||||
url: e.url.trim(),
|
||||
label: e.label
|
||||
|| (e.url ? e.url.split('/').pop() : '')
|
||||
|| `${e.type}-paste`,
|
||||
url: e.mode === 'text' ? '' : e.url.trim(),
|
||||
// Backend nimmt text > url. Wenn beide gefuellt sind und
|
||||
// mode='url', schicken wir den text NICHT mit.
|
||||
text: e.mode === 'text' ? e.text.trim() : '',
|
||||
})),
|
||||
check_cookie_banner: checkCookieBanner,
|
||||
use_agent: useAgent,
|
||||
scan_context: scanContext,
|
||||
}),
|
||||
})
|
||||
if (!startRes.ok) throw new Error(`Pruefung konnte nicht gestartet werden: ${startRes.status}`)
|
||||
@@ -111,13 +127,13 @@ export function DocCheckTab() {
|
||||
if (pollData.status === 'completed' && pollData.result) {
|
||||
setResults(pollData.result)
|
||||
setProgress('')
|
||||
localStorage.setItem('doc-check-results', JSON.stringify(pollData.result))
|
||||
safeSetItem('doc-check-results', JSON.stringify(pollData.result))
|
||||
const resultKey = `doc-check-result-${Date.now()}`
|
||||
try { localStorage.setItem(resultKey, JSON.stringify(pollData.result)) } catch { /* quota */ }
|
||||
safeSetItem(resultKey, JSON.stringify(pollData.result))
|
||||
const entry = { date: new Date().toISOString(), urls: validEntries.length, findings: pollData.result.total_findings || 0, resultKey }
|
||||
const updated = [entry, ...history].slice(0, 30)
|
||||
setHistory(updated)
|
||||
localStorage.setItem('doc-check-history', JSON.stringify(updated))
|
||||
safeSetItem('doc-check-history', JSON.stringify(updated))
|
||||
break
|
||||
}
|
||||
if (pollData.status === 'failed') {
|
||||
@@ -133,43 +149,90 @@ export function DocCheckTab() {
|
||||
}
|
||||
}
|
||||
|
||||
const contextReady = isContextComplete(scanContext)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* URL Entries */}
|
||||
<div className="space-y-2">
|
||||
{/* P79 Pre-Scan-Wizard — 8 Pflichtfelder */}
|
||||
<PreScanWizard value={scanContext} onChange={setScanContext} />
|
||||
|
||||
{/* URL / Text Entries */}
|
||||
<div className="space-y-3">
|
||||
{entries.map((entry, i) => (
|
||||
<div key={entry.id} className="flex items-center gap-2">
|
||||
<select
|
||||
value={entry.type}
|
||||
onChange={e => updateEntry(entry.id, 'type', e.target.value)}
|
||||
className="w-48 px-3 py-2.5 border border-gray-300 rounded-lg text-sm bg-white shrink-0"
|
||||
>
|
||||
{DOC_TYPES.map(t => (
|
||||
<option key={t.id} value={t.id}>{t.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
value={entry.label}
|
||||
onChange={e => updateEntry(entry.id, 'label', e.target.value)}
|
||||
placeholder={entry.type === 'other' ? 'Dokumentname' : 'Version / Stand (optional)'}
|
||||
className="w-40 px-3 py-2.5 border border-gray-300 rounded-lg text-sm shrink-0"
|
||||
/>
|
||||
<input
|
||||
type="url"
|
||||
value={entry.url}
|
||||
onChange={e => updateEntry(entry.id, 'url', e.target.value)}
|
||||
onBlur={() => autoLabel(entry)}
|
||||
placeholder="https://example.com/datenschutz"
|
||||
className="flex-1 px-3 py-2.5 border border-gray-300 rounded-lg text-sm"
|
||||
/>
|
||||
{entries.length > 1 && (
|
||||
<button onClick={() => removeEntry(entry.id)}
|
||||
className="p-2 text-gray-400 hover:text-red-500 shrink-0">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
<div key={entry.id} className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={entry.type}
|
||||
onChange={e => updateEntry(entry.id, 'type', e.target.value)}
|
||||
className="w-48 px-3 py-2.5 border border-gray-300 rounded-lg text-sm bg-white shrink-0"
|
||||
>
|
||||
{DOC_TYPES.map(t => (
|
||||
<option key={t.id} value={t.id}>{t.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
value={entry.label}
|
||||
onChange={e => updateEntry(entry.id, 'label', e.target.value)}
|
||||
placeholder={entry.type === 'other' ? 'Dokumentname' : 'Version / Stand (optional)'}
|
||||
className="w-40 px-3 py-2.5 border border-gray-300 rounded-lg text-sm shrink-0"
|
||||
/>
|
||||
|
||||
{/* Mode-Toggle URL / Text */}
|
||||
<div className="inline-flex border border-gray-300 rounded-lg overflow-hidden text-xs shrink-0">
|
||||
<button type="button"
|
||||
onClick={() => updateEntry(entry.id, 'mode', 'url')}
|
||||
className={`px-3 py-2 ${entry.mode === 'url'
|
||||
? 'bg-purple-600 text-white' : 'bg-white text-gray-600 hover:bg-gray-50'}`}>
|
||||
URL
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={() => updateEntry(entry.id, 'mode', 'text')}
|
||||
className={`px-3 py-2 ${entry.mode === 'text'
|
||||
? 'bg-purple-600 text-white' : 'bg-white text-gray-600 hover:bg-gray-50'}`}>
|
||||
Text einfügen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{entry.mode === 'url' && (
|
||||
<input
|
||||
type="url"
|
||||
value={entry.url}
|
||||
onChange={e => updateEntry(entry.id, 'url', e.target.value)}
|
||||
onBlur={() => autoLabel(entry)}
|
||||
placeholder="https://example.com/datenschutz"
|
||||
className="flex-1 px-3 py-2.5 border border-gray-300 rounded-lg text-sm"
|
||||
/>
|
||||
)}
|
||||
|
||||
{entries.length > 1 && (
|
||||
<button onClick={() => removeEntry(entry.id)}
|
||||
className="p-2 text-gray-400 hover:text-red-500 shrink-0">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{entry.mode === 'text' && (
|
||||
<div className="ml-[400px]">
|
||||
<textarea
|
||||
value={entry.text}
|
||||
onChange={e => updateEntry(entry.id, 'text', e.target.value)}
|
||||
placeholder={
|
||||
entry.type === 'cookie'
|
||||
? 'Kopiere hier die komplette Cookie-Tabelle rein (Tab-getrennt oder mit | als Trenner — wir parsen alle Spalten deterministisch)…'
|
||||
: 'Kopiere hier den vollständigen Doc-Text rein. Wir erkennen automatisch ob es zu „' + (DOC_TYPES.find(t => t.id === entry.type)?.label ?? entry.type) + '" passt.'
|
||||
}
|
||||
className="w-full h-32 px-3 py-2 border border-gray-300 rounded-lg text-xs font-mono resize-y"
|
||||
/>
|
||||
<div className="text-[10px] text-gray-500 mt-1">
|
||||
{entry.text.trim().length > 0
|
||||
? `${entry.text.trim().length.toLocaleString('de-DE')} Zeichen · ${entry.text.trim().split(/\s+/).length.toLocaleString('de-DE')} Wörter`
|
||||
: 'Der Crawler wird übersprungen — die Analyse läuft direkt auf dem eingefügten Text.'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
@@ -212,8 +275,11 @@ export function DocCheckTab() {
|
||||
{/* Submit */}
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={loading || entries.every(e => !e.url.trim())}
|
||||
disabled={loading
|
||||
|| entries.every(e => !e.url.trim() && !e.text.trim())
|
||||
|| !contextReady}
|
||||
className="w-full px-4 py-3 bg-purple-600 text-white rounded-lg font-medium hover:bg-purple-700 disabled:opacity-50 transition-colors text-sm flex items-center justify-center gap-2"
|
||||
title={!contextReady ? 'Bitte zuerst die 8 Pflichtfelder ausfüllen' : undefined}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
@@ -223,6 +289,8 @@ export function DocCheckTab() {
|
||||
</svg>
|
||||
Pruefe...
|
||||
</>
|
||||
) : !contextReady ? (
|
||||
`Klassifizierung unvollständig (8 Pflichtfelder)`
|
||||
) : (
|
||||
`${entries.filter(e => e.url.trim()).length} Dokument${entries.filter(e => e.url.trim()).length !== 1 ? 'e' : ''} pruefen`
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* P79 — Pre-Scan-Wizard (8 Pflichtfelder).
|
||||
*
|
||||
* 8 Pflichtfelder die vor dem Lauf abgefragt werden. Werte landen im
|
||||
* scan_context und filtern später die MC-Auswertung (zusammen mit P72
|
||||
* scope_doc_type + applicable_industries). Erwartete Noise-Reduktion:
|
||||
* 70-80% bei falsch zugeordneten HIGH-MCs.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
|
||||
export interface ScanContext {
|
||||
industry: string
|
||||
business_model: string
|
||||
direct_sales: string
|
||||
legal_form: string
|
||||
group_structure: string
|
||||
employee_count: string
|
||||
special_data: string[]
|
||||
third_country_transfer: string
|
||||
}
|
||||
|
||||
const INDUSTRIES = [
|
||||
{ id: '', label: '— bitte wählen —' },
|
||||
{ id: 'automotive', label: 'Automotive / OEM' },
|
||||
{ id: 'ecommerce', label: 'E-Commerce / Online-Handel' },
|
||||
{ id: 'saas', label: 'SaaS / Software' },
|
||||
{ id: 'banking', label: 'Banking / Finance' },
|
||||
{ id: 'insurance', label: 'Insurance / Versicherung' },
|
||||
{ id: 'healthcare', label: 'Healthcare / Gesundheit' },
|
||||
{ id: 'education', label: 'Bildung / Schule' },
|
||||
{ id: 'public', label: 'Öffentliche Verwaltung' },
|
||||
{ id: 'manufacturing', label: 'Industrie / Manufacturing' },
|
||||
{ id: 'media', label: 'Medien / Verlag' },
|
||||
{ id: 'other', label: 'Sonstige' },
|
||||
]
|
||||
|
||||
const LEGAL_FORMS = [
|
||||
{ id: '', label: '— bitte wählen —' },
|
||||
{ id: 'ag', label: 'AG (Aktiengesellschaft)' },
|
||||
{ id: 'gmbh', label: 'GmbH' },
|
||||
{ id: 'gmbh_co_kg', label: 'GmbH & Co. KG' },
|
||||
{ id: 'kg', label: 'KG' },
|
||||
{ id: 'ohg', label: 'OHG' },
|
||||
{ id: 'ug', label: 'UG (haftungsbeschränkt)' },
|
||||
{ id: 'ek', label: 'e.K. / Einzelunternehmen' },
|
||||
{ id: 'verein', label: 'Verein' },
|
||||
{ id: 'stiftung', label: 'Stiftung' },
|
||||
{ id: 'behoerde', label: 'Behörde / Körperschaft öff. Rechts' },
|
||||
{ id: 'other', label: 'Sonstige' },
|
||||
]
|
||||
|
||||
const GROUP_STRUCTURES = [
|
||||
{ id: '', label: '— bitte wählen —' },
|
||||
{ id: 'standalone', label: 'Eigenständig' },
|
||||
{ id: 'parent', label: 'Konzern-Mutter' },
|
||||
{ id: 'subsidiary', label: 'Konzern-Tochter' },
|
||||
{ id: 'joint_venture', label: 'Joint Venture' },
|
||||
{ id: 'processor', label: 'Reiner Auftragsverarbeiter' },
|
||||
]
|
||||
|
||||
const EMPLOYEE_COUNTS = [
|
||||
{ id: '', label: '— bitte wählen —' },
|
||||
{ id: 'lt10', label: 'unter 10' },
|
||||
{ id: '10_19', label: '10-19' },
|
||||
{ id: '20_49', label: '20-49 (DSB ab 20 Pflicht)' },
|
||||
{ id: '50_249', label: '50-249 (Whistleblower-Pflicht)' },
|
||||
{ id: '250_499', label: '250-499' },
|
||||
{ id: '500_999', label: '500-999' },
|
||||
{ id: '1000_plus', label: '1.000+ (Konzern)' },
|
||||
]
|
||||
|
||||
const SPECIAL_DATA_OPTIONS = [
|
||||
{ id: 'health', label: 'Gesundheitsdaten' },
|
||||
{ id: 'biometric', label: 'Biometrische Daten' },
|
||||
{ id: 'ethnicity', label: 'Religiöse / ethnische Herkunft' },
|
||||
{ id: 'sexual', label: 'Sexuelle Orientierung' },
|
||||
{ id: 'criminal', label: 'Strafrechtliche Daten' },
|
||||
{ id: 'minors', label: 'Minderjährige (<16)' },
|
||||
{ id: 'none', label: 'Keine besonderen Daten' },
|
||||
]
|
||||
|
||||
const STORAGE_KEY = 'compliance-scan-context'
|
||||
|
||||
function emptyContext(): ScanContext {
|
||||
return {
|
||||
industry: '',
|
||||
business_model: '',
|
||||
direct_sales: '',
|
||||
legal_form: '',
|
||||
group_structure: '',
|
||||
employee_count: '',
|
||||
special_data: [],
|
||||
third_country_transfer: '',
|
||||
}
|
||||
}
|
||||
|
||||
export function isContextComplete(ctx: ScanContext): boolean {
|
||||
return Boolean(
|
||||
ctx.industry &&
|
||||
ctx.business_model &&
|
||||
ctx.direct_sales &&
|
||||
ctx.legal_form &&
|
||||
ctx.group_structure &&
|
||||
ctx.employee_count &&
|
||||
ctx.special_data.length > 0 &&
|
||||
ctx.third_country_transfer
|
||||
)
|
||||
}
|
||||
|
||||
export function PreScanWizard({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: ScanContext
|
||||
onChange: (ctx: ScanContext) => void
|
||||
}) {
|
||||
const update = <K extends keyof ScanContext>(key: K, val: ScanContext[K]) => {
|
||||
onChange({ ...value, [key]: val })
|
||||
}
|
||||
|
||||
const toggleSpecialData = (id: string) => {
|
||||
const next = value.special_data.includes(id)
|
||||
? value.special_data.filter(x => x !== id)
|
||||
: [...value.special_data.filter(x => x !== 'none' || id === 'none'), id]
|
||||
onChange({ ...value, special_data: id === 'none' ? ['none'] : next.filter(x => x !== 'none') })
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
background: '#f0f9ff',
|
||||
border: '1px solid #bfdbfe',
|
||||
borderRadius: 8,
|
||||
padding: '14px 16px',
|
||||
marginBottom: 14,
|
||||
}}>
|
||||
<div style={{ fontSize: 11, color: '#1e40af', textTransform: 'uppercase',
|
||||
letterSpacing: 1.2, marginBottom: 4, fontWeight: 600 }}>
|
||||
Pflichtangaben zur Klassifizierung des Audits
|
||||
</div>
|
||||
<h3 style={{ margin: '0 0 6px', fontSize: 14, color: '#1e293b' }}>
|
||||
Vor dem Scan: 8 Angaben zum Unternehmen
|
||||
</h3>
|
||||
<p style={{ margin: '0 0 12px', fontSize: 11, color: '#475569', lineHeight: 1.5 }}>
|
||||
Diese Angaben filtern irrelevante Compliance-Themen heraus (z.B. eHealth-
|
||||
Vorschriften bei einem Autobauer) und liefern eine realistische
|
||||
Einschätzung statt pauschaler Verstoss-Listen.
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 10 }}>
|
||||
<Field label="1. Branche*">
|
||||
<select value={value.industry} onChange={e => update('industry', e.target.value)} style={inputStyle}>
|
||||
{INDUSTRIES.map(o => <option key={o.id} value={o.id}>{o.label}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<Field label="2. Geschäftsmodell*">
|
||||
<select value={value.business_model} onChange={e => update('business_model', e.target.value)} style={inputStyle}>
|
||||
<option value="">— bitte wählen —</option>
|
||||
<option value="b2b">B2B</option>
|
||||
<option value="b2c">B2C</option>
|
||||
<option value="both">Beides (B2B + B2C)</option>
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<Field label="3. Direkt-Vertrieb (Webshop/Buchung)*">
|
||||
<select value={value.direct_sales} onChange={e => update('direct_sales', e.target.value)} style={inputStyle}>
|
||||
<option value="">— bitte wählen —</option>
|
||||
<option value="yes">Ja</option>
|
||||
<option value="no">Nein</option>
|
||||
<option value="lead_funnel">Nur Lead-Funnel (Probefahrten, Anfragen)</option>
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<Field label="4. Rechtsform*">
|
||||
<select value={value.legal_form} onChange={e => update('legal_form', e.target.value)} style={inputStyle}>
|
||||
{LEGAL_FORMS.map(o => <option key={o.id} value={o.id}>{o.label}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<Field label="5. Konzern-Struktur*">
|
||||
<select value={value.group_structure} onChange={e => update('group_structure', e.target.value)} style={inputStyle}>
|
||||
{GROUP_STRUCTURES.map(o => <option key={o.id} value={o.id}>{o.label}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<Field label="6. Mitarbeiterzahl*">
|
||||
<select value={value.employee_count} onChange={e => update('employee_count', e.target.value)} style={inputStyle}>
|
||||
{EMPLOYEE_COUNTS.map(o => <option key={o.id} value={o.id}>{o.label}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<Field label="7. Besondere Datenkategorien*" colSpan={2}>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
{SPECIAL_DATA_OPTIONS.map(o => (
|
||||
<label key={o.id} style={{ fontSize: 12, display: 'inline-flex',
|
||||
alignItems: 'center', gap: 4,
|
||||
padding: '4px 8px', background: '#fff',
|
||||
border: '1px solid #cbd5e1',
|
||||
borderRadius: 4 }}>
|
||||
<input type="checkbox"
|
||||
checked={value.special_data.includes(o.id)}
|
||||
onChange={() => toggleSpecialData(o.id)} />
|
||||
{o.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<Field label="8. Bekannter Drittland-Transfer*" colSpan={2}>
|
||||
<select value={value.third_country_transfer} onChange={e => update('third_country_transfer', e.target.value)} style={inputStyle}>
|
||||
<option value="">— bitte wählen —</option>
|
||||
<option value="yes">Ja (USA, CN, IN, UK, ...)</option>
|
||||
<option value="no">Nein (nur EU/EWR)</option>
|
||||
<option value="unknown">Weiß nicht (bitte automatisch prüfen)</option>
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{!isContextComplete(value) && (
|
||||
<div style={{ marginTop: 10, fontSize: 11, color: '#92400e',
|
||||
background: '#fef3c7', padding: '6px 10px',
|
||||
borderRadius: 4, border: '1px solid #fde68a' }}>
|
||||
Bitte alle 8 Pflichtfelder ausfüllen — der Scan-Button wird erst aktiv,
|
||||
wenn die Klassifizierung komplett ist.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: '100%',
|
||||
padding: '6px 8px',
|
||||
fontSize: 12,
|
||||
border: '1px solid #cbd5e1',
|
||||
borderRadius: 4,
|
||||
background: '#fff',
|
||||
}
|
||||
|
||||
function Field({ label, children, colSpan }: { label: string; children: React.ReactNode; colSpan?: number }) {
|
||||
return (
|
||||
<div style={{ gridColumn: colSpan ? `span ${colSpan}` : undefined }}>
|
||||
<label style={{ display: 'block', fontSize: 11, color: '#475569',
|
||||
marginBottom: 4, fontWeight: 600 }}>
|
||||
{label}
|
||||
</label>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function useScanContext(): [ScanContext, (ctx: ScanContext) => void] {
|
||||
const [ctx, setCtx] = useState<ScanContext>(() => {
|
||||
if (typeof window === 'undefined') return emptyContext()
|
||||
try {
|
||||
const s = localStorage.getItem(STORAGE_KEY)
|
||||
return s ? { ...emptyContext(), ...JSON.parse(s) } : emptyContext()
|
||||
} catch {
|
||||
return emptyContext()
|
||||
}
|
||||
})
|
||||
useEffect(() => {
|
||||
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(ctx)) } catch {}
|
||||
}, [ctx])
|
||||
return [ctx, setCtx]
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* P47 — localStorage-Quota-Management.
|
||||
*
|
||||
* Wenn alte Compliance-Check-Ergebnisse den Browser-Storage fuellen,
|
||||
* versucht das setItem mit QuotaExceededError zu fangen, prunet
|
||||
* alte doc-check-result-*-Eintraege (oldest first) und retried.
|
||||
*
|
||||
* Wird von DocCheckTab/BannerCheckTab/etc beim Persistieren der
|
||||
* Result-Bloebs benutzt.
|
||||
*/
|
||||
|
||||
const RESULT_KEY_PREFIX = 'doc-check-result-'
|
||||
const MAX_KEEP = 10 // Maximal 10 alte Result-Bloebs behalten.
|
||||
|
||||
export function safeSetItem(key: string, value: string): boolean {
|
||||
try {
|
||||
localStorage.setItem(key, value)
|
||||
return true
|
||||
} catch (err: any) {
|
||||
if (err?.name !== 'QuotaExceededError'
|
||||
&& err?.code !== 22 && err?.code !== 1014) {
|
||||
console.warn('localStorage setItem failed:', err)
|
||||
return false
|
||||
}
|
||||
pruneOldResults()
|
||||
try {
|
||||
localStorage.setItem(key, value)
|
||||
return true
|
||||
} catch {
|
||||
// Pruning hat nicht gereicht — aggressiver pruefen
|
||||
pruneOldResults(0)
|
||||
try {
|
||||
localStorage.setItem(key, value)
|
||||
return true
|
||||
} catch {
|
||||
console.warn('localStorage immer noch voll, wert wird verworfen')
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function pruneOldResults(keep: number = MAX_KEEP): void {
|
||||
try {
|
||||
const keys: { key: string; ts: number }[] = []
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const k = localStorage.key(i)
|
||||
if (!k || !k.startsWith(RESULT_KEY_PREFIX)) continue
|
||||
const ts = Number(k.slice(RESULT_KEY_PREFIX.length)) || 0
|
||||
keys.push({ key: k, ts })
|
||||
}
|
||||
keys.sort((a, b) => a.ts - b.ts) // oldest first
|
||||
const toRemove = keys.slice(0, Math.max(0, keys.length - keep))
|
||||
for (const k of toRemove) {
|
||||
try { localStorage.removeItem(k.key) } catch {}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export function getStorageUsageMB(): number {
|
||||
let bytes = 0
|
||||
try {
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const k = localStorage.key(i)
|
||||
if (!k) continue
|
||||
const v = localStorage.getItem(k) || ''
|
||||
bytes += k.length + v.length
|
||||
}
|
||||
} catch {}
|
||||
return bytes / (1024 * 1024)
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
'use client'
|
||||
|
||||
import React, { useEffect, useState } from 'react'
|
||||
|
||||
type Phase = {
|
||||
cookies?: string[]
|
||||
scripts?: string[]
|
||||
tracking_services?: (string | { name?: string })[]
|
||||
new_tracking?: unknown[]
|
||||
violations?: Array<{ severity?: string; text?: string }>
|
||||
undocumented?: unknown[]
|
||||
}
|
||||
|
||||
type CategoryTest = {
|
||||
category: string
|
||||
category_label: string
|
||||
tracking_services?: (string | { name?: string })[]
|
||||
cookies_set?: string[]
|
||||
provider_details_visible?: boolean
|
||||
violations?: Array<{ severity?: string; text?: string; legal_ref?: string }>
|
||||
}
|
||||
|
||||
type BannerViolation = {
|
||||
severity?: string
|
||||
text?: string
|
||||
legal_ref?: string
|
||||
}
|
||||
|
||||
type StructuredCheck = {
|
||||
id: string
|
||||
label: string
|
||||
passed: boolean
|
||||
skipped?: boolean
|
||||
severity: string
|
||||
level?: number
|
||||
hint?: string
|
||||
}
|
||||
|
||||
type BannerResp = {
|
||||
found: boolean
|
||||
check_id: string
|
||||
banner?: {
|
||||
banner_provider?: string
|
||||
banner_detected?: boolean
|
||||
completeness_pct?: number
|
||||
correctness_pct?: number
|
||||
phases?: Record<string, Phase>
|
||||
banner_checks?: { violations?: BannerViolation[] }
|
||||
category_tests?: CategoryTest[]
|
||||
structured_checks?: StructuredCheck[]
|
||||
summary?: Record<string, number>
|
||||
}
|
||||
}
|
||||
|
||||
const PHASE_LABEL: Record<string, string> = {
|
||||
before_consent: 'Vor Consent',
|
||||
after_reject: 'Nach Ablehnung',
|
||||
after_accept: 'Nach Annahme',
|
||||
}
|
||||
|
||||
const SEV_BADGE: Record<string, string> = {
|
||||
CRITICAL: 'bg-red-600 text-white',
|
||||
HIGH: 'bg-red-100 text-red-800',
|
||||
MEDIUM: 'bg-amber-100 text-amber-800',
|
||||
LOW: 'bg-blue-100 text-blue-800',
|
||||
INFO: 'bg-gray-100 text-gray-600',
|
||||
}
|
||||
|
||||
function pctColor(pct?: number): string {
|
||||
if (pct === undefined || pct === null) return 'text-gray-400'
|
||||
return pct >= 80 ? 'text-green-700' : pct >= 50 ? 'text-amber-700' : 'text-red-700'
|
||||
}
|
||||
|
||||
export default function BannerTab({ checkId }: { checkId: string }) {
|
||||
const [data, setData] = useState<BannerResp | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [checkFilter, setCheckFilter] = useState<'all' | 'fail' | 'critical'>('fail')
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
fetch(`/api/sdk/v1/agent/banner/${checkId}`)
|
||||
.then(r => r.json())
|
||||
.then(d => { if (!cancelled) setData(d) })
|
||||
.catch(e => { if (!cancelled) setError(String(e)) })
|
||||
.finally(() => { if (!cancelled) setLoading(false) })
|
||||
return () => { cancelled = true }
|
||||
}, [checkId])
|
||||
|
||||
if (loading) return <div className="p-6 text-sm text-gray-500">Lade Banner-Daten…</div>
|
||||
if (error) return <div className="p-6 text-sm text-red-600">Fehler: {error}</div>
|
||||
if (!data?.found || !data.banner) {
|
||||
return <div className="p-6 text-sm text-gray-500">Keine Banner-Daten zu diesem Check.</div>
|
||||
}
|
||||
|
||||
const b = data.banner
|
||||
const phases = b.phases || {}
|
||||
const cats = b.category_tests || []
|
||||
const violations = b.banner_checks?.violations || []
|
||||
const checks = b.structured_checks || []
|
||||
const summary = b.summary || {}
|
||||
|
||||
const filteredChecks = checks.filter(c => {
|
||||
if (checkFilter === 'all') return true
|
||||
if (checkFilter === 'fail') return !c.passed && !c.skipped
|
||||
return !c.passed && !c.skipped && ['CRITICAL', 'HIGH'].includes(c.severity)
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Quality Cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-xs">
|
||||
<div className="border rounded p-3">
|
||||
<div className="text-[10px] uppercase text-gray-500">Vollstaendigkeit</div>
|
||||
<div className={`text-2xl font-semibold ${pctColor(b.completeness_pct)}`}>
|
||||
{b.completeness_pct ?? '–'}{b.completeness_pct !== undefined && '%'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="border rounded p-3">
|
||||
<div className="text-[10px] uppercase text-gray-500">Korrektheit</div>
|
||||
<div className={`text-2xl font-semibold ${pctColor(b.correctness_pct)}`}>
|
||||
{b.correctness_pct ?? '–'}{b.correctness_pct !== undefined && '%'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="border rounded p-3">
|
||||
<div className="text-[10px] uppercase text-gray-500">Verstoesse</div>
|
||||
<div className="text-2xl font-semibold text-red-700">
|
||||
{summary.total_violations ?? violations.length}
|
||||
</div>
|
||||
<div className="text-[10px] text-gray-500 mt-1">
|
||||
crit:{summary.critical ?? 0} · high:{summary.high ?? 0}
|
||||
</div>
|
||||
</div>
|
||||
<div className="border rounded p-3">
|
||||
<div className="text-[10px] uppercase text-gray-500">CMP</div>
|
||||
<div className="text-sm font-medium text-gray-800 truncate">
|
||||
{b.banner_provider || 'unbekannt'}
|
||||
</div>
|
||||
<div className="text-[10px] text-gray-500 mt-1">
|
||||
{b.banner_detected ? 'Banner erkannt' : 'kein Banner'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Phases */}
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<div className="px-4 py-2 bg-gray-50 border-b text-sm font-medium text-gray-700">
|
||||
Cookie-Setzungen pro Phase (echter Browser-Test)
|
||||
</div>
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-gray-50 text-gray-600">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left">Phase</th>
|
||||
<th className="px-3 py-2 text-center">Cookies</th>
|
||||
<th className="px-3 py-2 text-center">Tracker</th>
|
||||
<th className="px-3 py-2 text-left">Auffaelligkeiten</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(['before_consent', 'after_reject', 'after_accept'] as const).map(key => {
|
||||
const p = phases[key] || {}
|
||||
const nc = (p.cookies || []).length
|
||||
const nt = (p.tracking_services || []).length
|
||||
const issues: string[] = []
|
||||
if (p.violations?.length) issues.push(`${p.violations.length} Verstoss`)
|
||||
if (p.new_tracking?.length) issues.push(`${p.new_tracking.length} neue Tracker`)
|
||||
if (p.undocumented?.length) issues.push(`${p.undocumented.length} undokumentiert`)
|
||||
const color = key === 'before_consent'
|
||||
? (nc === 0 ? 'text-green-600' : 'text-red-600')
|
||||
: key === 'after_reject'
|
||||
? (nc <= 1 ? 'text-green-600' : 'text-amber-600')
|
||||
: 'text-gray-700'
|
||||
return (
|
||||
<tr key={key} className="border-t">
|
||||
<td className="px-3 py-2 font-medium">{PHASE_LABEL[key]}</td>
|
||||
<td className={`px-3 py-2 text-center font-semibold ${color}`}>{nc}</td>
|
||||
<td className="px-3 py-2 text-center">{nt}</td>
|
||||
<td className="px-3 py-2 text-gray-500">{issues.join(', ') || '—'}</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Per-Category */}
|
||||
{cats.length > 0 && (
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<div className="px-4 py-2 bg-gray-50 border-b text-sm font-medium text-gray-700">
|
||||
Provider-Listing pro Kategorie (P19 Click-Through-Test)
|
||||
</div>
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-gray-50 text-gray-600">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left">Kategorie</th>
|
||||
<th className="px-3 py-2 text-center">Anbieter sichtbar</th>
|
||||
<th className="px-3 py-2 text-center">Tracker erkannt</th>
|
||||
<th className="px-3 py-2 text-left">Violations</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{cats.map(c => {
|
||||
const pdv = c.provider_details_visible
|
||||
const pdv_label = pdv === true ? 'Ja' : pdv === false ? 'Nein' : '–'
|
||||
const pdv_color = pdv === false ? 'text-red-700' : pdv === true ? 'text-green-700' : 'text-gray-400'
|
||||
return (
|
||||
<tr key={c.category} className="border-t">
|
||||
<td className="px-3 py-2">{c.category_label}</td>
|
||||
<td className={`px-3 py-2 text-center font-semibold ${pdv_color}`}>{pdv_label}</td>
|
||||
<td className="px-3 py-2 text-center">{(c.tracking_services || []).length}</td>
|
||||
<td className="px-3 py-2 text-red-700 text-[10px]">
|
||||
{(c.violations || []).map(v => v.text?.slice(0, 80)).join('; ') || '—'}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Banner-Checks Violations */}
|
||||
{violations.length > 0 && (
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<div className="px-4 py-2 bg-gray-50 border-b text-sm font-medium text-gray-700">
|
||||
Banner-Verstoesse ({violations.length})
|
||||
</div>
|
||||
<ul className="text-xs divide-y">
|
||||
{violations.map((v, i) => {
|
||||
const sev = (v.severity || 'MEDIUM').toUpperCase()
|
||||
return (
|
||||
<li key={i} className="px-3 py-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className={`px-1.5 py-0.5 rounded text-[10px] font-medium ${SEV_BADGE[sev] || 'bg-gray-100'}`}>{sev}</span>
|
||||
<div>
|
||||
<div className="text-gray-900">{v.text}</div>
|
||||
{v.legal_ref && <div className="text-[10px] text-gray-400 italic mt-1">Quelle: {v.legal_ref}</div>}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 46 structured_checks Drilldown */}
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<div className="px-4 py-2 bg-gray-50 border-b text-sm font-medium text-gray-700 flex items-center gap-3">
|
||||
<span>Banner-Checks ({checks.length})</span>
|
||||
<div className="ml-auto flex gap-1">
|
||||
{(['all', 'fail', 'critical'] as const).map(f => (
|
||||
<button key={f}
|
||||
onClick={() => setCheckFilter(f)}
|
||||
className={`px-2 py-1 rounded text-[10px] border ${
|
||||
checkFilter === f ? 'bg-blue-600 text-white border-blue-600'
|
||||
: 'bg-white text-gray-600 border-gray-200'
|
||||
}`}>
|
||||
{f === 'all' ? 'Alle' : f === 'fail' ? 'Nur Fail' : 'Nur CRIT/HIGH'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-gray-50 text-gray-600">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left">Status</th>
|
||||
<th className="px-3 py-2 text-left">Sev</th>
|
||||
<th className="px-3 py-2 text-left">Check</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredChecks.map(c => (
|
||||
<tr key={c.id} className="border-t">
|
||||
<td className="px-3 py-2">
|
||||
{c.passed ? <span className="text-green-600">✓</span>
|
||||
: c.skipped ? <span className="text-gray-400">—</span>
|
||||
: <span className="text-red-600">✗</span>}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className={`px-1.5 py-0.5 rounded text-[10px] font-medium ${SEV_BADGE[c.severity] || 'bg-gray-100'}`}>
|
||||
{c.severity}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="text-gray-900">{c.label}</div>
|
||||
{c.hint && !c.passed && (
|
||||
<div className="text-[10px] text-gray-500 mt-1">{c.hint.slice(0, 200)}</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{filteredChecks.length === 0 && (
|
||||
<tr><td colSpan={3} className="px-3 py-4 text-center text-gray-400">Keine Checks fuer den Filter.</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import React, { useEffect, useState, useMemo } from 'react'
|
||||
import { use as useUnwrap } from 'react'
|
||||
import FindingsTab from './FindingsTab'
|
||||
import BannerTab from './BannerTab'
|
||||
|
||||
type MCRow = {
|
||||
id: number
|
||||
@@ -92,7 +93,7 @@ export default function AuditPage(
|
||||
const [filterReg, setFilterReg] = useState<string>('')
|
||||
const [filterDoc, setFilterDoc] = useState<string>('')
|
||||
const [expanded, setExpanded] = useState<number | null>(null)
|
||||
const [tab, setTab] = useState<'mc' | 'all'>('all')
|
||||
const [tab, setTab] = useState<'mc' | 'all' | 'banner'>('all')
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
@@ -155,6 +156,7 @@ export default function AuditPage(
|
||||
<div className="flex gap-2 border-b border-gray-200">
|
||||
{([
|
||||
{ key: 'all', label: 'Voll-Audit (alle Findings)' },
|
||||
{ key: 'banner', label: 'Cookie-Banner-Analyse' },
|
||||
{ key: 'mc', label: 'Nur MC-Scorecard' },
|
||||
] as const).map(t => (
|
||||
<button key={t.key}
|
||||
@@ -168,6 +170,7 @@ export default function AuditPage(
|
||||
</div>
|
||||
|
||||
{tab === 'all' && <FindingsTab checkId={checkId} />}
|
||||
{tab === 'banner' && <BannerTab checkId={checkId} />}
|
||||
|
||||
{tab === 'mc' && <>
|
||||
{/* Scorecard */}
|
||||
|
||||
@@ -362,6 +362,16 @@ export default function AIActPage() {
|
||||
)}
|
||||
</StepHeader>
|
||||
|
||||
<div className="px-4 py-2 bg-emerald-50 border border-emerald-200 rounded-lg text-xs text-emerald-800 flex items-start gap-2">
|
||||
<span className="font-semibold">Quellen & Lizenz:</span>
|
||||
<span>
|
||||
Inhalte gemaess <strong>EU-Verordnung 2024/1689 (KI-Verordnung / AI Act)</strong> —
|
||||
Lizenzregel R1 (EU_LAW, woertlich uebernehmbar).
|
||||
Risiko-Klassifizierungslogik basiert auf Anhang III der Verordnung.{' '}
|
||||
<a href="/sdk/licenses" className="underline">Quellenverzeichnis</a>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex items-center gap-1 bg-gray-100 p-1 rounded-lg w-fit">
|
||||
{TABS.map(tab => (
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
CATEGORY_OPTIONS,
|
||||
} from '../control-library/components/helpers'
|
||||
import { ControlDetail } from '../control-library/components/ControlDetail'
|
||||
import { SourceBadge } from '@/components/sdk/SourceBadge'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
@@ -310,6 +311,7 @@ export default function AtomicControlsPage() {
|
||||
<TargetAudienceBadge audience={ctrl.target_audience} />
|
||||
<GenerationStrategyBadge strategy={ctrl.generation_strategy} pipelineInfo={ctrl} />
|
||||
<ObligationTypeBadge type={ctrl.generation_metadata?.obligation_type as string} />
|
||||
<SourceBadge controlUuid={ctrl.id} compact />
|
||||
</div>
|
||||
<h3 className="text-sm font-medium text-gray-900 group-hover:text-violet-700">{ctrl.title}</h3>
|
||||
<p className="text-xs text-gray-500 mt-1 line-clamp-2">{ctrl.objective}</p>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
|
||||
import { LicenseModuleBanner } from '@/components/sdk/LicenseModuleBanner'
|
||||
import { useAuditChecklist } from './_hooks/useAuditChecklist'
|
||||
import { ChecklistItemCard } from './_components/ChecklistItemCard'
|
||||
import { LoadingSkeleton } from './_components/LoadingSkeleton'
|
||||
@@ -89,6 +90,12 @@ export default function AuditChecklistPage() {
|
||||
</div>
|
||||
</StepHeader>
|
||||
|
||||
<LicenseModuleBanner
|
||||
rule={3}
|
||||
sourceLabel="BreakPilot-Audit-Methodik"
|
||||
detail="Eigene Audit-Checklisten und -Workflows. Zitierte Rechtsquellen (DSGVO/ISO 27001/...) jeweils mit eigener Lizenzregel."
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div className="p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 flex items-center justify-between">
|
||||
<span>{error}</span>
|
||||
|
||||
@@ -232,14 +232,25 @@ export function StateBadge({ state }: { state: string }) {
|
||||
|
||||
export function LicenseRuleBadge({ rule }: { rule: number | null | undefined }) {
|
||||
if (!rule) return null
|
||||
const config: Record<number, { bg: string; label: string }> = {
|
||||
1: { bg: 'bg-green-100 text-green-700', label: 'Free Use' },
|
||||
2: { bg: 'bg-blue-100 text-blue-700', label: 'Zitation' },
|
||||
3: { bg: 'bg-amber-100 text-amber-700', label: 'Reformuliert' },
|
||||
// Corrected labels per Task #21 LICENSE_RULES.md mapping:
|
||||
// R1 = woertlich (Hoheitsrecht/Public Domain, no attribution required)
|
||||
// R2 = woertlich + Attribution-Pflicht (CC-BY, OWASP, OECD, ENISA)
|
||||
// R3 = nur Identifier zitieren (DIN/ANSI/IEC/DGUV/proprietary — pipeline drops full text)
|
||||
const config: Record<number, { bg: string; label: string; title: string }> = {
|
||||
1: { bg: 'bg-emerald-100 text-emerald-800', label: 'R1', title: 'Woertlich uebernehmbar (Hoheitsrecht/Public Domain)' },
|
||||
2: { bg: 'bg-amber-100 text-amber-800', label: 'R2', title: 'Woertlich mit Attribution (CC-BY/OWASP/OECD/ENISA)' },
|
||||
3: { bg: 'bg-slate-100 text-slate-700', label: 'R3', title: 'Nur Identifier-Verweis (DIN/ANSI/IEC/proprietaer)' },
|
||||
}
|
||||
const c = config[rule]
|
||||
if (!c) return null
|
||||
return <span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${c.bg}`}>{c.label}</span>
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${c.bg}`}
|
||||
title={c.title}
|
||||
>
|
||||
{c.label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function VerificationMethodBadge({ method }: { method: string | null }) {
|
||||
|
||||
@@ -99,6 +99,16 @@ export default function CRAProjectsPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 px-4 py-2 bg-emerald-50 border border-emerald-200 rounded-lg text-xs text-emerald-800 flex items-start gap-2">
|
||||
<span className="font-semibold">Quellen & Lizenz:</span>
|
||||
<span>
|
||||
Inhalte gemaess <strong>EU-Verordnung 2024/2847 (Cyber Resilience Act)</strong> —
|
||||
Lizenzregel R1 (EU_LAW, woertlich uebernehmbar). ENISA-Implementation-Guidance
|
||||
ergaenzend (R1 EU_PUBLIC).{' '}
|
||||
<a href="/sdk/licenses" className="underline">Quellenverzeichnis</a>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 bg-red-50 border border-red-200 rounded-lg p-4 text-sm text-red-700">
|
||||
{error}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Lifecycle-Phasen-Filter für den Document-Generator.
|
||||
*
|
||||
* Zeigt 5 Phasen-Tabs (Pre-Founding, Founding, Startup, KMU, Konzern) und
|
||||
* filtert die angezeigten Templates entsprechend ihres `lifecycle_stage`-Arrays.
|
||||
*
|
||||
* Phasen-Definitionen synchron zu lib/sdk/founding/template-categories.ts
|
||||
*/
|
||||
|
||||
import {
|
||||
LIFECYCLE_STAGE_LABELS,
|
||||
type LifecycleStage,
|
||||
TEMPLATE_CATEGORIES,
|
||||
} from '@/lib/sdk/founding/template-categories'
|
||||
|
||||
interface Props {
|
||||
activeStage: LifecycleStage | 'all'
|
||||
onChange: (stage: LifecycleStage | 'all') => void
|
||||
/** Template-Counts pro Stage (optional, sonst aus Code-Registry berechnet) */
|
||||
countsByStage?: Record<string, number>
|
||||
}
|
||||
|
||||
const STAGE_ORDER: (LifecycleStage | 'all')[] = [
|
||||
'all',
|
||||
'pre_founding',
|
||||
'founding',
|
||||
'startup',
|
||||
'kmu',
|
||||
'konzern',
|
||||
]
|
||||
|
||||
const STAGE_ICONS: Record<LifecycleStage | 'all', string> = {
|
||||
all: '📚',
|
||||
pre_founding: '🌱',
|
||||
founding: '⚖️',
|
||||
startup: '🚀',
|
||||
kmu: '🏢',
|
||||
konzern: '🏛️',
|
||||
}
|
||||
|
||||
const STAGE_HINTS: Record<LifecycleStage, string> = {
|
||||
pre_founding: 'Vor dem Notartermin — Term Sheet, IP-Sicherung, Wandeldarlehen',
|
||||
founding: 'Für den Notartermin — Satzung, Gesellschafterliste, HRB-Anmeldung',
|
||||
startup: '0–3 Jahre, <25 Mitarbeiter — Arbeitsverträge, AVV, Datenschutz',
|
||||
kmu: '3+ Jahre, 25–250 MA — ISMS, Whistleblower, vollständige TOM',
|
||||
konzern: '250+ MA — Konzern-Compliance, ISO 27001',
|
||||
}
|
||||
|
||||
export function LifecycleFilter({ activeStage, onChange, countsByStage }: Props) {
|
||||
const counts = countsByStage || computeCountsFromRegistry()
|
||||
|
||||
return (
|
||||
<div className="mb-6" data-testid="lifecycle-filter">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h3 className="text-sm font-semibold text-gray-700">Phase Deines Unternehmens</h3>
|
||||
<span className="text-xs text-gray-500">— filtert Dokumente nach Lifecycle</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{STAGE_ORDER.map(stage => {
|
||||
const isAll = stage === 'all'
|
||||
const count = isAll
|
||||
? Object.values(counts).reduce((s, c) => s + c, 0)
|
||||
: (counts[stage] || 0)
|
||||
const label = isAll ? 'Alle' : LIFECYCLE_STAGE_LABELS[stage as LifecycleStage].split(' (')[0]
|
||||
const isActive = activeStage === stage
|
||||
return (
|
||||
<button
|
||||
key={stage}
|
||||
type="button"
|
||||
data-testid={`stage-tab-${stage}`}
|
||||
onClick={() => onChange(stage)}
|
||||
className={`px-3 py-2 rounded-lg border text-sm font-medium transition ${
|
||||
isActive
|
||||
? 'bg-purple-600 text-white border-purple-600 shadow-sm'
|
||||
: 'bg-white text-gray-700 border-gray-200 hover:border-purple-300 hover:bg-purple-50'
|
||||
}`}
|
||||
>
|
||||
<span className="mr-1.5">{STAGE_ICONS[stage]}</span>
|
||||
{label}
|
||||
<span className={`ml-2 px-1.5 py-0.5 text-xs rounded-full ${
|
||||
isActive ? 'bg-white/20' : 'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{activeStage !== 'all' && (
|
||||
<p className="mt-2 text-sm text-gray-500" data-testid="stage-hint">
|
||||
{STAGE_HINTS[activeStage as LifecycleStage]}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function computeCountsFromRegistry(): Record<string, number> {
|
||||
const counts: Record<string, number> = {
|
||||
pre_founding: 0, founding: 0, startup: 0, kmu: 0, konzern: 0,
|
||||
}
|
||||
for (const cat of Object.values(TEMPLATE_CATEGORIES)) {
|
||||
for (const stage of cat.lifecycle_stage) {
|
||||
counts[stage] = (counts[stage] || 0) + 1
|
||||
}
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
export function filterTemplatesByStage<T extends { document_type?: string; type?: string }>(
|
||||
templates: T[],
|
||||
stage: LifecycleStage | 'all'
|
||||
): T[] {
|
||||
if (stage === 'all') return templates
|
||||
return templates.filter(t => {
|
||||
const docType = t.document_type || t.type
|
||||
if (!docType) return false
|
||||
const cat = TEMPLATE_CATEGORIES[docType]
|
||||
if (!cat) return stage === 'startup' // Fallback: unkategorisierte zeigen wir in Startup
|
||||
return cat.lifecycle_stage.includes(stage)
|
||||
})
|
||||
}
|
||||
@@ -39,7 +39,7 @@ export const CATEGORIES: { key: string; label: string; types: string[] | null }[
|
||||
]},
|
||||
|
||||
// Datenschutz-Informationen (alle DSI-Typen):
|
||||
{ key: 'dsi', label: 'Datenschutzinfos', types: ['privacy_policy', 'applicant_dsi', 'employee_dsi', 'social_media_dsi', 'video_conference_dsi', 'informationspflichten'] },
|
||||
{ key: 'dsi', label: 'Datenschutzinfos', types: ['privacy_policy', 'data_protection_policy', 'applicant_dsi', 'employee_dsi', 'social_media_dsi', 'video_conference_dsi', 'informationspflichten'] },
|
||||
|
||||
// Einwilligungen:
|
||||
{ key: 'consent', label: 'Einwilligungen', types: ['consent_texts', 'cookie_banner', 'verpflichtungserklaerung'] },
|
||||
|
||||
@@ -15,6 +15,8 @@ import { getGeneratorDefaults, getProfileLabel } from './scopeDefaults'
|
||||
import TemplateLibrary from './_components/TemplateLibrary'
|
||||
import GeneratorSection from './_components/GeneratorSection'
|
||||
import RecommendedDocuments from './_components/RecommendedDocuments'
|
||||
import { LifecycleFilter, filterTemplatesByStage } from './_components/LifecycleFilter'
|
||||
import type { LifecycleStage } from '@/lib/sdk/founding/template-categories'
|
||||
|
||||
function DocumentGeneratorPageInner() {
|
||||
const { state } = useSDK()
|
||||
@@ -24,6 +26,7 @@ function DocumentGeneratorPageInner() {
|
||||
const [allTemplates, setAllTemplates] = useState<LegalTemplateResult[]>([])
|
||||
const [isLoadingLibrary, setIsLoadingLibrary] = useState(true)
|
||||
const [activeCategory, setActiveCategory] = useState<string>('all')
|
||||
const [activeStage, setActiveStage] = useState<LifecycleStage | 'all'>('all')
|
||||
const [activeLanguage, setActiveLanguage] = useState<'all' | 'de' | 'en'>('all')
|
||||
const [librarySearch, setLibrarySearch] = useState('')
|
||||
const [expandedPreviewId, setExpandedPreviewId] = useState<string | null>(null)
|
||||
@@ -209,10 +212,15 @@ function DocumentGeneratorPageInner() {
|
||||
}
|
||||
}, [selectedDataPointsData])
|
||||
|
||||
// Filtered templates (computed)
|
||||
// Filtered templates (computed) — Lifecycle + Category + Language + Search
|
||||
const filteredTemplates = useMemo(() => {
|
||||
const category = CATEGORIES.find((c: { key: string }) => c.key === activeCategory)
|
||||
return allTemplates.filter((t) => {
|
||||
// 1. Lifecycle-Phase Filter via Code-Registry (mapped auf templateType)
|
||||
const stageFiltered = filterTemplatesByStage(
|
||||
allTemplates.map(t => ({ ...t, document_type: t.templateType || '' })),
|
||||
activeStage
|
||||
)
|
||||
return stageFiltered.filter((t) => {
|
||||
if (category && category.types !== null) {
|
||||
if (!category.types.includes(t.templateType || '')) return false
|
||||
}
|
||||
@@ -225,7 +233,22 @@ function DocumentGeneratorPageInner() {
|
||||
}
|
||||
return true
|
||||
})
|
||||
}, [allTemplates, activeCategory, activeLanguage, librarySearch])
|
||||
}, [allTemplates, activeCategory, activeStage, activeLanguage, librarySearch])
|
||||
|
||||
// Counts by stage for filter UI
|
||||
const countsByStage = useMemo(() => {
|
||||
const counts: Record<string, number> = { pre_founding: 0, founding: 0, startup: 0, kmu: 0, konzern: 0 }
|
||||
const stages: LifecycleStage[] = ['pre_founding', 'founding', 'startup', 'kmu', 'konzern']
|
||||
for (const t of allTemplates) {
|
||||
const docType = t.templateType || ''
|
||||
for (const s of stages) {
|
||||
if (filterTemplatesByStage([{ document_type: docType }], s).length) {
|
||||
counts[s]++
|
||||
}
|
||||
}
|
||||
}
|
||||
return counts
|
||||
}, [allTemplates])
|
||||
|
||||
const handleUseTemplate = useCallback((t: LegalTemplateResult) => {
|
||||
setActiveTemplate(t)
|
||||
@@ -274,6 +297,16 @@ function DocumentGeneratorPageInner() {
|
||||
tips={stepInfo.tips}
|
||||
/>
|
||||
|
||||
<div className="px-4 py-2 bg-slate-50 border border-slate-200 rounded-lg text-xs text-slate-700 flex items-start gap-2">
|
||||
<span className="font-semibold">Quellen & Lizenz:</span>
|
||||
<span>
|
||||
Die 91 Standard-Vorlagen sind <strong>BreakPilot-Eigenwerke</strong> (Lizenzregel R3 — Identifier-Verweis,
|
||||
eigene Lizenz). Vorlagen mit gesetzlicher Grundlage (z.B. VVT nach Art. 30 DSGVO,
|
||||
Loeschkonzept nach Art. 17 DSGVO) zitieren die jeweilige Rechtsquelle als R1.{' '}
|
||||
<a href="/sdk/licenses" className="underline">Quellenverzeichnis</a>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Status bar */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5">
|
||||
@@ -292,6 +325,13 @@ function DocumentGeneratorPageInner() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lifecycle-Phase Filter */}
|
||||
<LifecycleFilter
|
||||
activeStage={activeStage}
|
||||
onChange={setActiveStage}
|
||||
countsByStage={countsByStage}
|
||||
/>
|
||||
|
||||
{/* Recommended documents based on scope profile */}
|
||||
<RecommendedDocuments
|
||||
allTemplates={allTemplates}
|
||||
|
||||
@@ -225,6 +225,51 @@ const TEMPLATE_RULES: TemplateRule[] = [
|
||||
condition: () => 'required', // Immer Pflicht bei Websites
|
||||
},
|
||||
|
||||
// ── DSE & Datenschutz-Kerndokumente (P38) ──────────────────────────────
|
||||
{
|
||||
templateType: 'privacy_policy',
|
||||
label: 'Datenschutzerklaerung (Website)',
|
||||
condition: () => 'required', // Art. 13 DSGVO — bei jeder Website Pflicht
|
||||
},
|
||||
{
|
||||
templateType: 'data_protection_policy',
|
||||
label: 'Datenschutzrichtlinie (intern)',
|
||||
condition: (_answers, level) => level >= 'L2' ? 'required' : 'recommended',
|
||||
},
|
||||
{
|
||||
templateType: 'dsfa',
|
||||
label: 'DSFA-Vorlage',
|
||||
condition: (answers) => {
|
||||
const dsfa = answers.get('proc_dsfa_required') || answers.get('comp_dsfa_processes')
|
||||
if (dsfa === 'yes' || dsfa === 'required') return 'required'
|
||||
return 'optional'
|
||||
},
|
||||
},
|
||||
{
|
||||
templateType: 'dpa',
|
||||
label: 'Auftragsverarbeitungsvertrag (AVV)',
|
||||
condition: (answers) => {
|
||||
const vendors = answers.get('comp_has_processors') || answers.get('comp_vendor_management')
|
||||
if (vendors && vendors !== 'no') return 'required'
|
||||
return 'recommended'
|
||||
},
|
||||
},
|
||||
{
|
||||
templateType: 'vvt_register',
|
||||
label: 'Verzeichnis von Verarbeitungstaetigkeiten (VVT)',
|
||||
condition: (_answers, level) => level >= 'L2' ? 'required' : 'recommended',
|
||||
},
|
||||
{
|
||||
templateType: 'tom_documentation',
|
||||
label: 'TOM-Dokumentation',
|
||||
condition: (_answers, level) => level >= 'L2' ? 'required' : 'recommended',
|
||||
},
|
||||
{
|
||||
templateType: 'loeschkonzept',
|
||||
label: 'Loeschkonzept',
|
||||
condition: (_answers, level) => level >= 'L2' ? 'required' : 'recommended',
|
||||
},
|
||||
|
||||
// ── Drittlandtransfer (SCC + TIA) ───────────────────────────────────────
|
||||
// SCC+TIA nur erforderlich wenn Drittlandtransfer OHNE Angemessenheitsbeschluss/DPF
|
||||
{
|
||||
|
||||
@@ -132,6 +132,16 @@ export default function DSFAPage() {
|
||||
)}
|
||||
</StepHeader>
|
||||
|
||||
<div className="px-4 py-2 bg-emerald-50 border border-emerald-200 rounded-lg text-xs text-emerald-800 flex items-start gap-2">
|
||||
<span className="font-semibold">Quellen & Lizenz:</span>
|
||||
<span>
|
||||
Inhalte gemaess <strong>DSGVO Art. 35</strong> (EU 2016/679) — Lizenzregel R1
|
||||
(Hoheitsrecht/EU_LAW, woertlich uebernehmbar). Vorlagen-Texte aus
|
||||
Aufsichtsbehoerden ebenfalls R1.{' '}
|
||||
<a href="/sdk/licenses" className="underline">Quellenverzeichnis</a>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* DSFA Requirement Check */}
|
||||
{dsfaCheck.required && dsfas.length === 0 && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-xl p-5">
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import type { FoundingWizardState } from '@/lib/sdk/founding/types'
|
||||
|
||||
interface Props {
|
||||
state: FoundingWizardState
|
||||
update: <K extends keyof FoundingWizardState>(k: K, v: FoundingWizardState[K]) => void
|
||||
}
|
||||
|
||||
export function StepBasics({ state, update }: Props) {
|
||||
const b = state.basics
|
||||
const [prefillStatus, setPrefillStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle')
|
||||
|
||||
async function prefillFromCompanyProfile() {
|
||||
setPrefillStatus('loading')
|
||||
try {
|
||||
const res = await fetch('/api/sdk/v1/company-profile', { cache: 'no-store' })
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const payload = await res.json()
|
||||
const p = payload?.profile ?? payload
|
||||
if (!p || typeof p !== 'object') throw new Error('leeres Profil')
|
||||
const industries = Array.isArray(p.industry) ? p.industry.filter(Boolean) : []
|
||||
const industry = industries.length > 0
|
||||
? industries.join(', ')
|
||||
: (p.industryOther || b.industry)
|
||||
const address = [p.headquartersStreet, [p.headquartersZip, p.headquartersCity].filter(Boolean).join(' ')]
|
||||
.filter(Boolean).join(', ') || b.company_address
|
||||
const seat = p.headquartersCity || b.company_seat
|
||||
// Purpose ableiten aus offerings/businessModel — Fallback wenn nichts da
|
||||
const purposeBits: string[] = []
|
||||
if (p.businessModel) purposeBits.push(`Geschäftsmodell: ${p.businessModel}`)
|
||||
if (Array.isArray(p.offerings) && p.offerings.length > 0)
|
||||
purposeBits.push(`Leistungen: ${p.offerings.join(', ')}`)
|
||||
const purpose = purposeBits.length > 0
|
||||
? purposeBits.join('; ')
|
||||
: b.company_purpose_description
|
||||
update('basics', {
|
||||
...b,
|
||||
company_name: p.companyName || b.company_name,
|
||||
legal_form: (p.legalForm === 'UG' ? 'UG' : (p.legalForm === 'GmbH' ? 'GmbH' : b.legal_form)),
|
||||
company_seat: seat,
|
||||
company_address: address,
|
||||
industry,
|
||||
company_purpose_description: b.company_purpose_description.trim() === '' ? purpose : b.company_purpose_description,
|
||||
})
|
||||
setPrefillStatus('success')
|
||||
} catch (err) {
|
||||
console.error('[founding-wizard] prefill failed', err)
|
||||
setPrefillStatus('error')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-gray-600">
|
||||
Stammdaten der Gesellschaft. Pflicht für Satzung, HRB-Anmeldung und SHA.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={prefillFromCompanyProfile}
|
||||
disabled={prefillStatus === 'loading'}
|
||||
className="px-3 py-1.5 text-sm rounded-lg border border-blue-300 bg-blue-50 hover:bg-blue-100 disabled:opacity-50"
|
||||
>
|
||||
{prefillStatus === 'loading' ? 'Lade…' : 'Aus Unternehmensprofil vorbefüllen'}
|
||||
</button>
|
||||
</div>
|
||||
{prefillStatus === 'success' && (
|
||||
<div className="text-xs text-green-700 bg-green-50 border border-green-200 rounded px-2 py-1">
|
||||
Daten aus Unternehmensprofil übernommen. Bitte prüfen und ergänzen.
|
||||
</div>
|
||||
)}
|
||||
{prefillStatus === 'error' && (
|
||||
<div className="text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded px-2 py-1">
|
||||
Konnte Unternehmensprofil nicht laden — bitte Felder manuell ausfüllen.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Firmenname</label>
|
||||
<input
|
||||
data-testid="company-name"
|
||||
type="text"
|
||||
value={b.company_name}
|
||||
onChange={e => update('basics', { ...b, company_name: e.target.value })}
|
||||
placeholder="Breakpilot GmbH"
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Rechtsform</label>
|
||||
<select
|
||||
data-testid="legal-form"
|
||||
value={b.legal_form}
|
||||
onChange={e => update('basics', { ...b, legal_form: e.target.value as 'GmbH' | 'UG' })}
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
>
|
||||
<option value="GmbH">GmbH</option>
|
||||
<option value="UG">UG (haftungsbeschränkt)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Sitz (Stadt)</label>
|
||||
<input
|
||||
data-testid="company-seat"
|
||||
type="text"
|
||||
value={b.company_seat}
|
||||
onChange={e => update('basics', { ...b, company_seat: e.target.value })}
|
||||
placeholder="z.B. Stuttgart"
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Adresse</label>
|
||||
<input
|
||||
data-testid="company-address"
|
||||
type="text"
|
||||
value={b.company_address}
|
||||
onChange={e => update('basics', { ...b, company_address: e.target.value })}
|
||||
placeholder="Straße, PLZ Ort"
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Branche</label>
|
||||
<input
|
||||
data-testid="industry"
|
||||
type="text"
|
||||
value={b.industry}
|
||||
onChange={e => update('basics', { ...b, industry: e.target.value })}
|
||||
placeholder="z.B. SaaS, Beratung, Handwerk"
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Geschäftsjahr</label>
|
||||
<input
|
||||
data-testid="business-year"
|
||||
type="text"
|
||||
value={b.business_year}
|
||||
onChange={e => update('basics', { ...b, business_year: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Registergericht
|
||||
</label>
|
||||
<input
|
||||
data-testid="register-court"
|
||||
type="text"
|
||||
value={b.register_court || ''}
|
||||
onChange={e => update('basics', { ...b, register_court: e.target.value })}
|
||||
placeholder="z.B. Amtsgericht Stuttgart"
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Zuständiges Amtsgericht für HRB-Eintragung
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
HRB-Nummer <span className="text-gray-400">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
data-testid="hrb-number"
|
||||
type="text"
|
||||
value={b.hrb_number || ''}
|
||||
onChange={e => update('basics', { ...b, hrb_number: e.target.value })}
|
||||
placeholder="z.B. HRB 12345 (leer falls noch nicht eingetragen)"
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Unternehmensgegenstand (Volltext für § 2 Satzung)
|
||||
</label>
|
||||
<textarea
|
||||
data-testid="company-purpose"
|
||||
value={b.company_purpose_description}
|
||||
onChange={e => update('basics', { ...b, company_purpose_description: e.target.value })}
|
||||
rows={4}
|
||||
placeholder="z.B. die Entwicklung, Bereitstellung, der Betrieb und der Vertrieb von Softwarelösungen, Plattformen und IT-Dienstleistungen im Bereich der Künstlichen Intelligenz"
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Detaillierte Tätigkeitsbereiche (eine Zeile pro Bullet)
|
||||
</label>
|
||||
<textarea
|
||||
data-testid="company-purpose-bullets"
|
||||
value={b.company_purpose_bullets.join('\n')}
|
||||
onChange={e => update('basics', { ...b, company_purpose_bullets: e.target.value.split('\n').filter(Boolean) })}
|
||||
rows={5}
|
||||
placeholder={'a) Entwicklung von Software\nb) Beratung im Bereich...\nc) ...'}
|
||||
className="w-full px-3 py-2 border rounded-lg font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="research_focus"
|
||||
data-testid="research-focus"
|
||||
checked={b.has_research_focus}
|
||||
onChange={e => update('basics', { ...b, has_research_focus: e.target.checked })}
|
||||
/>
|
||||
<label htmlFor="research_focus" className="text-sm text-gray-700">
|
||||
Forschungsfokus (aktiviert F&E-Klauseln in SHA und GO-GF)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import type { FoundingWizardState, GeneratedDocument } from '@/lib/sdk/founding/types'
|
||||
import { NOTARY_BUNDLE_DOCUMENTS } from '@/lib/sdk/founding/template-categories'
|
||||
|
||||
interface Props {
|
||||
state: FoundingWizardState
|
||||
update: <K extends keyof FoundingWizardState>(k: K, v: FoundingWizardState[K]) => void
|
||||
generating: boolean
|
||||
error: string | null
|
||||
onGenerate: () => Promise<GeneratedDocument[]>
|
||||
}
|
||||
|
||||
const DOC_LABELS: Record<string, string> = {
|
||||
articles_of_association: 'Satzung',
|
||||
gesellschafterliste: 'Gesellschafterliste (§ 40 GmbHG)',
|
||||
gf_bestellungsbeschluss: 'Gesellschafterbeschluss zur GF-Bestellung',
|
||||
hrb_anmeldung: 'Handelsregister-Anmeldung',
|
||||
sha: 'Shareholders\' Agreement (SHA)',
|
||||
geschaeftsordnung_gf: 'Geschäftsordnung Geschäftsführung (GO-GF)',
|
||||
managing_director_employment_contract: 'GF-Dienstvertrag (pro GF)',
|
||||
ip_assignment_agreement: 'IP-Assignment (pro Gründer)',
|
||||
term_sheet: 'Term Sheet',
|
||||
convertible_loan_agreement: 'Wandeldarlehensvertrag',
|
||||
subscription_agreement: 'Beteiligungsvertrag',
|
||||
esop_plan: 'ESOP/VSOP-Plan',
|
||||
cap_table: 'Cap Table',
|
||||
}
|
||||
|
||||
export function StepGenerate({ state, update, generating, error, onGenerate }: Props) {
|
||||
const toggleDoc = (docType: string) => {
|
||||
const next = state.selected_documents.includes(docType)
|
||||
? state.selected_documents.filter(d => d !== docType)
|
||||
: [...state.selected_documents, docType]
|
||||
update('selected_documents', next)
|
||||
}
|
||||
|
||||
const selectNotaryBundle = () => {
|
||||
update('selected_documents', [...NOTARY_BUNDLE_DOCUMENTS])
|
||||
}
|
||||
|
||||
const summary = useMemo(() => ({
|
||||
name: state.basics.company_name,
|
||||
seat: state.basics.company_seat,
|
||||
stammkapital: state.capital.stammkapital_eur,
|
||||
num_gesellschafter: state.gesellschafter.length,
|
||||
num_gf: state.gesellschafter.filter(g => g.is_geschaeftsfuehrer).length,
|
||||
}), [state])
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4">
|
||||
<h3 className="font-semibold text-purple-900 mb-2">Zusammenfassung</h3>
|
||||
<dl className="grid grid-cols-2 gap-2 text-sm" data-testid="generate-summary">
|
||||
<dt className="text-gray-600">Firma:</dt><dd>{summary.name} ({state.basics.legal_form})</dd>
|
||||
<dt className="text-gray-600">Sitz:</dt><dd>{summary.seat}</dd>
|
||||
<dt className="text-gray-600">Stammkapital:</dt><dd>{summary.stammkapital.toLocaleString('de-DE')} €</dd>
|
||||
<dt className="text-gray-600">Gesellschafter:</dt><dd>{summary.num_gesellschafter}</dd>
|
||||
<dt className="text-gray-600">Geschäftsführer:</dt><dd>{summary.num_gf}</dd>
|
||||
<dt className="text-gray-600">Notar:</dt><dd>{state.notar.notary_name} ({state.notar.notary_place})</dd>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<h3 className="font-semibold">Zu generierende Dokumente</h3>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="select-notary-bundle"
|
||||
onClick={selectNotaryBundle}
|
||||
className="text-sm text-purple-600 hover:underline"
|
||||
>
|
||||
➜ Notartermin-Bundle auswählen
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
{Object.entries(DOC_LABELS).map(([docType, label]) => (
|
||||
<label key={docType} className="flex items-start gap-3 p-2 hover:bg-gray-50 rounded">
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid={`doc-${docType}`}
|
||||
checked={state.selected_documents.includes(docType)}
|
||||
onChange={() => toggleDoc(docType)}
|
||||
className="mt-1"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium">{label}</div>
|
||||
<div className="text-xs text-gray-500">{docType}</div>
|
||||
</div>
|
||||
{NOTARY_BUNDLE_DOCUMENTS.includes(docType) && (
|
||||
<span className="text-xs bg-purple-100 text-purple-700 px-2 py-0.5 rounded">Notartermin</span>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center pt-4 border-t">
|
||||
<p className="text-sm text-gray-500">
|
||||
{state.selected_documents.length} Dokument(e) ausgewählt
|
||||
</p>
|
||||
<button
|
||||
data-testid="generate-docs"
|
||||
onClick={onGenerate}
|
||||
disabled={generating || state.selected_documents.length === 0}
|
||||
className="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 font-medium"
|
||||
>
|
||||
{generating ? 'Generiere...' : 'Dokumente als Word generieren'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-3 text-sm text-red-900" data-testid="generate-error">
|
||||
Fehler: {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.generated_documents && state.generated_documents.length > 0 && (
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4" data-testid="generated-docs">
|
||||
<h3 className="font-semibold text-green-900 mb-3">
|
||||
✓ {state.generated_documents.length} Dokument(e) generiert
|
||||
</h3>
|
||||
<ul className="space-y-2">
|
||||
{state.generated_documents.map((doc, idx) => (
|
||||
<li key={idx} className="flex justify-between items-center bg-white rounded px-3 py-2 border border-green-200">
|
||||
<div>
|
||||
<div className="text-sm font-medium">{doc.title}</div>
|
||||
<div className="text-xs text-gray-500">{(doc.size_bytes / 1024).toFixed(1)} KB</div>
|
||||
</div>
|
||||
<a
|
||||
href={doc.download_url}
|
||||
download
|
||||
data-testid={`download-${doc.document_type}`}
|
||||
className="px-3 py-1.5 bg-green-600 text-white rounded text-sm hover:bg-green-700"
|
||||
>
|
||||
Word herunterladen
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import type { FoundingWizardState, Gesellschafter } from '@/lib/sdk/founding/types'
|
||||
|
||||
interface Props {
|
||||
state: FoundingWizardState
|
||||
addGesellschafter: (g: Omit<Gesellschafter, 'id' | 'anteil_nr'>) => void
|
||||
updateGesellschafter: (id: string, p: Partial<Gesellschafter>) => void
|
||||
removeGesellschafter: (id: string) => void
|
||||
}
|
||||
|
||||
export function StepGesellschafter({ state, addGesellschafter, updateGesellschafter, removeGesellschafter }: Props) {
|
||||
const [form, setForm] = useState({
|
||||
name: '', geburtsdatum: '', adresse: '', email: '',
|
||||
nennbetrag_eur: 12500, is_geschaeftsfuehrer: true, internal_role: '',
|
||||
has_academic_background: false, ip_areas: '',
|
||||
})
|
||||
|
||||
const totalNennbetrag = state.gesellschafter.reduce((s, g) => s + g.nennbetrag_eur, 0)
|
||||
const target = state.capital.stammkapital_eur
|
||||
|
||||
const handleAdd = () => {
|
||||
if (!form.name.trim()) return
|
||||
const ip_areas = form.ip_areas
|
||||
.split('\n').map(s => s.trim()).filter(Boolean)
|
||||
addGesellschafter({
|
||||
rolle: 'founder',
|
||||
name: form.name,
|
||||
geburtsdatum: form.geburtsdatum || undefined,
|
||||
adresse: form.adresse,
|
||||
email: form.email || undefined,
|
||||
nennbetrag_eur: form.nennbetrag_eur,
|
||||
is_geschaeftsfuehrer: form.is_geschaeftsfuehrer,
|
||||
internal_role: form.internal_role || undefined,
|
||||
has_academic_background: form.has_academic_background,
|
||||
ip_areas: ip_areas.length > 0 ? ip_areas : undefined,
|
||||
})
|
||||
setForm({ name: '', geburtsdatum: '', adresse: '', email: '', nennbetrag_eur: 12500,
|
||||
is_geschaeftsfuehrer: true, internal_role: '', has_academic_background: false, ip_areas: '' })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-gray-50 p-4 rounded-lg">
|
||||
<h3 className="font-semibold mb-3">Neuen Gesellschafter hinzufügen</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<input
|
||||
data-testid="gs-name"
|
||||
placeholder="Name"
|
||||
value={form.name}
|
||||
onChange={e => setForm({ ...form, name: e.target.value })}
|
||||
className="px-3 py-2 border rounded"
|
||||
/>
|
||||
<input
|
||||
data-testid="gs-birthdate"
|
||||
type="date"
|
||||
placeholder="Geburtsdatum"
|
||||
value={form.geburtsdatum}
|
||||
onChange={e => setForm({ ...form, geburtsdatum: e.target.value })}
|
||||
className="px-3 py-2 border rounded"
|
||||
/>
|
||||
<input
|
||||
data-testid="gs-address"
|
||||
placeholder="Adresse (Straße, PLZ Ort)"
|
||||
value={form.adresse}
|
||||
onChange={e => setForm({ ...form, adresse: e.target.value })}
|
||||
className="px-3 py-2 border rounded col-span-2"
|
||||
/>
|
||||
<input
|
||||
data-testid="gs-email"
|
||||
type="email"
|
||||
placeholder="E-Mail (optional)"
|
||||
value={form.email}
|
||||
onChange={e => setForm({ ...form, email: e.target.value })}
|
||||
className="px-3 py-2 border rounded"
|
||||
/>
|
||||
<input
|
||||
data-testid="gs-nennbetrag"
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
placeholder="Nennbetrag in EUR"
|
||||
value={form.nennbetrag_eur}
|
||||
onChange={e => setForm({ ...form, nennbetrag_eur: parseInt(e.target.value) || 0 })}
|
||||
className="px-3 py-2 border rounded"
|
||||
/>
|
||||
<select
|
||||
data-testid="gs-role"
|
||||
value={form.internal_role}
|
||||
onChange={e => setForm({ ...form, internal_role: e.target.value })}
|
||||
className="px-3 py-2 border rounded bg-white"
|
||||
>
|
||||
<option value="">Rolle wählen…</option>
|
||||
<option value="CEO">CEO (Chief Executive Officer)</option>
|
||||
<option value="CTO">CTO (Chief Technical Officer)</option>
|
||||
<option value="CFO">CFO (Chief Financial Officer)</option>
|
||||
<option value="COO">COO (Chief Operating Officer)</option>
|
||||
<option value="CPO">CPO (Chief Product Officer)</option>
|
||||
<option value="Geschäftsführer">Geschäftsführer (ohne Spezialisierung)</option>
|
||||
<option value="Gesellschafter">Gesellschafter (kein GF)</option>
|
||||
<option value="Sonstige">Sonstige</option>
|
||||
</select>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="gs-is-gf"
|
||||
checked={form.is_geschaeftsfuehrer}
|
||||
onChange={e => setForm({ ...form, is_geschaeftsfuehrer: e.target.checked })}
|
||||
/>
|
||||
<label className="text-sm">Geschäftsführer/in</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="gs-academic"
|
||||
checked={form.has_academic_background}
|
||||
onChange={e => setForm({ ...form, has_academic_background: e.target.checked })}
|
||||
/>
|
||||
<label className="text-sm">Akademischer Hintergrund</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
IP-Bereiche, die diese Person in die Gesellschaft einbringt
|
||||
<span className="text-gray-400"> (optional, eine Zeile pro Bereich)</span>
|
||||
</label>
|
||||
<textarea
|
||||
data-testid="gs-ip-areas"
|
||||
value={form.ip_areas}
|
||||
onChange={e => setForm({ ...form, ip_areas: e.target.value })}
|
||||
rows={3}
|
||||
placeholder={'z.B.\nCompliance-Engine (Quellcode + Architektur)\nRAG-Pipeline\nKonfigurationsdaten'}
|
||||
className="w-full px-3 py-2 border rounded font-mono text-xs"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Bei mehreren Gründern wird pro Person ein eigener IP-Assignment-Vertrag generiert.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
data-testid="add-gesellschafter"
|
||||
onClick={handleAdd}
|
||||
disabled={!form.name.trim() || form.nennbetrag_eur < 1}
|
||||
className="mt-3 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50"
|
||||
>
|
||||
Gesellschafter hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold mb-3">Gesellschafter ({state.gesellschafter.length})</h3>
|
||||
{state.gesellschafter.length === 0 ? (
|
||||
<p className="text-gray-500 text-sm">Noch keine Gesellschafter angelegt.</p>
|
||||
) : (
|
||||
<table className="w-full text-sm" data-testid="gs-table">
|
||||
<thead className="bg-gray-100">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left">Nr.</th>
|
||||
<th className="px-3 py-2 text-left">Name</th>
|
||||
<th className="px-3 py-2 text-left">Geburtsdatum</th>
|
||||
<th className="px-3 py-2 text-right">Nennbetrag</th>
|
||||
<th className="px-3 py-2 text-right">Anteil %</th>
|
||||
<th className="px-3 py-2">GF?</th>
|
||||
<th className="px-3 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{state.gesellschafter.map(g => (
|
||||
<tr key={g.id} className="border-t" data-testid={`gs-row-${g.anteil_nr}`}>
|
||||
<td className="px-3 py-2">{g.anteil_nr}</td>
|
||||
<td className="px-3 py-2 font-medium">
|
||||
{g.name}{g.internal_role ? ` (${g.internal_role})` : ''}
|
||||
{g.ip_areas && g.ip_areas.length > 0 && (
|
||||
<div className="text-xs text-gray-500 mt-0.5">
|
||||
IP: {g.ip_areas.join(', ')}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2">{g.geburtsdatum || '—'}</td>
|
||||
<td className="px-3 py-2 text-right">{g.nennbetrag_eur.toLocaleString('de-DE')} €</td>
|
||||
<td className="px-3 py-2 text-right">{((g.nennbetrag_eur / Math.max(target, 1)) * 100).toFixed(2)}%</td>
|
||||
<td className="px-3 py-2 text-center">{g.is_geschaeftsfuehrer ? '✓' : '—'}</td>
|
||||
<td className="px-3 py-2">
|
||||
<button
|
||||
onClick={() => removeGesellschafter(g.id)}
|
||||
className="text-red-600 hover:underline text-xs"
|
||||
>
|
||||
Entfernen
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
<tr className="border-t-2 font-semibold bg-gray-50">
|
||||
<td colSpan={3} className="px-3 py-2">Summe</td>
|
||||
<td className="px-3 py-2 text-right" data-testid="gs-total">
|
||||
{totalNennbetrag.toLocaleString('de-DE')} €
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
{totalNennbetrag === target ? '100%' : `≠ ${target.toLocaleString('de-DE')} €`}
|
||||
</td>
|
||||
<td colSpan={2}></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{totalNennbetrag !== target && state.gesellschafter.length > 0 && (
|
||||
<p className="mt-2 text-sm text-orange-600">
|
||||
⚠ Die Summe der Nennbeträge ({totalNennbetrag.toLocaleString('de-DE')} €)
|
||||
entspricht nicht dem Stammkapital ({target.toLocaleString('de-DE')} €).
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Kombinierte einfache Steps: Geschäftsführer (3), Kapital (4), Notar (5), SHA (6).
|
||||
* Jeder Sub-Step ist eine simple Form.
|
||||
*/
|
||||
|
||||
import type { FoundingWizardState, GFContract } from '@/lib/sdk/founding/types'
|
||||
|
||||
interface PropsBase {
|
||||
state: FoundingWizardState
|
||||
update: <K extends keyof FoundingWizardState>(k: K, v: FoundingWizardState[K]) => void
|
||||
}
|
||||
|
||||
export function StepGFAssignment({ state, update }: PropsBase) {
|
||||
const founders = state.gesellschafter
|
||||
const toggleGF = (id: string, val: boolean) => {
|
||||
update('gesellschafter', state.gesellschafter.map(g => g.id === id ? { ...g, is_geschaeftsfuehrer: val } : g))
|
||||
}
|
||||
const setRole = (id: string, role: string) => {
|
||||
update('gesellschafter', state.gesellschafter.map(g => g.id === id ? { ...g, internal_role: role } : g))
|
||||
}
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-600">
|
||||
Wähle, welche Gesellschafter zu Geschäftsführern bestellt werden sollen. Standardmäßig sind alle Gründer auch GF.
|
||||
</p>
|
||||
{founders.length === 0 ? (
|
||||
<p className="text-orange-600">Bitte zuerst Gesellschafter in Step 2 anlegen.</p>
|
||||
) : (
|
||||
<table className="w-full text-sm" data-testid="gf-assignment-table">
|
||||
<thead className="bg-gray-100">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left">Gesellschafter</th>
|
||||
<th className="px-3 py-2 text-left">Interne Rolle (CEO, CTO, ...)</th>
|
||||
<th className="px-3 py-2">GF?</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{founders.map(g => (
|
||||
<tr key={g.id} className="border-t">
|
||||
<td className="px-3 py-2 font-medium">{g.name}</td>
|
||||
<td className="px-3 py-2">
|
||||
<input
|
||||
value={g.internal_role || ''}
|
||||
onChange={e => setRole(g.id, e.target.value)}
|
||||
className="px-2 py-1 border rounded w-48"
|
||||
placeholder="CEO, CTO, COO..."
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid={`gf-toggle-${g.anteil_nr}`}
|
||||
checked={g.is_geschaeftsfuehrer}
|
||||
onChange={e => toggleGF(g.id, e.target.checked)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function StepCapital({ state, update }: PropsBase) {
|
||||
const c = state.capital
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Stammkapital (EUR)</label>
|
||||
<input
|
||||
data-testid="stammkapital"
|
||||
type="number" min={1} step={1}
|
||||
value={c.stammkapital_eur}
|
||||
onChange={e => update('capital', { ...c, stammkapital_eur: parseInt(e.target.value) || 0 })}
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">GmbH: mind. 25.000 €, UG: ab 1 €</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Einlage-Art</label>
|
||||
<select
|
||||
data-testid="einlage-method"
|
||||
value={c.einlage_method}
|
||||
onChange={e => update('capital', { ...c, einlage_method: e.target.value as typeof c.einlage_method })}
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
>
|
||||
<option value="Geld">Bargründung</option>
|
||||
<option value="Sacheinlage">Sachgründung</option>
|
||||
<option value="Geld und Sacheinlage">Misch-Gründung</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Sofortige Einzahlung (%)
|
||||
</label>
|
||||
<input
|
||||
data-testid="einlage-quote"
|
||||
type="number" min={25} max={100}
|
||||
value={c.einlage_quote_initial_pct}
|
||||
onChange={e => update('capital', { ...c, einlage_quote_initial_pct: parseInt(e.target.value) || 50 })}
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">Mind. 25% gem. § 7 Abs. 2 GmbHG, Standard 50%</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-7">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="has_sach"
|
||||
data-testid="has-sacheinlage"
|
||||
checked={c.has_sacheinlage}
|
||||
onChange={e => update('capital', { ...c, has_sacheinlage: e.target.checked })}
|
||||
/>
|
||||
<label htmlFor="has_sach" className="text-sm">Sacheinlage-Klausel aktivieren</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function StepNotar({ state, update }: PropsBase) {
|
||||
const n = state.notar
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Name des Notars</label>
|
||||
<input
|
||||
data-testid="notary-name"
|
||||
value={n.notary_name}
|
||||
onChange={e => update('notar', { ...n, notary_name: e.target.value })}
|
||||
placeholder="z.B. Dr. Müller"
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Notarsitz</label>
|
||||
<input
|
||||
data-testid="notary-place"
|
||||
value={n.notary_place}
|
||||
onChange={e => update('notar', { ...n, notary_place: e.target.value })}
|
||||
placeholder="z.B. Stuttgart"
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Adresse</label>
|
||||
<input
|
||||
data-testid="notary-address"
|
||||
value={n.notary_address || ''}
|
||||
onChange={e => update('notar', { ...n, notary_address: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Geplanter Notartermin</label>
|
||||
<input
|
||||
data-testid="notarial-date"
|
||||
type="date"
|
||||
value={n.notarial_date || ''}
|
||||
onChange={e => update('notar', { ...n, notarial_date: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3 text-sm text-blue-900">
|
||||
<strong>Hinweis:</strong> Die URNr. wird vom Notar beim Beurkundungstermin vergeben. Du kannst die generierte
|
||||
HRB-Anmeldung als Vorbereitungsdokument zum Termin mitnehmen.
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function StepSHAConfig({ state, update }: PropsBase) {
|
||||
const s = state.sha
|
||||
const updateField = <K extends keyof typeof s>(k: K, v: typeof s[K]) => update('sha', { ...s, [k]: v })
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="has-sha"
|
||||
checked={s.has_sha}
|
||||
onChange={e => updateField('has_sha', e.target.checked)}
|
||||
/>
|
||||
<label className="text-sm font-medium">SHA (Shareholders' Agreement) ist Teil des Notartermin-Pakets</label>
|
||||
</div>
|
||||
|
||||
{s.has_sha && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm text-gray-700 mb-1">Vesting-Dauer (Monate)</label>
|
||||
<input data-testid="vesting-months" type="number" value={s.vesting_months}
|
||||
onChange={e => updateField('vesting_months', parseInt(e.target.value) || 48)}
|
||||
className="w-full px-3 py-2 border rounded-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-700 mb-1">Cliff (Monate)</label>
|
||||
<input data-testid="cliff-months" type="number" value={s.cliff_months}
|
||||
onChange={e => updateField('cliff_months', parseInt(e.target.value) || 12)}
|
||||
className="w-full px-3 py-2 border rounded-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-700 mb-1">Drag-Along Schwelle (%)</label>
|
||||
<input data-testid="drag-along-pct" type="number" value={s.drag_along_threshold_pct}
|
||||
onChange={e => updateField('drag_along_threshold_pct', parseInt(e.target.value) || 75)}
|
||||
className="w-full px-3 py-2 border rounded-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-700 mb-1">Reserved-Matters Mehrheit (%)</label>
|
||||
<input data-testid="reserved-matters-pct" type="number" value={s.reserved_matters_majority_pct}
|
||||
onChange={e => updateField('reserved_matters_majority_pct', parseInt(e.target.value) || 75)}
|
||||
className="w-full px-3 py-2 border rounded-lg" />
|
||||
</div>
|
||||
<div className="col-span-2 grid grid-cols-3 gap-3 mt-2">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" data-testid="has-beirat" checked={s.has_beirat}
|
||||
onChange={e => updateField('has_beirat', e.target.checked)} />
|
||||
Beirat einrichten
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" data-testid="has-texas" checked={s.has_texas_shootout}
|
||||
onChange={e => updateField('has_texas_shootout', e.target.checked)} />
|
||||
Texas Shoot-Out (Deadlock)
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" data-testid="has-ceo" checked={s.has_ceo_designation}
|
||||
onChange={e => updateField('has_ceo_designation', e.target.checked)} />
|
||||
CEO mit Stichentscheid
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface GFContractStepProps extends PropsBase {
|
||||
gf_list: Array<{ id: string; name: string; internal_role?: string }>
|
||||
upsertGFContract: (c: GFContract) => void
|
||||
}
|
||||
|
||||
export function StepGFContracts({ state, gf_list, upsertGFContract }: GFContractStepProps) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-600">
|
||||
Für jeden Geschäftsführer wird ein Dienstvertrag generiert. Bitte Eckdaten ausfüllen.
|
||||
</p>
|
||||
{gf_list.length === 0 ? (
|
||||
<p className="text-orange-600">Bitte zuerst in Step 2 mindestens einen GF anlegen.</p>
|
||||
) : (
|
||||
gf_list.map(gf => {
|
||||
const c = state.gf_contracts.find(x => x.gesellschafter_id === gf.id) || {
|
||||
gesellschafter_id: gf.id,
|
||||
gross_annual_salary_eur: 84000,
|
||||
has_bonus: false,
|
||||
has_company_car: false,
|
||||
has_bav: false,
|
||||
vacation_days: 30,
|
||||
kuendigungsfrist_gesellschaft_monate: 6,
|
||||
kuendigungsfrist_gf_monate: 3,
|
||||
para_181_release: true,
|
||||
sv_status: 'sozialversicherungsfrei' as const,
|
||||
}
|
||||
const u = (patch: Partial<GFContract>) => upsertGFContract({ ...c, ...patch })
|
||||
return (
|
||||
<div key={gf.id} className="border rounded-lg p-4" data-testid={`contract-${gf.id}`}>
|
||||
<h4 className="font-semibold mb-3">{gf.name} {gf.internal_role && `(${gf.internal_role})`}</h4>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-700 mb-1">Jahresgehalt (EUR brutto)</label>
|
||||
<input
|
||||
data-testid={`salary-${gf.id}`}
|
||||
type="number"
|
||||
value={c.gross_annual_salary_eur}
|
||||
onChange={e => u({ gross_annual_salary_eur: parseInt(e.target.value) || 0 })}
|
||||
className="w-full px-2 py-1 border rounded"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-700 mb-1">Urlaubstage</label>
|
||||
<input type="number" value={c.vacation_days}
|
||||
onChange={e => u({ vacation_days: parseInt(e.target.value) || 30 })}
|
||||
className="w-full px-2 py-1 border rounded" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-700 mb-1">SV-Status</label>
|
||||
<select value={c.sv_status} onChange={e => u({ sv_status: e.target.value as GFContract['sv_status'] })}
|
||||
className="w-full px-2 py-1 border rounded">
|
||||
<option value="sozialversicherungsfrei">sv-frei (Standard für GF/Gesellschafter)</option>
|
||||
<option value="sozialversicherungspflichtig">sv-pflichtig</option>
|
||||
<option value="noch zu klären">noch zu klären</option>
|
||||
</select>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={c.para_181_release}
|
||||
onChange={e => u({ para_181_release: e.target.checked })} />
|
||||
§ 181 BGB-Befreiung
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={c.has_bonus}
|
||||
onChange={e => u({ has_bonus: e.target.checked })} />
|
||||
Bonus-Vereinbarung
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={c.has_company_car}
|
||||
onChange={e => u({ has_company_car: e.target.checked })} />
|
||||
Firmenfahrzeug
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
defaultFoundingWizardState,
|
||||
type FoundingWizardState,
|
||||
type Gesellschafter,
|
||||
type GFContract,
|
||||
type GeneratedDocument,
|
||||
} from '@/lib/sdk/founding/types'
|
||||
|
||||
const STORAGE_KEY = 'breakpilot:founding-wizard:state:v1'
|
||||
|
||||
export const FOUNDING_WIZARD_STEPS = [
|
||||
{ id: 1, name: 'Stage & Basics', description: 'Unternehmensname, Sitz, Gegenstand' },
|
||||
{ id: 2, name: 'Gesellschafter', description: 'Gründer und ihre Anteile' },
|
||||
{ id: 3, name: 'Geschäftsführer', description: 'GF-Bestellung und Rollen' },
|
||||
{ id: 4, name: 'Kapital', description: 'Stammkapital und Einzahlung' },
|
||||
{ id: 5, name: 'Notar', description: 'Notartermin und Beurkundung' },
|
||||
{ id: 6, name: 'SHA-Optionen', description: 'Vesting, Drag-Along, Reserved Matters' },
|
||||
{ id: 7, name: 'GF-Verträge', description: 'Vergütung, D&O, Kündigungsfristen' },
|
||||
{ id: 8, name: 'Dokumente generieren', description: 'Auswahl und Word-Export' },
|
||||
]
|
||||
|
||||
export function useFoundingWizardForm() {
|
||||
const [state, setState] = useState<FoundingWizardState>(defaultFoundingWizardState())
|
||||
const [hydrated, setHydrated] = useState(false)
|
||||
const [generating, setGenerating] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Hydrate from localStorage
|
||||
useEffect(() => {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw)
|
||||
setState({ ...defaultFoundingWizardState(), ...parsed })
|
||||
}
|
||||
} catch {
|
||||
// ignore corrupted storage
|
||||
}
|
||||
setHydrated(true)
|
||||
}, [])
|
||||
|
||||
// Persist on every change after hydration
|
||||
useEffect(() => {
|
||||
if (!hydrated) return
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(state))
|
||||
} catch {
|
||||
// quota exceeded - ignore
|
||||
}
|
||||
}, [state, hydrated])
|
||||
|
||||
const update = useCallback(<K extends keyof FoundingWizardState>(
|
||||
key: K,
|
||||
value: FoundingWizardState[K] | ((prev: FoundingWizardState[K]) => FoundingWizardState[K])
|
||||
) => {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
[key]: typeof value === 'function' ? (value as Function)(prev[key]) : value,
|
||||
}))
|
||||
}, [])
|
||||
|
||||
const setStep = useCallback((step: number) => {
|
||||
setState(prev => ({ ...prev, current_step: step }))
|
||||
}, [])
|
||||
|
||||
const nextStep = useCallback(() => {
|
||||
setState(prev => ({ ...prev, current_step: Math.min(prev.current_step + 1, FOUNDING_WIZARD_STEPS.length) }))
|
||||
}, [])
|
||||
|
||||
const prevStep = useCallback(() => {
|
||||
setState(prev => ({ ...prev, current_step: Math.max(prev.current_step - 1, 1) }))
|
||||
}, [])
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setState(defaultFoundingWizardState())
|
||||
try { localStorage.removeItem(STORAGE_KEY) } catch {}
|
||||
}, [])
|
||||
|
||||
// Gesellschafter helpers
|
||||
const addGesellschafter = useCallback((gs: Omit<Gesellschafter, 'id' | 'anteil_nr'>) => {
|
||||
setState(prev => {
|
||||
const nextNr = (prev.gesellschafter.reduce((m, g) => Math.max(m, g.anteil_nr), 0)) + 1
|
||||
const id = `gs_${Date.now()}_${nextNr}`
|
||||
return { ...prev, gesellschafter: [...prev.gesellschafter, { ...gs, id, anteil_nr: nextNr }] }
|
||||
})
|
||||
}, [])
|
||||
|
||||
const updateGesellschafter = useCallback((id: string, patch: Partial<Gesellschafter>) => {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
gesellschafter: prev.gesellschafter.map(g => g.id === id ? { ...g, ...patch } : g),
|
||||
}))
|
||||
}, [])
|
||||
|
||||
const removeGesellschafter = useCallback((id: string) => {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
gesellschafter: prev.gesellschafter.filter(g => g.id !== id),
|
||||
gf_contracts: prev.gf_contracts.filter(c => c.gesellschafter_id !== id),
|
||||
}))
|
||||
}, [])
|
||||
|
||||
// GF Contract helpers
|
||||
const upsertGFContract = useCallback((contract: GFContract) => {
|
||||
setState(prev => {
|
||||
const idx = prev.gf_contracts.findIndex(c => c.gesellschafter_id === contract.gesellschafter_id)
|
||||
const next = [...prev.gf_contracts]
|
||||
if (idx >= 0) next[idx] = contract
|
||||
else next.push(contract)
|
||||
return { ...prev, gf_contracts: next }
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Validation (canProceed for current step)
|
||||
const canProceed = useMemo(() => {
|
||||
switch (state.current_step) {
|
||||
case 1:
|
||||
return state.basics.company_name.trim().length > 1 &&
|
||||
state.basics.company_seat.trim().length > 1 &&
|
||||
state.basics.company_purpose_description.trim().length > 10
|
||||
case 2: {
|
||||
if (state.gesellschafter.length < 1) return false
|
||||
const sum = state.gesellschafter.reduce((s, g) => s + (g.nennbetrag_eur || 0), 0)
|
||||
return sum === state.capital.stammkapital_eur
|
||||
}
|
||||
case 3:
|
||||
return state.gesellschafter.some(g => g.is_geschaeftsfuehrer)
|
||||
case 4:
|
||||
return state.capital.stammkapital_eur >= 25000
|
||||
case 5:
|
||||
return state.notar.notary_name.trim().length > 1 && state.notar.notary_place.trim().length > 1
|
||||
case 6:
|
||||
return true
|
||||
case 7:
|
||||
return state.gesellschafter.filter(g => g.is_geschaeftsfuehrer)
|
||||
.every(g => state.gf_contracts.some(c => c.gesellschafter_id === g.id))
|
||||
case 8:
|
||||
return state.selected_documents.length > 0
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}, [state])
|
||||
|
||||
const generateDocuments = useCallback(async (): Promise<GeneratedDocument[]> => {
|
||||
setGenerating(true)
|
||||
setError(null)
|
||||
try {
|
||||
const response = await fetch('/api/v1/founding-wizard/generate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(state),
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(`Generierung fehlgeschlagen: ${response.status}`)
|
||||
}
|
||||
const data = await response.json()
|
||||
const docs: GeneratedDocument[] = data.documents || []
|
||||
setState(prev => ({ ...prev, generated_documents: docs }))
|
||||
return docs
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : 'Unbekannter Fehler'
|
||||
setError(msg)
|
||||
throw e
|
||||
} finally {
|
||||
setGenerating(false)
|
||||
}
|
||||
}, [state])
|
||||
|
||||
// Derived: hat zugehöriger GF einen Vertrag?
|
||||
const gf_list = useMemo(
|
||||
() => state.gesellschafter.filter(g => g.is_geschaeftsfuehrer),
|
||||
[state.gesellschafter]
|
||||
)
|
||||
|
||||
return {
|
||||
state, hydrated, generating, error,
|
||||
update, setStep, nextStep, prevStep, reset,
|
||||
addGesellschafter, updateGesellschafter, removeGesellschafter,
|
||||
upsertGFContract,
|
||||
canProceed, generateDocuments,
|
||||
gf_list,
|
||||
steps: FOUNDING_WIZARD_STEPS,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { useFoundingWizardForm } from './_hooks/useFoundingWizardForm'
|
||||
import { StepBasics } from './_components/StepBasics'
|
||||
import { StepGesellschafter } from './_components/StepGesellschafter'
|
||||
import { StepCapital, StepGFAssignment, StepGFContracts, StepNotar, StepSHAConfig } from './_components/StepsSimpleConfig'
|
||||
import { StepGenerate } from './_components/StepGenerate'
|
||||
|
||||
export default function FoundingWizardPage() {
|
||||
const {
|
||||
state, hydrated, generating, error,
|
||||
update, nextStep, prevStep, reset,
|
||||
addGesellschafter, updateGesellschafter, removeGesellschafter,
|
||||
upsertGFContract,
|
||||
canProceed, generateDocuments,
|
||||
gf_list, steps,
|
||||
} = useFoundingWizardForm()
|
||||
|
||||
if (!hydrated) return null
|
||||
|
||||
const isLastStep = state.current_step === steps.length
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8" data-testid="founding-wizard">
|
||||
<div className="max-w-5xl mx-auto px-4">
|
||||
{/* Header */}
|
||||
<div className="mb-8 flex justify-between items-start">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Gründungs-Wizard</h1>
|
||||
<p className="text-gray-600 mt-2">
|
||||
Erstellt alle Notartermin-Dokumente für Deine GmbH/UG-Gründung in 8 Schritten.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
data-testid="reset-wizard"
|
||||
onClick={() => { if (confirm('Wizard-Daten zurücksetzen?')) reset() }}
|
||||
className="text-sm text-gray-500 hover:text-red-600"
|
||||
>
|
||||
Zurücksetzen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Progress Steps */}
|
||||
<div className="mb-8" data-testid="wizard-progress">
|
||||
<div className="flex items-center justify-between">
|
||||
{steps.map((step, idx) => (
|
||||
<React.Fragment key={step.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => state.current_step > step.id && update('current_step', step.id)}
|
||||
className="flex items-center"
|
||||
data-testid={`step-indicator-${step.id}`}
|
||||
>
|
||||
<div className={`w-9 h-9 rounded-full flex items-center justify-center text-sm font-medium ${
|
||||
step.id < state.current_step ? 'bg-purple-600 text-white' :
|
||||
step.id === state.current_step ? 'bg-purple-100 text-purple-600 border-2 border-purple-600' :
|
||||
'bg-gray-100 text-gray-400'
|
||||
}`}>
|
||||
{step.id < state.current_step ? '✓' : step.id}
|
||||
</div>
|
||||
<div className="ml-2 hidden md:block text-left">
|
||||
<div className={`text-xs font-medium ${step.id <= state.current_step ? 'text-gray-900' : 'text-gray-400'}`}>
|
||||
{step.name}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{idx < steps.length - 1 && (
|
||||
<div className={`flex-1 h-0.5 mx-2 ${step.id < state.current_step ? 'bg-purple-600' : 'bg-gray-200'}`} />
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step Content */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-8">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900">
|
||||
{steps[state.current_step - 1]?.name}
|
||||
</h2>
|
||||
<p className="text-gray-500 text-sm">{steps[state.current_step - 1]?.description}</p>
|
||||
</div>
|
||||
|
||||
<div data-testid={`step-content-${state.current_step}`}>
|
||||
{state.current_step === 1 && <StepBasics state={state} update={update} />}
|
||||
{state.current_step === 2 && (
|
||||
<StepGesellschafter
|
||||
state={state}
|
||||
addGesellschafter={addGesellschafter}
|
||||
updateGesellschafter={updateGesellschafter}
|
||||
removeGesellschafter={removeGesellschafter}
|
||||
/>
|
||||
)}
|
||||
{state.current_step === 3 && <StepGFAssignment state={state} update={update} />}
|
||||
{state.current_step === 4 && <StepCapital state={state} update={update} />}
|
||||
{state.current_step === 5 && <StepNotar state={state} update={update} />}
|
||||
{state.current_step === 6 && <StepSHAConfig state={state} update={update} />}
|
||||
{state.current_step === 7 && (
|
||||
<StepGFContracts state={state} update={update} gf_list={gf_list} upsertGFContract={upsertGFContract} />
|
||||
)}
|
||||
{state.current_step === 8 && (
|
||||
<StepGenerate
|
||||
state={state}
|
||||
update={update}
|
||||
generating={generating}
|
||||
error={error}
|
||||
onGenerate={generateDocuments}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
{!isLastStep && (
|
||||
<div className="flex justify-between items-center mt-8 pt-6 border-t border-gray-200">
|
||||
<button
|
||||
data-testid="prev-step"
|
||||
onClick={prevStep}
|
||||
disabled={state.current_step === 1}
|
||||
className="px-6 py-3 text-gray-600 hover:text-gray-900 disabled:opacity-50"
|
||||
>
|
||||
Zurück
|
||||
</button>
|
||||
<span className="text-xs text-gray-400">
|
||||
Schritt {state.current_step} von {steps.length}
|
||||
</span>
|
||||
<button
|
||||
data-testid="next-step"
|
||||
onClick={nextStep}
|
||||
disabled={!canProceed}
|
||||
className="px-8 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50"
|
||||
>
|
||||
Weiter
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -39,11 +39,19 @@ export function HazardTable({ hazards, lifecyclePhases, onDelete }: {
|
||||
.map((hazard) => (
|
||||
<tr key={hazard.id} className="hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors">
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">{hazard.name}</div>
|
||||
{hazard.name.startsWith('Auto:') && (
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-green-100 text-green-700">Auto</span>
|
||||
)}
|
||||
{(hazard as { pattern_id?: string }).pattern_id && (
|
||||
<span
|
||||
className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-mono font-medium bg-slate-100 text-slate-700 border border-slate-200 cursor-help"
|
||||
title={`Quelle: BreakPilot IACE Pattern-Engine (${(hazard as { pattern_id?: string }).pattern_id}). Lizenzregel R3 — Eigenwerk, kein externer Lizenz-Footer noetig. Pattern-Definition mit Norm-Referenzen siehe Library.`}
|
||||
>
|
||||
{(hazard as { pattern_id?: string }).pattern_id} · R3
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{hazard.description && (
|
||||
<div className="text-xs text-gray-500 truncate max-w-[250px]">{hazard.description}</div>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { ObjectivesTab } from './_components/ObjectivesTab'
|
||||
import { AuditsTab } from './_components/AuditsTab'
|
||||
import { ReviewsTab } from './_components/ReviewsTab'
|
||||
import { AssetsTab } from './_components/AssetsTab'
|
||||
import { LicenseModuleBanner } from '@/components/sdk/LicenseModuleBanner'
|
||||
|
||||
// =============================================================================
|
||||
// MAIN PAGE
|
||||
@@ -38,6 +39,13 @@ export default function ISMSPage() {
|
||||
<p className="text-xs text-amber-600 mt-2">
|
||||
Hinweis: Basierend auf eigenen Pruefaspekten, kein ISO-Normtext. Ersetzt kein Zertifizierungsaudit.
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
<LicenseModuleBanner
|
||||
rule={3}
|
||||
sourceLabel="BreakPilot-ISMS-Methodik mit Verweis auf ISO/IEC 27001"
|
||||
detail="ISO-Normtexte sind copyright-geschuetzt (R3 — nur Identifier-Verweise). Eigene Pruefaspekte sind BreakPilot-Eigenwerk."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
// Stufe 1 of the Attribution Renderer (Task #23): the global
|
||||
// "Quellen & Lizenzen" overview. Aggregates all 314k canonical_controls
|
||||
// by their license_rule and shows the source regulations behind each
|
||||
// bucket. Drives the footer link and gives auditors a one-page view of
|
||||
// what licence classes the platform is operating under.
|
||||
|
||||
type SourceCount = {
|
||||
regulation_id: string
|
||||
regulation_name_de: string | null
|
||||
license_rule: number
|
||||
license_type: string | null
|
||||
attribution: string | null
|
||||
jurisdiction: string | null
|
||||
source_type: string | null
|
||||
n_controls: number
|
||||
}
|
||||
|
||||
type RuleBucket = {
|
||||
rule: number
|
||||
label_de: string
|
||||
label_en: string
|
||||
attribution_required: boolean
|
||||
render_full_text: boolean
|
||||
total_controls: number
|
||||
distinct_sources: number
|
||||
sources: SourceCount[]
|
||||
}
|
||||
|
||||
type Overview = {
|
||||
total_controls: number
|
||||
buckets: RuleBucket[]
|
||||
}
|
||||
|
||||
const RULE_COLOR: Record<number, string> = {
|
||||
1: 'border-emerald-200 bg-emerald-50',
|
||||
2: 'border-amber-200 bg-amber-50',
|
||||
3: 'border-slate-200 bg-slate-50',
|
||||
}
|
||||
|
||||
const RULE_BADGE: Record<number, string> = {
|
||||
1: 'bg-emerald-600 text-white',
|
||||
2: 'bg-amber-600 text-white',
|
||||
3: 'bg-slate-600 text-white',
|
||||
}
|
||||
|
||||
export default function LicensesPage() {
|
||||
const [data, setData] = useState<Overview | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/sdk/v1/compliance/licenses/overview')
|
||||
.then((r) => (r.ok ? r.json() : Promise.reject(`HTTP ${r.status}`)))
|
||||
.then(setData)
|
||||
.catch((e) => setError(String(e)))
|
||||
}, [])
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-xl font-semibold mb-2">Quellen & Lizenzen</h1>
|
||||
<p className="text-red-600">Fehler beim Laden: {error}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-xl font-semibold">Quellen & Lizenzen</h1>
|
||||
<p className="text-slate-500 mt-2">Lade …</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-7xl">
|
||||
<header className="mb-6">
|
||||
<h1 className="text-2xl font-semibold">Quellen & Lizenzen</h1>
|
||||
<p className="text-sm text-slate-600 mt-1">
|
||||
Diese Plattform stützt sich auf {data.total_controls.toLocaleString('de-DE')}{' '}
|
||||
klassifizierte Compliance-Controls aus den unten genannten Quellen.
|
||||
Jeder Control trägt eine deterministische Lizenzregel (R1–R3), die das
|
||||
Render-Verhalten in Berichten und im Frontend steuert.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-lg font-medium mb-3">Klassifizierungs-Schema</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 text-sm">
|
||||
{data.buckets.map((b) => (
|
||||
<div key={b.rule} className={`rounded border ${RULE_COLOR[b.rule] ?? 'border-slate-200'} p-3`}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className={`inline-flex items-center justify-center w-7 h-7 rounded-full text-xs font-bold ${RULE_BADGE[b.rule] ?? 'bg-slate-600 text-white'}`}>
|
||||
R{b.rule}
|
||||
</span>
|
||||
<span className="font-medium">{b.label_de}</span>
|
||||
</div>
|
||||
<ul className="text-xs text-slate-700 space-y-1">
|
||||
<li>{b.total_controls.toLocaleString('de-DE')} Controls</li>
|
||||
<li>{b.distinct_sources} Quellen</li>
|
||||
<li>{b.render_full_text ? 'Volltext-Anzeige erlaubt' : 'Nur Identifier-Verweis'}</li>
|
||||
<li>{b.attribution_required ? 'Attribution-Pflicht in Output' : 'keine Attribution-Pflicht'}</li>
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{data.buckets.map((b) => (
|
||||
<section key={b.rule} className="mb-8">
|
||||
<h2 className="text-lg font-medium mb-3 flex items-center gap-2">
|
||||
<span className={`inline-flex items-center justify-center w-7 h-7 rounded-full text-xs font-bold ${RULE_BADGE[b.rule] ?? 'bg-slate-600 text-white'}`}>
|
||||
R{b.rule}
|
||||
</span>
|
||||
{b.label_de}{' '}
|
||||
<span className="text-sm text-slate-500 font-normal">
|
||||
({b.total_controls.toLocaleString('de-DE')} Controls aus {b.distinct_sources} Quellen)
|
||||
</span>
|
||||
</h2>
|
||||
|
||||
<div className="overflow-x-auto border rounded">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-100 text-slate-700">
|
||||
<tr>
|
||||
<th className="text-left p-2">Quelle</th>
|
||||
<th className="text-left p-2">Lizenztyp</th>
|
||||
<th className="text-left p-2">Rechtsraum</th>
|
||||
<th className="text-left p-2">Attribution</th>
|
||||
<th className="text-right p-2">Controls</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{b.sources.map((s) => (
|
||||
<tr key={`${b.rule}-${s.regulation_id}`} className="border-t">
|
||||
<td className="p-2">{s.regulation_name_de ?? s.regulation_id}</td>
|
||||
<td className="p-2 text-slate-600">{s.license_type ?? '—'}</td>
|
||||
<td className="p-2 text-slate-600">{s.jurisdiction ?? '—'}</td>
|
||||
<td className="p-2 text-slate-600">{s.attribution ?? '—'}</td>
|
||||
<td className="p-2 text-right tabular-nums">{s.n_controls.toLocaleString('de-DE')}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
|
||||
<footer className="text-xs text-slate-500 border-t pt-4 mt-8">
|
||||
Klassifizierung: deterministisch über parent_control_uuid-Vererbung,
|
||||
control_parent_links → regulation_registry, source_citation,
|
||||
canonical_processed_chunks (Pipeline-Ground-Truth) und LLM-Aggregat-
|
||||
Identifikation für eigene Werke. Audit-Skripte unter
|
||||
breakpilot-core/control-pipeline/scripts/.
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { SecurityItemCard } from './_components/SecurityItemCard'
|
||||
import { ItemModal } from './_components/ItemModal'
|
||||
import { useSecurityBacklog, EMPTY_NEW_ITEM } from './_hooks/useSecurityBacklog'
|
||||
import type { SecurityItem } from './_hooks/useSecurityBacklog'
|
||||
import { LicenseModuleBanner } from '@/components/sdk/LicenseModuleBanner'
|
||||
|
||||
export default function SecurityBacklogPage() {
|
||||
const [filter, setFilter] = useState<string>('all')
|
||||
@@ -37,6 +38,11 @@ export default function SecurityBacklogPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<LicenseModuleBanner
|
||||
rule={2}
|
||||
sourceLabel="OWASP Top 10 / ASVS / SAMM (CC-BY-SA 4.0) + NIST SP 800-53 (US PD)"
|
||||
detail="OWASP-Inhalte zitiert mit Pflicht-Attribution 'OWASP Foundation, CC BY-SA 4.0'. NIST woertlich (R1)."
|
||||
/>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import React from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useTOMGenerator } from '@/lib/sdk/tom-generator'
|
||||
import { TOM_GENERATOR_STEPS } from '@/lib/sdk/tom-generator/types'
|
||||
import { LicenseModuleBanner } from '@/components/sdk/LicenseModuleBanner'
|
||||
|
||||
/**
|
||||
* TOM Generator Landing Page
|
||||
@@ -45,6 +46,14 @@ export default function TOMGeneratorPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<LicenseModuleBanner
|
||||
rule={1}
|
||||
sourceLabel="DSGVO Art. 32 (EU 2016/679) — TOM-Anforderungen"
|
||||
detail="Generator-Logik und Vorlagen sind BreakPilot-Eigenwerk (R3); zitierte Rechtsquelle EU_LAW (R1)."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Progress Card */}
|
||||
{hasProgress && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6 mb-8">
|
||||
|
||||
@@ -350,7 +350,12 @@ function ActivityCard({ activity, onEdit, onDelete }: { activity: VVTActivity; o
|
||||
<span className="px-2 py-0.5 text-xs bg-purple-100 text-purple-700 rounded-full">DSFA</span>
|
||||
)}
|
||||
{(activity as any).sourceTemplateId && (
|
||||
<span className="px-2 py-0.5 text-xs bg-indigo-100 text-indigo-700 rounded-full">Vorlage</span>
|
||||
<span
|
||||
className="px-2 py-0.5 text-xs bg-indigo-100 text-indigo-700 rounded-full cursor-help"
|
||||
title="Erstellt aus Bundeslaender-DSGVO-Vorlage (Art. 30 DSGVO). Lizenzregel R1 — Hoheitsrecht/DE_LAW, woertlich uebernehmbar."
|
||||
>
|
||||
Vorlage · R1
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="text-base font-semibold text-gray-900 truncate">{activity.name || '(Ohne Namen)'}</h3>
|
||||
|
||||
@@ -195,12 +195,18 @@ export default function CatalogTable({
|
||||
)}
|
||||
<td className="px-4 py-2.5">
|
||||
{entry.source === 'system' ? (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300">
|
||||
System
|
||||
<span
|
||||
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 cursor-help"
|
||||
title="System-Katalog — Quellen aus EU-Recht, BAuA, NIST u.a. Lizenzregel je Eintrag (siehe /sdk/licenses)."
|
||||
>
|
||||
System · R1/R2/R3
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300">
|
||||
Benutzerdefiniert
|
||||
<span
|
||||
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300 cursor-help"
|
||||
title="Benutzerdefinierter Eintrag — BreakPilot/Anwender-Eigenwerk. Lizenzregel R3 (Identifier-Verweis), keine externe Attribution noetig."
|
||||
>
|
||||
Benutzerdefiniert · R3
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
'use client'
|
||||
|
||||
// Reusable licence-source banner placed at the top of an SDK module page.
|
||||
// One-line context that tells the user (and any auditor) which sources
|
||||
// the module draws on and which BreakPilot licence rule applies.
|
||||
//
|
||||
// Usage:
|
||||
// <LicenseModuleBanner
|
||||
// rule={1}
|
||||
// sourceLabel="DSGVO Art. 30 (EU 2016/679)"
|
||||
// />
|
||||
//
|
||||
// For modules that are pure BreakPilot eigenwerk:
|
||||
// <LicenseModuleBanner rule={3} sourceLabel="BreakPilot-Eigenwerk" />
|
||||
|
||||
type Props = {
|
||||
rule: 1 | 2 | 3
|
||||
sourceLabel: string
|
||||
/** Optional extended note shown after sourceLabel */
|
||||
detail?: string
|
||||
}
|
||||
|
||||
const RULE_META: Record<number, { bg: string; text: string; pill: string; descr: string }> = {
|
||||
1: {
|
||||
bg: 'bg-emerald-50 border-emerald-200',
|
||||
text: 'text-emerald-800',
|
||||
pill: 'bg-emerald-600 text-white',
|
||||
descr: 'Hoheitsrecht/Public Domain — woertlich uebernehmbar',
|
||||
},
|
||||
2: {
|
||||
bg: 'bg-amber-50 border-amber-200',
|
||||
text: 'text-amber-800',
|
||||
pill: 'bg-amber-600 text-white',
|
||||
descr: 'Woertlich mit Attribution-Pflicht',
|
||||
},
|
||||
3: {
|
||||
bg: 'bg-slate-50 border-slate-200',
|
||||
text: 'text-slate-700',
|
||||
pill: 'bg-slate-600 text-white',
|
||||
descr: 'Identifier-Verweis / BreakPilot-Eigenwerk',
|
||||
},
|
||||
}
|
||||
|
||||
export function LicenseModuleBanner({ rule, sourceLabel, detail }: Props) {
|
||||
const m = RULE_META[rule]
|
||||
return (
|
||||
<div className={`px-3 py-2 ${m.bg} border rounded-lg text-xs ${m.text} flex items-start gap-2`}>
|
||||
<span className={`inline-flex items-center justify-center w-6 h-6 rounded-full text-[10px] font-bold ${m.pill} flex-shrink-0`}>
|
||||
R{rule}
|
||||
</span>
|
||||
<div className="flex-1">
|
||||
<span className="font-semibold">Quellen & Lizenz:</span>{' '}
|
||||
<span>{sourceLabel}</span>
|
||||
<span className="text-slate-500"> — {m.descr}.</span>
|
||||
{detail && <span className="block mt-0.5 text-[11px] opacity-80">{detail}</span>}
|
||||
<a href="/sdk/licenses" className="underline ml-1">Quellenverzeichnis</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LicenseModuleBanner
|
||||
@@ -224,6 +224,19 @@ export function SDKSidebar({ collapsed = false, onCollapsedChange }: SDKSidebarP
|
||||
<span>Exportieren</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!collapsed && (
|
||||
<a
|
||||
href="/sdk/licenses"
|
||||
className="mt-2 w-full flex items-center justify-center gap-2 px-4 py-2 text-xs text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
title="Quellen und Lizenzen aller verwendeten Compliance-Controls"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<span>Quellen & Lizenzen</span>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
// Stufe 3 of the Attribution Renderer (Task #23): an inline source
|
||||
// badge that any rendered control/hazard/measure can attach to itself.
|
||||
//
|
||||
// Visually a small license-rule pill (R1/R2/R3); on hover/click it
|
||||
// reveals the underlying regulation, license type, and — for Rule 2 —
|
||||
// the mandatory attribution string.
|
||||
//
|
||||
// Usage:
|
||||
// <SourceBadge controlUuid={hazard.id} />
|
||||
//
|
||||
// The component lazily fetches /licenses/source-info/{uuid} on first
|
||||
// expand so the surrounding list view stays cheap.
|
||||
|
||||
type SourceInfo = {
|
||||
control_uuid: string
|
||||
license_rule: number | null
|
||||
license_label_de: string | null
|
||||
attribution_required: boolean
|
||||
render_full_text: boolean
|
||||
regulation_id: string | null
|
||||
regulation_name_de: string | null
|
||||
license_type: string | null
|
||||
attribution: string | null
|
||||
source_url: string | null
|
||||
}
|
||||
|
||||
const RULE_BADGE: Record<number, string> = {
|
||||
1: 'bg-emerald-100 text-emerald-800 border-emerald-300',
|
||||
2: 'bg-amber-100 text-amber-800 border-amber-300',
|
||||
3: 'bg-slate-100 text-slate-700 border-slate-300',
|
||||
}
|
||||
|
||||
const RULE_TITLE: Record<number, string> = {
|
||||
1: 'R1 — wörtlich übernehmbar',
|
||||
2: 'R2 — wörtlich mit Attribution',
|
||||
3: 'R3 — nur Identifier zitieren',
|
||||
}
|
||||
|
||||
interface SourceBadgeProps {
|
||||
controlUuid: string
|
||||
/** Optional: skip the fetch and render from already-known data. */
|
||||
prefetched?: SourceInfo
|
||||
/** Compact mode for tight UI rows (smaller pill). */
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
export function SourceBadge({ controlUuid, prefetched, compact }: SourceBadgeProps) {
|
||||
const [data, setData] = useState<SourceInfo | null>(prefetched ?? null)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || data) return
|
||||
setLoading(true)
|
||||
fetch(`/api/sdk/v1/compliance/licenses/source-info/${controlUuid}`)
|
||||
.then((r) => (r.ok ? r.json() : Promise.reject(`HTTP ${r.status}`)))
|
||||
.then(setData)
|
||||
.catch((e) => setError(String(e)))
|
||||
.finally(() => setLoading(false))
|
||||
}, [open, data, controlUuid])
|
||||
|
||||
const rule = data?.license_rule ?? prefetched?.license_rule ?? null
|
||||
const badgeClass = rule ? RULE_BADGE[rule] ?? RULE_BADGE[3] : 'bg-slate-100 text-slate-500 border-slate-200'
|
||||
const sizeClass = compact ? 'text-[10px] px-1.5 py-0.5' : 'text-xs px-2 py-0.5'
|
||||
|
||||
return (
|
||||
<span className="relative inline-block">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className={`inline-flex items-center gap-1 rounded border font-medium ${sizeClass} ${badgeClass} hover:opacity-80 transition`}
|
||||
title={rule ? RULE_TITLE[rule] : 'Lizenz unbekannt'}
|
||||
aria-expanded={open}
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 16 16" fill="currentColor" aria-hidden>
|
||||
<path d="M8 0a8 8 0 1 0 0 16A8 8 0 0 0 8 0Zm0 4.5a1 1 0 1 1 0 2 1 1 0 0 1 0-2ZM7 8h2v4.5H7V8Z" />
|
||||
</svg>
|
||||
{rule ? `R${rule}` : '?'}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute left-0 mt-1 z-40 w-80 rounded-md border border-slate-200 bg-white shadow-lg p-3 text-xs">
|
||||
{loading && <p className="text-slate-500">Lade Quellen-Info…</p>}
|
||||
{error && <p className="text-red-600">Fehler: {error}</p>}
|
||||
{data && (
|
||||
<div className="space-y-2">
|
||||
<div className="font-semibold text-slate-800">
|
||||
{data.license_label_de ?? 'Lizenz unbekannt'}
|
||||
</div>
|
||||
{data.regulation_name_de && (
|
||||
<div>
|
||||
<span className="text-slate-500">Quelle:</span>{' '}
|
||||
<span className="text-slate-800">{data.regulation_name_de}</span>
|
||||
</div>
|
||||
)}
|
||||
{data.license_type && (
|
||||
<div>
|
||||
<span className="text-slate-500">Lizenztyp:</span>{' '}
|
||||
<span className="text-slate-700">{data.license_type}</span>
|
||||
</div>
|
||||
)}
|
||||
{data.attribution && (
|
||||
<div className="rounded bg-amber-50 border border-amber-200 px-2 py-1.5">
|
||||
<div className="text-[10px] font-semibold text-amber-800 uppercase tracking-wide">
|
||||
Attribution-Pflicht
|
||||
</div>
|
||||
<div className="text-amber-900">{data.attribution}</div>
|
||||
</div>
|
||||
)}
|
||||
{!data.render_full_text && (
|
||||
<div className="text-[10px] text-slate-500 italic">
|
||||
Volltext wird im Output nicht gerendert — nur Identifier-Verweis.
|
||||
</div>
|
||||
)}
|
||||
{data.source_url && (
|
||||
<a
|
||||
href={data.source_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-block text-[10px] text-blue-600 hover:underline mt-1"
|
||||
>
|
||||
Originalquelle öffnen ↗
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default SourceBadge
|
||||
@@ -0,0 +1,355 @@
|
||||
/**
|
||||
* E2E-Test fuer den Founding-Wizard
|
||||
*
|
||||
* Prueft den vollstaendigen 8-Step-Flow:
|
||||
* - Application-Errors / Console-Errors auf jeder Seite
|
||||
* - StepBasics: Prefill-Button + Registergericht/HRB-Felder
|
||||
* - StepGesellschafter: Rollen-Dropdown + IP-Bereiche fuer 2 Gruender
|
||||
* - Per-Person Generation: 2 IP-Assignment-Dokumente
|
||||
* - localStorage-Persistenz
|
||||
*
|
||||
* Backend wird per route.fulfill() gemockt — Test ist hermetisch.
|
||||
*/
|
||||
|
||||
import { test, expect, type Page, type ConsoleMessage } from '@playwright/test'
|
||||
|
||||
const BASE = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:3002'
|
||||
const WIZARD_PATH = '/sdk/founding-wizard'
|
||||
|
||||
/** Filtert Browser-Console auf echte App-Errors (ignoriert Next.js / Hydration / 3rd-party Warnings). */
|
||||
function isRealAppError(msg: ConsoleMessage): boolean {
|
||||
if (msg.type() !== 'error') return false
|
||||
const text = msg.text()
|
||||
// Bekanntes Rauschen ausschliessen
|
||||
const ignored = [
|
||||
'Failed to load resource', // 404 fuer Icons etc.
|
||||
'Download the React DevTools', // React-Hinweis
|
||||
'net::ERR_', // Netzwerk (gemockt → erwartete Misses)
|
||||
'Hydration failed because', // Next 15 Pseudo-Errors bei dev
|
||||
'[founding-wizard] prefill failed', // Intentional UX-Logging im Prefill-Fehlerpfad
|
||||
]
|
||||
return !ignored.some(p => text.includes(p))
|
||||
}
|
||||
|
||||
const IGNORED_PAGE_ERRORS = [
|
||||
// Hydration mismatches durch dynamische Zeitstempel ("Gerade eben" vs "vor 1 Min")
|
||||
// im SDK-Header — pure dev-Mode-Symptom, kein App-Bug.
|
||||
'Hydration failed because the server rendered text didn',
|
||||
'There was an error while hydrating',
|
||||
// Next.js dev-mode signals fuer Hydration-Issues
|
||||
'Text content does not match server-rendered HTML',
|
||||
]
|
||||
|
||||
function isIgnoredPageError(err: Error): boolean {
|
||||
return IGNORED_PAGE_ERRORS.some(p => err.message.includes(p))
|
||||
}
|
||||
|
||||
/** Setzt Console-Error- und PageError-Listener. Wirft am Ende, wenn welche aufgetreten sind. */
|
||||
function installErrorTraps(page: Page): { assertNoErrors: () => void } {
|
||||
const consoleErrors: string[] = []
|
||||
const pageErrors: string[] = []
|
||||
|
||||
page.on('console', msg => {
|
||||
if (isRealAppError(msg)) consoleErrors.push(msg.text())
|
||||
})
|
||||
page.on('pageerror', err => {
|
||||
if (!isIgnoredPageError(err)) pageErrors.push(`${err.name}: ${err.message}`)
|
||||
})
|
||||
|
||||
return {
|
||||
assertNoErrors() {
|
||||
const all = [...pageErrors.map(e => `[pageerror] ${e}`), ...consoleErrors.map(e => `[console.error] ${e}`)]
|
||||
if (all.length > 0) {
|
||||
throw new Error(`Application-Errors waehrend des Flows:\n${all.join('\n')}`)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Mockt die zwei API-Endpoints, die der Wizard aufruft. */
|
||||
async function mockBackend(page: Page) {
|
||||
// 1) Company-Profile Prefill
|
||||
await page.route('**/api/sdk/v1/company-profile**', async route => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
profile: {
|
||||
companyName: 'Breakpilot GmbH',
|
||||
legalForm: 'GmbH',
|
||||
industry: ['Software', 'KI/ML'],
|
||||
businessModel: 'SaaS',
|
||||
offerings: ['SaaS-Plattform', 'Compliance-API'],
|
||||
headquartersStreet: 'Königstraße 1',
|
||||
headquartersZip: '70173',
|
||||
headquartersCity: 'Stuttgart',
|
||||
},
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
// 2) Founding-Wizard Generate (gibt 9 Dokumente zurueck: 7 normale + 2 per-person IP-Assignments)
|
||||
await page.route('**/api/v1/founding-wizard/generate', async route => {
|
||||
const request = route.request()
|
||||
const body = JSON.parse(request.postData() || '{}')
|
||||
const selected: string[] = body.selected_documents || []
|
||||
const gesellschafter: Array<{ name?: string; is_geschaeftsfuehrer?: boolean }> = body.gesellschafter || []
|
||||
|
||||
const PER_PERSON = ['ip_assignment_agreement', 'managing_director_employment_contract']
|
||||
const docs: unknown[] = []
|
||||
const tinyDocx = 'UEsDBBQAAAAIAA==' // gueltige base64-Stub (Playwright braucht keinen echten DOCX)
|
||||
|
||||
for (const docType of selected) {
|
||||
if (PER_PERSON.includes(docType)) {
|
||||
const persons = docType === 'managing_director_employment_contract'
|
||||
? gesellschafter.filter(g => g.is_geschaeftsfuehrer)
|
||||
: gesellschafter
|
||||
for (const p of persons) {
|
||||
docs.push({
|
||||
document_type: docType,
|
||||
title: `${docType} — ${p.name}`,
|
||||
filename: `${docType}_${(p.name || 'X').replace(/\s/g, '_')}.docx`,
|
||||
download_url: `data:application/vnd.openxmlformats-officedocument.wordprocessingml.document;base64,${tinyDocx}`,
|
||||
size_bytes: 12345,
|
||||
generated_at: '2026-05-21T12:00:00Z',
|
||||
})
|
||||
}
|
||||
} else {
|
||||
docs.push({
|
||||
document_type: docType,
|
||||
title: docType,
|
||||
filename: `${docType}.docx`,
|
||||
download_url: `data:application/vnd.openxmlformats-officedocument.wordprocessingml.document;base64,${tinyDocx}`,
|
||||
size_bytes: 12345,
|
||||
generated_at: '2026-05-21T12:00:00Z',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ documents: docs, warnings: [] }),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/** Clears wizard-state and pre-accepts cookies so the CookieBannerOverlay
|
||||
* does not intercept clicks during the test. */
|
||||
async function resetWizardState(page: Page) {
|
||||
await page.addInitScript(() => {
|
||||
try {
|
||||
window.localStorage.removeItem('breakpilot:founding-wizard:state:v1')
|
||||
// CookieBannerOverlay liest 'bp-sdk-cookie-consent' und blendet sich aus,
|
||||
// sobald ein Eintrag existiert. Wir setzen Minimal-Consent.
|
||||
window.localStorage.setItem('bp-sdk-cookie-consent', JSON.stringify({
|
||||
necessary: true, statistics: false, marketing: false, functional: false,
|
||||
ewrOnly: false, blockedVendors: [], timestamp: new Date().toISOString(),
|
||||
}))
|
||||
} catch {}
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Founding-Wizard E2E', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await resetWizardState(page)
|
||||
await mockBackend(page)
|
||||
})
|
||||
|
||||
test('vollstaendiger 8-Step-Flow ohne Application-Errors', async ({ page }) => {
|
||||
const errors = installErrorTraps(page)
|
||||
|
||||
await page.goto(`${BASE}${WIZARD_PATH}`)
|
||||
await expect(page.getByTestId('founding-wizard')).toBeVisible()
|
||||
await expect(page.getByTestId('step-content-1')).toBeVisible()
|
||||
|
||||
// --- Step 1: Basics + Prefill ---
|
||||
await page.getByRole('button', { name: /Aus Unternehmensprofil vorbef/i }).click()
|
||||
await expect(page.getByTestId('company-name')).toHaveValue('Breakpilot GmbH', { timeout: 5000 })
|
||||
await expect(page.getByTestId('company-seat')).toHaveValue('Stuttgart')
|
||||
|
||||
// Pflichtfeld: company_purpose_description (mind. 10 Zeichen)
|
||||
await page.getByTestId('company-purpose').fill(
|
||||
'die Entwicklung, Bereitstellung und der Betrieb von KI-gestuetzten Compliance-Werkzeugen sowie damit verbundener Beratungsleistungen.'
|
||||
)
|
||||
|
||||
// Neue Felder: Registergericht + HRB
|
||||
await page.getByTestId('register-court').fill('Amtsgericht Stuttgart')
|
||||
await page.getByTestId('hrb-number').fill('') // noch nicht eingetragen
|
||||
|
||||
await page.getByTestId('next-step').click()
|
||||
|
||||
// --- Step 2: Gesellschafter ---
|
||||
await expect(page.getByTestId('step-content-2')).toBeVisible()
|
||||
|
||||
// Benjamin (CEO, IP: Compliance + RAG)
|
||||
await page.getByTestId('gs-name').fill('Benjamin Bönisch')
|
||||
await page.getByTestId('gs-birthdate').fill('1985-01-15')
|
||||
await page.getByTestId('gs-address').fill('Teststraße 1, 70173 Stuttgart')
|
||||
await page.getByTestId('gs-email').fill('benjamin@breakpilot.ai')
|
||||
await page.getByTestId('gs-nennbetrag').fill('12500')
|
||||
await page.getByTestId('gs-role').selectOption('CEO')
|
||||
await page.getByTestId('gs-ip-areas').fill(
|
||||
'Compliance-Engine (Quellcode + Architektur)\nRAG-Pipeline\nProdukt-Konzepte'
|
||||
)
|
||||
await page.getByTestId('add-gesellschafter').click()
|
||||
await expect(page.getByTestId('gs-row-1')).toBeVisible()
|
||||
|
||||
// Sharang (CTO, IP: Security + Infrastruktur)
|
||||
await page.getByTestId('gs-name').fill('Sharang Parnerkar')
|
||||
await page.getByTestId('gs-birthdate').fill('1990-06-20')
|
||||
await page.getByTestId('gs-address').fill('Teststraße 2, 70173 Stuttgart')
|
||||
await page.getByTestId('gs-email').fill('sharang@breakpilot.ai')
|
||||
await page.getByTestId('gs-nennbetrag').fill('12500')
|
||||
await page.getByTestId('gs-role').selectOption('CTO')
|
||||
await page.getByTestId('gs-ip-areas').fill('Security-Modul\nInfrastructure-as-Code')
|
||||
await page.getByTestId('add-gesellschafter').click()
|
||||
await expect(page.getByTestId('gs-row-2')).toBeVisible()
|
||||
|
||||
// Summe Nennbetraege muss Stammkapital entsprechen (25.000)
|
||||
await expect(page.getByTestId('gs-total')).toContainText('25.000')
|
||||
|
||||
await page.getByTestId('next-step').click()
|
||||
|
||||
// --- Step 3: GF-Assignment (Defaults sind ok, beide bereits GF) ---
|
||||
await expect(page.getByTestId('step-content-3')).toBeVisible()
|
||||
await expect(page.getByTestId('gf-assignment-table')).toBeVisible()
|
||||
await page.getByTestId('next-step').click()
|
||||
|
||||
// --- Step 4: Kapital (Defaults: 25000) ---
|
||||
await expect(page.getByTestId('step-content-4')).toBeVisible()
|
||||
await expect(page.getByTestId('stammkapital')).toHaveValue('25000')
|
||||
await page.getByTestId('next-step').click()
|
||||
|
||||
// --- Step 5: Notar ---
|
||||
await expect(page.getByTestId('step-content-5')).toBeVisible()
|
||||
await page.getByTestId('notary-name').fill('Dr. Max Mustermann')
|
||||
await page.getByTestId('notary-place').fill('Stuttgart')
|
||||
await page.getByTestId('notary-address').fill('Königstraße 99, 70173 Stuttgart')
|
||||
await page.getByTestId('notarial-date').fill('2026-06-15')
|
||||
await page.getByTestId('next-step').click()
|
||||
|
||||
// --- Step 6: SHA-Optionen (Defaults sind ok) ---
|
||||
await expect(page.getByTestId('step-content-6')).toBeVisible()
|
||||
await expect(page.getByTestId('has-sha')).toBeChecked()
|
||||
await page.getByTestId('next-step').click()
|
||||
|
||||
// --- Step 7: GF-Vertraege (fuer jeden GF einen) ---
|
||||
await expect(page.getByTestId('step-content-7')).toBeVisible()
|
||||
// Beide GF-Contract-Karten muessen sichtbar sein
|
||||
const contractCards = page.locator('[data-testid^="contract-"]')
|
||||
await expect(contractCards).toHaveCount(2)
|
||||
// Salary in beiden Cards anfassen → registriert Contracts (canProceed-Bedingung).
|
||||
// Wir setzen einen anderen Wert als Default (84000) damit React onChange feuert.
|
||||
const salaryInputs = page.locator('[data-testid^="salary-"]')
|
||||
const salaryCount = await salaryInputs.count()
|
||||
for (let i = 0; i < salaryCount; i++) {
|
||||
await salaryInputs.nth(i).fill('90000')
|
||||
}
|
||||
// Warten bis "Weiter" enabled ist
|
||||
await expect(page.getByTestId('next-step')).toBeEnabled()
|
||||
await page.getByTestId('next-step').click()
|
||||
|
||||
// --- Step 8: Generate ---
|
||||
await expect(page.getByTestId('step-content-8')).toBeVisible()
|
||||
await expect(page.getByTestId('generate-summary')).toContainText('Breakpilot GmbH')
|
||||
await expect(page.getByTestId('generate-summary')).toContainText('2', { useInnerText: true })
|
||||
|
||||
// Notartermin-Bundle auswaehlen
|
||||
await page.getByTestId('select-notary-bundle').click()
|
||||
|
||||
// Generieren (Backend gemockt)
|
||||
await page.getByTestId('generate-docs').click()
|
||||
|
||||
// Generated-Docs-Block muss erscheinen
|
||||
await expect(page.getByTestId('generated-docs')).toBeVisible({ timeout: 10000 })
|
||||
|
||||
// Per-Person Verifikation: zwei IP-Assignment-Downloads erwartet
|
||||
const ipDownloads = page.locator('[data-testid="download-ip_assignment_agreement"]')
|
||||
await expect(ipDownloads).toHaveCount(2)
|
||||
|
||||
// Per-Person Verifikation: zwei GF-Vertraege erwartet
|
||||
const gfDownloads = page.locator('[data-testid="download-managing_director_employment_contract"]')
|
||||
await expect(gfDownloads).toHaveCount(2)
|
||||
|
||||
// Kein generate-error sichtbar
|
||||
await expect(page.getByTestId('generate-error')).toBeHidden()
|
||||
|
||||
// Final: keine Errors auf der Konsole
|
||||
errors.assertNoErrors()
|
||||
})
|
||||
|
||||
test('Prefill-Button setzt Fehler bei Backend-Fehler ohne Application-Error', async ({ page }) => {
|
||||
// Spezial-Mock: company-profile gibt 500 zurueck
|
||||
await page.route('**/api/sdk/v1/company-profile**', async route => {
|
||||
await route.fulfill({ status: 500, body: 'boom' })
|
||||
})
|
||||
|
||||
const errors = installErrorTraps(page)
|
||||
await page.goto(`${BASE}${WIZARD_PATH}`)
|
||||
|
||||
await page.getByRole('button', { name: /Aus Unternehmensprofil vorbef/i }).click()
|
||||
// UI muss Fehlermeldung anzeigen, NICHT crashen
|
||||
await expect(page.getByText('Konnte Unternehmensprofil nicht laden')).toBeVisible()
|
||||
|
||||
errors.assertNoErrors()
|
||||
})
|
||||
|
||||
test('Step-Navigation: Zurueck und Reset funktionieren ohne Errors', async ({ page }) => {
|
||||
const errors = installErrorTraps(page)
|
||||
await page.goto(`${BASE}${WIZARD_PATH}`)
|
||||
|
||||
// Minimum Step 1 fuellen
|
||||
await page.getByTestId('company-name').fill('Breakpilot GmbH')
|
||||
await page.getByTestId('company-seat').fill('Stuttgart')
|
||||
await page.getByTestId('company-purpose').fill('die Entwicklung von Compliance-Software fuer Unternehmen.')
|
||||
|
||||
await page.getByTestId('next-step').click()
|
||||
await expect(page.getByTestId('step-content-2')).toBeVisible()
|
||||
|
||||
// Zurueck
|
||||
await page.getByTestId('prev-step').click()
|
||||
await expect(page.getByTestId('step-content-1')).toBeVisible()
|
||||
|
||||
// Eingaben muessen erhalten geblieben sein (localStorage-persistence)
|
||||
await expect(page.getByTestId('company-name')).toHaveValue('Breakpilot GmbH')
|
||||
|
||||
// Reset (mit Dialog-Bestaetigung)
|
||||
page.once('dialog', dialog => dialog.accept())
|
||||
await page.getByTestId('reset-wizard').click()
|
||||
await expect(page.getByTestId('company-name')).toHaveValue('')
|
||||
|
||||
errors.assertNoErrors()
|
||||
})
|
||||
|
||||
test('IP-Areas + Rollen-Dropdown in Step 2', async ({ page }) => {
|
||||
const errors = installErrorTraps(page)
|
||||
await page.goto(`${BASE}${WIZARD_PATH}`)
|
||||
|
||||
// Step 1 zuegig fuellen
|
||||
await page.getByTestId('company-name').fill('Breakpilot GmbH')
|
||||
await page.getByTestId('company-seat').fill('Stuttgart')
|
||||
await page.getByTestId('company-purpose').fill('die Entwicklung von Compliance-Software fuer Unternehmen.')
|
||||
await page.getByTestId('next-step').click()
|
||||
|
||||
// Rollen-Dropdown muss ein <select> sein, nicht <input>
|
||||
const role = page.getByTestId('gs-role')
|
||||
await expect(role).toHaveJSProperty('tagName', 'SELECT')
|
||||
|
||||
// CEO-Option waehlbar
|
||||
await page.getByTestId('gs-name').fill('Benjamin Bönisch')
|
||||
await page.getByTestId('gs-address').fill('Test 1')
|
||||
await page.getByTestId('gs-nennbetrag').fill('25000')
|
||||
await role.selectOption('CEO')
|
||||
await page.getByTestId('gs-ip-areas').fill('Compliance-Engine\nRAG-Pipeline')
|
||||
await page.getByTestId('add-gesellschafter').click()
|
||||
|
||||
// Tabelle muss IP-Bereiche anzeigen
|
||||
const row = page.getByTestId('gs-row-1')
|
||||
await expect(row).toContainText('Benjamin Bönisch')
|
||||
await expect(row).toContainText('CEO')
|
||||
await expect(row).toContainText('Compliance-Engine')
|
||||
|
||||
errors.assertNoErrors()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Template-Kategorisierung als Code-Registry.
|
||||
*
|
||||
* Source-of-Truth bei aktiver Migration 137/138 ist die DB.
|
||||
* Diese Registry dient als Fallback und für Frontend-only Filter,
|
||||
* wenn DB-Felder noch nicht verfügbar sind (z.B. lokale Dev-DB ohne Migration).
|
||||
*
|
||||
* Synchron halten mit migrations/138_template_backfill_categories.sql.
|
||||
*/
|
||||
|
||||
export type LifecycleStage = 'pre_founding' | 'founding' | 'startup' | 'kmu' | 'konzern'
|
||||
|
||||
export type FunctionalCategory =
|
||||
| 'founding_legal'
|
||||
| 'employment'
|
||||
| 'investor_funding'
|
||||
| 'customer_b2b'
|
||||
| 'customer_b2c'
|
||||
| 'data_protection'
|
||||
| 'it_security'
|
||||
| 'ai_governance'
|
||||
| 'internal_policy'
|
||||
| 'public_facing'
|
||||
| 'compliance_process'
|
||||
| 'finance_tax'
|
||||
| 'vendor_supplier'
|
||||
|
||||
export interface TemplateCategorization {
|
||||
lifecycle_stage: LifecycleStage[]
|
||||
functional_category: FunctionalCategory
|
||||
}
|
||||
|
||||
export const TEMPLATE_CATEGORIES: Record<string, TemplateCategorization> = {
|
||||
// Founding Legal
|
||||
gesellschafterliste: { lifecycle_stage: ['pre_founding', 'founding'], functional_category: 'founding_legal' },
|
||||
gf_bestellungsbeschluss: { lifecycle_stage: ['founding'], functional_category: 'founding_legal' },
|
||||
hrb_anmeldung: { lifecycle_stage: ['founding'], functional_category: 'founding_legal' },
|
||||
ip_assignment_agreement: { lifecycle_stage: ['pre_founding', 'founding', 'startup'], functional_category: 'founding_legal' },
|
||||
articles_of_association: { lifecycle_stage: ['founding', 'startup', 'kmu', 'konzern'], functional_category: 'founding_legal' },
|
||||
sha: { lifecycle_stage: ['founding', 'startup', 'kmu', 'konzern'], functional_category: 'founding_legal' },
|
||||
geschaeftsordnung_gf: { lifecycle_stage: ['founding', 'startup', 'kmu', 'konzern'], functional_category: 'founding_legal' },
|
||||
|
||||
// Investor / Funding
|
||||
term_sheet: { lifecycle_stage: ['pre_founding', 'startup'], functional_category: 'investor_funding' },
|
||||
convertible_loan_agreement: { lifecycle_stage: ['pre_founding', 'startup'], functional_category: 'investor_funding' },
|
||||
subscription_agreement: { lifecycle_stage: ['startup', 'kmu'], functional_category: 'investor_funding' },
|
||||
esop_plan: { lifecycle_stage: ['startup', 'kmu'], functional_category: 'investor_funding' },
|
||||
cap_table: { lifecycle_stage: ['founding', 'startup', 'kmu', 'konzern'], functional_category: 'investor_funding' },
|
||||
|
||||
// Employment
|
||||
managing_director_employment_contract: { lifecycle_stage: ['founding', 'startup', 'kmu', 'konzern'], functional_category: 'employment' },
|
||||
employment_contract_de: { lifecycle_stage: ['founding', 'startup', 'kmu', 'konzern'], functional_category: 'employment' },
|
||||
nda: { lifecycle_stage: ['founding', 'startup', 'kmu', 'konzern'], functional_category: 'employment' },
|
||||
offboarding_policy: { lifecycle_stage: ['founding', 'startup', 'kmu', 'konzern'], functional_category: 'employment' },
|
||||
|
||||
// Customer B2B
|
||||
agb: { lifecycle_stage: ['startup', 'kmu', 'konzern'], functional_category: 'customer_b2b' },
|
||||
sla: { lifecycle_stage: ['startup', 'kmu', 'konzern'], functional_category: 'customer_b2b' },
|
||||
dpa: { lifecycle_stage: ['startup', 'kmu', 'konzern'], functional_category: 'customer_b2b' },
|
||||
data_processing_agreement: { lifecycle_stage: ['startup', 'kmu', 'konzern'], functional_category: 'customer_b2b' },
|
||||
cloud_service_agreement: { lifecycle_stage: ['startup', 'kmu', 'konzern'], functional_category: 'customer_b2b' },
|
||||
terms_of_service: { lifecycle_stage: ['startup', 'kmu', 'konzern'], functional_category: 'customer_b2b' },
|
||||
|
||||
// Public-facing
|
||||
impressum: { lifecycle_stage: ['founding', 'startup', 'kmu', 'konzern'], functional_category: 'public_facing' },
|
||||
|
||||
// AI Governance
|
||||
ai_usage_policy: { lifecycle_stage: ['startup', 'kmu', 'konzern'], functional_category: 'ai_governance' },
|
||||
|
||||
// Whistleblower nur ab KMU (>=50 MA)
|
||||
whistleblower_policy: { lifecycle_stage: ['kmu', 'konzern'], functional_category: 'internal_policy' },
|
||||
}
|
||||
|
||||
/**
|
||||
* Notartermin-Bundle: alle Dokumente die für die Gründung benötigt werden.
|
||||
* Investor-Dokumente sind separat (term_sheet, convertible_loan_agreement, etc.).
|
||||
*/
|
||||
export const NOTARY_BUNDLE_DOCUMENTS: string[] = [
|
||||
'articles_of_association', // Satzung — notariell beurkundet
|
||||
'gesellschafterliste', // Pflicht § 40 GmbHG
|
||||
'gf_bestellungsbeschluss', // Bestellung Geschäftsführer
|
||||
'hrb_anmeldung', // HRB-Anmeldung
|
||||
'sha', // optional parallel
|
||||
'geschaeftsordnung_gf', // intern, nach Notar
|
||||
'managing_director_employment_contract', // GF-Dienstverträge
|
||||
'ip_assignment_agreement', // Gründer-IP sichern
|
||||
]
|
||||
|
||||
export function getDocumentsForStage(stage: LifecycleStage): string[] {
|
||||
return Object.entries(TEMPLATE_CATEGORIES)
|
||||
.filter(([, cat]) => cat.lifecycle_stage.includes(stage))
|
||||
.map(([docType]) => docType)
|
||||
}
|
||||
|
||||
export function getDocumentsForCategory(category: FunctionalCategory): string[] {
|
||||
return Object.entries(TEMPLATE_CATEGORIES)
|
||||
.filter(([, cat]) => cat.functional_category === category)
|
||||
.map(([docType]) => docType)
|
||||
}
|
||||
|
||||
export const LIFECYCLE_STAGE_LABELS: Record<LifecycleStage, string> = {
|
||||
pre_founding: 'Vor-Gründung (Term Sheet, IP-Sicherung)',
|
||||
founding: 'Gründung (Notar)',
|
||||
startup: 'Startup (0-3 Jahre, <25 MA)',
|
||||
kmu: 'KMU (3+ Jahre, 25-250 MA)',
|
||||
konzern: 'Konzern (250+ MA)',
|
||||
}
|
||||
|
||||
export const FUNCTIONAL_CATEGORY_LABELS: Record<FunctionalCategory, string> = {
|
||||
founding_legal: 'Gründungsrechtliches',
|
||||
employment: 'Arbeitsverträge',
|
||||
investor_funding: 'Investor & Funding',
|
||||
customer_b2b: 'Kunden-Verträge (B2B)',
|
||||
customer_b2c: 'Kunden-Verträge (B2C)',
|
||||
data_protection: 'Datenschutz (DSGVO)',
|
||||
it_security: 'IT-Sicherheit',
|
||||
ai_governance: 'KI-Governance',
|
||||
internal_policy: 'Interne Richtlinien',
|
||||
public_facing: 'Öffentlich (Website)',
|
||||
compliance_process:'Compliance-Prozesse',
|
||||
finance_tax: 'Finanzen & Steuern',
|
||||
vendor_supplier: 'Lieferanten',
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* TypeScript-Datentypen für den Founding-Wizard.
|
||||
*
|
||||
* Die Wizard-Eingaben werden in localStorage gespeichert und beim Submit
|
||||
* an die document-generator API geschickt zur Template-Befüllung.
|
||||
*/
|
||||
|
||||
import type { LifecycleStage } from './template-categories'
|
||||
|
||||
export interface Gesellschafter {
|
||||
id: string
|
||||
rolle: 'founder' | 'investor' | 'family' | 'other'
|
||||
name: string
|
||||
geburtsdatum?: string // YYYY-MM-DD
|
||||
adresse: string
|
||||
email?: string
|
||||
/** Nennbetrag in EUR, z.B. 25000 */
|
||||
nennbetrag_eur: number
|
||||
/** Anteilsnummer beginnend bei 1 */
|
||||
anteil_nr: number
|
||||
/** prozentualer Anteil am Stammkapital (computed) */
|
||||
anteil_pct?: number
|
||||
is_geschaeftsfuehrer: boolean
|
||||
/** Bei GF: interne Rolle z.B. CEO/CTO */
|
||||
internal_role?: string
|
||||
/** Falls Gründer akademischen Hintergrund hat (Professur etc.) */
|
||||
has_academic_background?: boolean
|
||||
/** IP-Bereiche die der Gründer für die GmbH einbringt (z.B. ["Compliance-Engine", "RAG-Pipeline"]) */
|
||||
ip_areas?: string[]
|
||||
}
|
||||
|
||||
export interface NotarData {
|
||||
notary_name: string
|
||||
notary_place: string
|
||||
notary_address?: string
|
||||
notary_email?: string
|
||||
notarial_date?: string // YYYY-MM-DD, geplant
|
||||
urnr?: string // wird vom Notar vergeben
|
||||
}
|
||||
|
||||
export interface CompanyBasics {
|
||||
company_name: string
|
||||
legal_form: 'GmbH' | 'UG'
|
||||
company_seat: string // z.B. "Bietigheim-Bissingen"
|
||||
company_address: string
|
||||
company_purpose_description: string // Volltext für § 2 Satzung
|
||||
company_purpose_bullets: string[]
|
||||
industry: string
|
||||
business_year: string // z.B. "Kalenderjahr"
|
||||
has_research_focus: boolean
|
||||
/** Registergericht (z.B. "Amtsgericht Stuttgart"). Pflicht für HRB-Anmeldung. */
|
||||
register_court?: string
|
||||
/** HRB-Nummer (z.B. "HRB 12345"). Leer falls noch nicht eingetragen. */
|
||||
hrb_number?: string
|
||||
}
|
||||
|
||||
export interface CapitalConfig {
|
||||
stammkapital_eur: number // z.B. 25000
|
||||
einlage_method: 'Geld' | 'Sacheinlage' | 'Geld und Sacheinlage'
|
||||
einlage_quote_initial_pct: number // z.B. 50 oder 100
|
||||
has_sacheinlage: boolean
|
||||
}
|
||||
|
||||
export interface SHAConfig {
|
||||
has_sha: boolean
|
||||
vesting_months: number // Standard 48
|
||||
cliff_months: number // Standard 12
|
||||
drag_along_threshold_pct: number // Standard 75
|
||||
tag_along_threshold_pct: number // Standard 20
|
||||
reserved_matters_majority_pct: number // Standard 75
|
||||
has_beirat: boolean
|
||||
has_texas_shootout: boolean
|
||||
has_ceo_designation: boolean
|
||||
ceo_name?: string // ref to gesellschafter.name
|
||||
esop_pool_pct: number // Standard 0 oder 10
|
||||
}
|
||||
|
||||
export interface GFContract {
|
||||
gesellschafter_id: string // ref to gesellschafter.id
|
||||
gross_annual_salary_eur: number
|
||||
has_bonus: boolean
|
||||
has_company_car: boolean
|
||||
has_bav: boolean
|
||||
vacation_days: number // Standard 30
|
||||
kuendigungsfrist_gesellschaft_monate: number // Standard 6
|
||||
kuendigungsfrist_gf_monate: number // Standard 3
|
||||
para_181_release: boolean
|
||||
sv_status: 'sozialversicherungsfrei' | 'sozialversicherungspflichtig' | 'noch zu klären'
|
||||
}
|
||||
|
||||
/**
|
||||
* Vollständiger Wizard-State.
|
||||
* Wird Step-by-Step befüllt, in localStorage gespeichert,
|
||||
* und beim Submit an /api/v1/founding-wizard/generate geschickt.
|
||||
*/
|
||||
export interface FoundingWizardState {
|
||||
/** Aktueller Step (1-8) */
|
||||
current_step: number
|
||||
/** Lifecycle-Stage Auswahl (default: founding) */
|
||||
lifecycle_stage: LifecycleStage
|
||||
|
||||
// Step 1: Lifecycle
|
||||
is_pre_notary: boolean
|
||||
|
||||
// Step 2: Basics
|
||||
basics: CompanyBasics
|
||||
|
||||
// Step 3: Gesellschafter
|
||||
gesellschafter: Gesellschafter[]
|
||||
|
||||
// Step 4: Kapital
|
||||
capital: CapitalConfig
|
||||
|
||||
// Step 5: Notar
|
||||
notar: NotarData
|
||||
|
||||
// Step 6: SHA-Konfiguration
|
||||
sha: SHAConfig
|
||||
|
||||
// Step 7: GF-Verträge (1 pro GF)
|
||||
gf_contracts: GFContract[]
|
||||
|
||||
// Step 8: Auswahl der zu generierenden Dokumente
|
||||
selected_documents: string[]
|
||||
|
||||
/** Output nach Submit: URL + Dateiname pro generiertem Dokument */
|
||||
generated_documents?: GeneratedDocument[]
|
||||
}
|
||||
|
||||
export interface GeneratedDocument {
|
||||
document_type: string
|
||||
title: string
|
||||
download_url: string
|
||||
size_bytes: number
|
||||
generated_at: string
|
||||
}
|
||||
|
||||
/** Default-State für einen frischen Wizard */
|
||||
export function defaultFoundingWizardState(): FoundingWizardState {
|
||||
return {
|
||||
current_step: 1,
|
||||
lifecycle_stage: 'founding',
|
||||
is_pre_notary: true,
|
||||
basics: {
|
||||
company_name: '',
|
||||
legal_form: 'GmbH',
|
||||
company_seat: '',
|
||||
company_address: '',
|
||||
company_purpose_description: '',
|
||||
company_purpose_bullets: [],
|
||||
industry: '',
|
||||
business_year: 'Kalenderjahr',
|
||||
has_research_focus: false,
|
||||
register_court: '',
|
||||
hrb_number: '',
|
||||
},
|
||||
gesellschafter: [],
|
||||
capital: {
|
||||
stammkapital_eur: 25000,
|
||||
einlage_method: 'Geld',
|
||||
einlage_quote_initial_pct: 50,
|
||||
has_sacheinlage: false,
|
||||
},
|
||||
notar: {
|
||||
notary_name: '',
|
||||
notary_place: '',
|
||||
},
|
||||
sha: {
|
||||
has_sha: true,
|
||||
vesting_months: 48,
|
||||
cliff_months: 12,
|
||||
drag_along_threshold_pct: 75,
|
||||
tag_along_threshold_pct: 20,
|
||||
reserved_matters_majority_pct: 75,
|
||||
has_beirat: false,
|
||||
has_texas_shootout: false,
|
||||
has_ceo_designation: false,
|
||||
esop_pool_pct: 0,
|
||||
},
|
||||
gf_contracts: [],
|
||||
selected_documents: [
|
||||
'articles_of_association',
|
||||
'gesellschafterliste',
|
||||
'gf_bestellungsbeschluss',
|
||||
'hrb_anmeldung',
|
||||
'sha',
|
||||
'geschaeftsordnung_gf',
|
||||
'managing_director_employment_contract',
|
||||
'ip_assignment_agreement',
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* Playwright E2E-Test: Founding-Wizard mit 2-Mann GmbH (Benjamin Bönisch + Sharang Parnerkar).
|
||||
*
|
||||
* Test-Flow:
|
||||
* 1. Lokale Dev-URL aufrufen
|
||||
* 2. Wizard durch alle 8 Steps befüllen
|
||||
* 3. Dokumente generieren (8 Stück für Notartermin-Bundle)
|
||||
* 4. Word-Download-Links validieren
|
||||
*
|
||||
* Voraussetzung: `npm run dev` läuft auf http://localhost:3007
|
||||
* Backend ist erreichbar (mit Migration 137 + 138 + Templates 123–136)
|
||||
*
|
||||
* Ausführen:
|
||||
* cd admin-compliance
|
||||
* npx playwright test tests/playwright/founding-wizard/
|
||||
*/
|
||||
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
const BASE_URL = process.env.WIZARD_URL || 'http://localhost:3007/sdk/founding-wizard'
|
||||
|
||||
const TEST_DATA = {
|
||||
basics: {
|
||||
company_name: 'Breakpilot GmbH',
|
||||
company_seat: 'Bietigheim-Bissingen',
|
||||
company_address: 'Hauptstraße 1, 74321 Bietigheim-Bissingen',
|
||||
industry: 'Software / KI / SaaS',
|
||||
purpose: 'die Entwicklung, Bereitstellung und der Vertrieb von Softwarelösungen, Plattformen und IT-Dienstleistungen im Bereich der Künstlichen Intelligenz sowie compliance-bezogener Datenverarbeitungssysteme',
|
||||
bullets: [
|
||||
'a) Entwicklung, Programmierung und Betrieb von KI-gestützter Compliance-Software',
|
||||
'b) Bereitstellung von datenschutzkonformen SaaS-Lösungen für Unternehmen',
|
||||
'c) Beratungs- und Integrationsleistungen im Compliance-Umfeld',
|
||||
],
|
||||
},
|
||||
notar: {
|
||||
name: 'Dr. Müller',
|
||||
place: 'Stuttgart',
|
||||
address: 'Königstraße 1, 70173 Stuttgart',
|
||||
date: '2026-06-15',
|
||||
},
|
||||
gesellschafter: [
|
||||
{
|
||||
name: 'Benjamin Bönisch',
|
||||
birthdate: '1980-03-15',
|
||||
address: 'Hauptstraße 1, 74321 Bietigheim-Bissingen',
|
||||
email: 'benjamin@breakpilot.ai',
|
||||
nennbetrag: 12500,
|
||||
is_gf: true,
|
||||
role: 'CEO',
|
||||
},
|
||||
{
|
||||
name: 'Sharang Parnerkar',
|
||||
birthdate: '1985-09-22',
|
||||
address: 'Hauptstraße 2, 74321 Bietigheim-Bissingen',
|
||||
email: 'sharang@breakpilot.ai',
|
||||
nennbetrag: 12500,
|
||||
is_gf: true,
|
||||
role: 'CTO',
|
||||
},
|
||||
],
|
||||
stammkapital: 25000,
|
||||
}
|
||||
|
||||
test.describe('Founding Wizard — 2-Mann GmbH', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Clear localStorage to start fresh
|
||||
await page.goto(BASE_URL)
|
||||
await page.evaluate(() => localStorage.clear())
|
||||
await page.reload()
|
||||
})
|
||||
|
||||
test('füllt komplette 2-Mann GmbH aus und generiert Notartermin-Bundle', async ({ page }) => {
|
||||
await page.goto(BASE_URL)
|
||||
await expect(page.getByTestId('founding-wizard')).toBeVisible()
|
||||
|
||||
// STEP 1: Basics
|
||||
await expect(page.getByTestId('step-content-1')).toBeVisible()
|
||||
await page.getByTestId('company-name').fill(TEST_DATA.basics.company_name)
|
||||
await page.getByTestId('legal-form').selectOption('GmbH')
|
||||
await page.getByTestId('company-seat').fill(TEST_DATA.basics.company_seat)
|
||||
await page.getByTestId('company-address').fill(TEST_DATA.basics.company_address)
|
||||
await page.getByTestId('industry').fill(TEST_DATA.basics.industry)
|
||||
await page.getByTestId('company-purpose').fill(TEST_DATA.basics.purpose)
|
||||
await page.getByTestId('company-purpose-bullets').fill(TEST_DATA.basics.bullets.join('\n'))
|
||||
await page.getByTestId('next-step').click()
|
||||
|
||||
// STEP 2: Gesellschafter
|
||||
await expect(page.getByTestId('step-content-2')).toBeVisible()
|
||||
for (const gs of TEST_DATA.gesellschafter) {
|
||||
await page.getByTestId('gs-name').fill(gs.name)
|
||||
await page.getByTestId('gs-birthdate').fill(gs.birthdate)
|
||||
await page.getByTestId('gs-address').fill(gs.address)
|
||||
await page.getByTestId('gs-email').fill(gs.email)
|
||||
await page.getByTestId('gs-nennbetrag').fill(String(gs.nennbetrag))
|
||||
await page.getByTestId('gs-role').fill(gs.role)
|
||||
// is_gf bereits default true, nichts zu tun
|
||||
await page.getByTestId('add-gesellschafter').click()
|
||||
}
|
||||
await expect(page.getByTestId('gs-row-1')).toContainText('Benjamin Bönisch')
|
||||
await expect(page.getByTestId('gs-row-2')).toContainText('Sharang Parnerkar')
|
||||
await expect(page.getByTestId('gs-total')).toContainText('25.000')
|
||||
await page.getByTestId('next-step').click()
|
||||
|
||||
// STEP 3: GF-Assignment (beide bereits GF aus Step 2)
|
||||
await expect(page.getByTestId('step-content-3')).toBeVisible()
|
||||
await page.getByTestId('next-step').click()
|
||||
|
||||
// STEP 4: Kapital
|
||||
await expect(page.getByTestId('step-content-4')).toBeVisible()
|
||||
await expect(page.getByTestId('stammkapital')).toHaveValue('25000')
|
||||
await page.getByTestId('einlage-method').selectOption('Geld')
|
||||
await page.getByTestId('einlage-quote').fill('50')
|
||||
await page.getByTestId('next-step').click()
|
||||
|
||||
// STEP 5: Notar
|
||||
await expect(page.getByTestId('step-content-5')).toBeVisible()
|
||||
await page.getByTestId('notary-name').fill(TEST_DATA.notar.name)
|
||||
await page.getByTestId('notary-place').fill(TEST_DATA.notar.place)
|
||||
await page.getByTestId('notary-address').fill(TEST_DATA.notar.address)
|
||||
await page.getByTestId('notarial-date').fill(TEST_DATA.notar.date)
|
||||
await page.getByTestId('next-step').click()
|
||||
|
||||
// STEP 6: SHA-Optionen
|
||||
await expect(page.getByTestId('step-content-6')).toBeVisible()
|
||||
await expect(page.getByTestId('has-sha')).toBeChecked()
|
||||
await expect(page.getByTestId('vesting-months')).toHaveValue('48')
|
||||
await expect(page.getByTestId('drag-along-pct')).toHaveValue('75')
|
||||
await page.getByTestId('next-step').click()
|
||||
|
||||
// STEP 7: GF-Verträge (für beide Founders)
|
||||
await expect(page.getByTestId('step-content-7')).toBeVisible()
|
||||
// GF-Contracts werden mit Defaults erzeugt sobald GFs definiert sind -
|
||||
// wir editieren die Gehälter
|
||||
const contracts = page.locator('[data-testid^="contract-"]')
|
||||
const count = await contracts.count()
|
||||
expect(count).toBe(2)
|
||||
await page.getByTestId('next-step').click()
|
||||
|
||||
// STEP 8: Generate
|
||||
await expect(page.getByTestId('step-content-8')).toBeVisible()
|
||||
await expect(page.getByTestId('generate-summary')).toContainText('Breakpilot GmbH')
|
||||
await expect(page.getByTestId('generate-summary')).toContainText('Bietigheim-Bissingen')
|
||||
await expect(page.getByTestId('generate-summary')).toContainText('25.000')
|
||||
|
||||
// Notartermin-Bundle auswählen
|
||||
await page.getByTestId('select-notary-bundle').click()
|
||||
// Check that bundle items are selected
|
||||
await expect(page.getByTestId('doc-articles_of_association')).toBeChecked()
|
||||
await expect(page.getByTestId('doc-sha')).toBeChecked()
|
||||
await expect(page.getByTestId('doc-gesellschafterliste')).toBeChecked()
|
||||
await expect(page.getByTestId('doc-managing_director_employment_contract')).toBeChecked()
|
||||
|
||||
// Generate
|
||||
await page.getByTestId('generate-docs').click()
|
||||
|
||||
// Warten auf Generierung (max 30s)
|
||||
await expect(page.getByTestId('generated-docs')).toBeVisible({ timeout: 30000 })
|
||||
|
||||
// Mindestens 8 Dokumente sollten erscheinen (für 2 Founders evtl. doppelt: GF-Vertrag, IP-Assignment)
|
||||
const downloadLinks = page.locator('[data-testid^="download-"]')
|
||||
const linkCount = await downloadLinks.count()
|
||||
expect(linkCount).toBeGreaterThanOrEqual(8)
|
||||
|
||||
// Validiere dass download-URLs data: URLs sind (base64 DOCX)
|
||||
for (let i = 0; i < Math.min(linkCount, 3); i++) {
|
||||
const href = await downloadLinks.nth(i).getAttribute('href')
|
||||
expect(href).toMatch(/^data:application\/vnd\.openxmlformats-officedocument\.wordprocessingml\.document;base64,/)
|
||||
}
|
||||
|
||||
// Screenshot fürs Test-Artifact
|
||||
await page.screenshot({ path: 'test-results/founding-wizard-final.png', fullPage: true })
|
||||
})
|
||||
|
||||
test('zeigt Validierung wenn Pflichtfelder fehlen', async ({ page }) => {
|
||||
await page.goto(BASE_URL)
|
||||
// Next-Button sollte disabled sein wenn nichts ausgefüllt
|
||||
await expect(page.getByTestId('next-step')).toBeDisabled()
|
||||
|
||||
await page.getByTestId('company-name').fill('Test')
|
||||
// Immer noch disabled weil purpose fehlt
|
||||
await expect(page.getByTestId('next-step')).toBeDisabled()
|
||||
|
||||
await page.getByTestId('company-seat').fill('Stuttgart')
|
||||
await page.getByTestId('company-purpose').fill('Eine lange genug Beschreibung des Zwecks.')
|
||||
// Jetzt sollte er enabled sein
|
||||
await expect(page.getByTestId('next-step')).toBeEnabled()
|
||||
})
|
||||
|
||||
test('Reset löscht alle Daten', async ({ page }) => {
|
||||
await page.goto(BASE_URL)
|
||||
await page.getByTestId('company-name').fill('Wird gelöscht GmbH')
|
||||
page.on('dialog', d => d.accept())
|
||||
await page.getByTestId('reset-wizard').click()
|
||||
await expect(page.getByTestId('company-name')).toHaveValue('')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,241 @@
|
||||
// Command iace-audit runs static and runtime audits on the IACE pattern
|
||||
// engine to find gaps without a ground-truth reference.
|
||||
//
|
||||
// Subcommands:
|
||||
//
|
||||
// reachability — Method A: which patterns can never fire given the library?
|
||||
// consistency — Method B: do components cover their TypicalHazardCategories?
|
||||
// vocabulary — Method C: which limits-form words are unknown to the dict?
|
||||
// echo — Method D: which limits-form sentences have no hazard echo?
|
||||
// hierarchy — Method E: which hazards lack design/protection/information?
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/iace/audit"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
usage()
|
||||
os.Exit(2)
|
||||
}
|
||||
switch os.Args[1] {
|
||||
case "reachability":
|
||||
cmdReachability(os.Args[2:])
|
||||
case "consistency":
|
||||
cmdConsistency(os.Args[2:])
|
||||
case "vocabulary":
|
||||
cmdVocabulary(os.Args[2:])
|
||||
case "echo":
|
||||
cmdEcho(os.Args[2:])
|
||||
case "hierarchy":
|
||||
cmdHierarchy(os.Args[2:])
|
||||
default:
|
||||
usage()
|
||||
os.Exit(2)
|
||||
}
|
||||
}
|
||||
|
||||
func usage() {
|
||||
fmt.Fprintln(os.Stderr, "Usage: iace-audit <reachability|consistency|vocabulary|echo|hierarchy> [args]")
|
||||
}
|
||||
|
||||
func cmdReachability(_ []string) {
|
||||
r := audit.RunReachability()
|
||||
printSummary(fmt.Sprintf("Method A — Pattern Reachability"), map[string]int{
|
||||
"total": r.TotalPatterns,
|
||||
"reachable": r.Reachable,
|
||||
"weakly_reachable": r.WeaklyReachable,
|
||||
"unreachable": r.Unreachable,
|
||||
"universe_tags": len(r.UniverseTags),
|
||||
})
|
||||
if len(r.UnreachablePatterns) > 0 {
|
||||
fmt.Println("\n## Unreachable patterns (top 30 by priority):\n")
|
||||
printPatternRows(r.UnreachablePatterns, 30)
|
||||
}
|
||||
if len(r.WeakPatterns) > 0 {
|
||||
fmt.Println("\n## Weakly reachable (top 20 by priority):\n")
|
||||
printPatternRows(r.WeakPatterns, 20)
|
||||
}
|
||||
writeJSON("audit-reports/reachability.json", r)
|
||||
}
|
||||
|
||||
func cmdConsistency(_ []string) {
|
||||
r := audit.RunConsistency()
|
||||
printSummary("Method B — Component Self-Consistency", map[string]int{
|
||||
"total_components": r.TotalComponents,
|
||||
"consistent": r.Consistent,
|
||||
"incomplete": r.Incomplete,
|
||||
})
|
||||
if len(r.IncompleteComponents) > 0 {
|
||||
fmt.Println("\n## Components missing tags for declared hazard categories:\n")
|
||||
for _, c := range r.IncompleteComponents {
|
||||
fmt.Printf("- %s (%s)\n", c.ComponentID, c.NameDE)
|
||||
for _, miss := range c.MissingForCategories {
|
||||
fmt.Printf(" %s: no pattern fires (suggest tags: %s)\n", miss.Category, joinFirst(miss.SuggestedTags, 5))
|
||||
}
|
||||
}
|
||||
}
|
||||
writeJSON("audit-reports/consistency.json", r)
|
||||
}
|
||||
|
||||
func cmdVocabulary(args []string) {
|
||||
if len(args) < 1 {
|
||||
fmt.Fprintln(os.Stderr, "vocabulary: missing path to limits-form JSON")
|
||||
os.Exit(2)
|
||||
}
|
||||
data, err := os.ReadFile(args[0])
|
||||
must(err)
|
||||
var form map[string]any
|
||||
must(json.Unmarshal(data, &form))
|
||||
r := audit.RunVocabulary(form)
|
||||
printSummary("Method C — Vocabulary Diff", map[string]int{
|
||||
"unique_tokens": r.UniqueTokens,
|
||||
"unknown_tokens": len(r.UnknownTokens),
|
||||
"unknown_with_pattern_hit": len(r.SuggestedDictionaryEntries),
|
||||
})
|
||||
if len(r.SuggestedDictionaryEntries) > 0 {
|
||||
fmt.Println("\n## Suggested dictionary additions (token appears in pattern scenarios but not in dict):\n")
|
||||
for _, s := range r.SuggestedDictionaryEntries {
|
||||
fmt.Printf("- '%s' → seen in %d patterns. Examples: %s\n", s.Token, len(s.PatternIDs), joinFirst(s.PatternIDs, 5))
|
||||
}
|
||||
}
|
||||
writeJSON("audit-reports/vocabulary.json", r)
|
||||
}
|
||||
|
||||
func cmdEcho(args []string) {
|
||||
if len(args) < 2 {
|
||||
fmt.Fprintln(os.Stderr, "echo: usage: iace-audit echo <limits-form.json> <hazards.json>")
|
||||
os.Exit(2)
|
||||
}
|
||||
limitsData, err := os.ReadFile(args[0])
|
||||
must(err)
|
||||
hazardsData, err := os.ReadFile(args[1])
|
||||
must(err)
|
||||
var form map[string]any
|
||||
must(json.Unmarshal(limitsData, &form))
|
||||
var hwrap struct {
|
||||
Hazards []map[string]any `json:"hazards"`
|
||||
}
|
||||
must(json.Unmarshal(hazardsData, &hwrap))
|
||||
r := audit.RunEcho(form, hwrap.Hazards)
|
||||
printSummary("Method D — Limits-Form Echo", map[string]int{
|
||||
"total_phrases": r.TotalPhrases,
|
||||
"echoed": r.Echoed,
|
||||
"orphaned": r.Orphaned,
|
||||
})
|
||||
if len(r.OrphanedPhrases) > 0 {
|
||||
fmt.Println("\n## Orphaned phrases (no hazard echoes them):\n")
|
||||
for _, o := range r.OrphanedPhrases {
|
||||
fmt.Printf("- [%s] %s\n", o.Field, truncate(o.Phrase, 120))
|
||||
}
|
||||
}
|
||||
writeJSON("audit-reports/echo.json", r)
|
||||
}
|
||||
|
||||
func cmdHierarchy(args []string) {
|
||||
if len(args) < 2 {
|
||||
fmt.Fprintln(os.Stderr, "hierarchy: usage: iace-audit hierarchy <hazards.json> <mitigations.json>")
|
||||
os.Exit(2)
|
||||
}
|
||||
hData, err := os.ReadFile(args[0])
|
||||
must(err)
|
||||
mData, err := os.ReadFile(args[1])
|
||||
must(err)
|
||||
var hwrap struct {
|
||||
Hazards []map[string]any `json:"hazards"`
|
||||
}
|
||||
must(json.Unmarshal(hData, &hwrap))
|
||||
var mwrap struct {
|
||||
Mitigations []map[string]any `json:"mitigations"`
|
||||
}
|
||||
must(json.Unmarshal(mData, &mwrap))
|
||||
r := audit.RunHierarchy(hwrap.Hazards, mwrap.Mitigations)
|
||||
printSummary("Method E — Hierarchy Completeness", map[string]int{
|
||||
"total_hazards": r.TotalHazards,
|
||||
"complete": r.Complete,
|
||||
"missing_design": r.MissingDesign,
|
||||
"missing_protection": r.MissingProtection,
|
||||
"missing_info": r.MissingInfo,
|
||||
})
|
||||
if len(r.IncompleteHazards) > 0 {
|
||||
fmt.Println("\n## Hazards with incomplete hierarchy:\n")
|
||||
for _, h := range r.IncompleteHazards {
|
||||
fmt.Printf("- [%s] %s — missing: %s\n", h.Category, truncate(h.Name, 70), joinFirst(h.MissingLevels, 3))
|
||||
}
|
||||
}
|
||||
writeJSON("audit-reports/hierarchy.json", r)
|
||||
}
|
||||
|
||||
func printSummary(title string, kv map[string]int) {
|
||||
fmt.Println("=", title, "=")
|
||||
for k, v := range kv {
|
||||
fmt.Printf(" %-22s %d\n", k, v)
|
||||
}
|
||||
}
|
||||
|
||||
func printPatternRows(rows []audit.ReachabilityResult, max int) {
|
||||
if max > len(rows) {
|
||||
max = len(rows)
|
||||
}
|
||||
for i := 0; i < max; i++ {
|
||||
r := rows[i]
|
||||
fmt.Printf("- %s (P%d) %s\n", r.PatternID, r.Priority, truncate(r.Name, 60))
|
||||
if len(r.UnreachableTags) > 0 {
|
||||
fmt.Printf(" missing tags: %s\n", joinFirst(r.UnreachableTags, 8))
|
||||
}
|
||||
for _, s := range r.FixSuggestions {
|
||||
fmt.Printf(" fix: %s\n", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func writeJSON(path string, v any) {
|
||||
_ = os.MkdirAll("audit-reports", 0o755)
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "warn: could not write report:", err)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
enc := json.NewEncoder(f)
|
||||
enc.SetIndent("", " ")
|
||||
_ = enc.Encode(v)
|
||||
fmt.Println("→ wrote", path)
|
||||
}
|
||||
|
||||
func must(err error) {
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "…"
|
||||
}
|
||||
|
||||
func joinFirst(list []string, n int) string {
|
||||
if len(list) <= n {
|
||||
return join(list)
|
||||
}
|
||||
return join(list[:n]) + ", …"
|
||||
}
|
||||
|
||||
func join(list []string) string {
|
||||
out := ""
|
||||
for i, s := range list {
|
||||
if i > 0 {
|
||||
out += ", "
|
||||
}
|
||||
out += s
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"sort"
|
||||
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/iace"
|
||||
)
|
||||
|
||||
// runConsistencyImpl asks: does this component, with its own tags PLUS the
|
||||
// tags of its TypicalEnergySources, actually trigger at least one pattern
|
||||
// in every category listed in its TypicalHazardCategories?
|
||||
//
|
||||
// A component declares "this is what I am dangerous for" and the engine
|
||||
// turns that declaration into hazards through patterns. If no pattern can
|
||||
// fire from the component's tag set, the declaration is decorative — the
|
||||
// engine will never produce a hazard in that category for this component,
|
||||
// even though the library author said it should.
|
||||
func init() {
|
||||
runConsistencyImpl = runConsistency
|
||||
}
|
||||
|
||||
func runConsistency() ConsistencyReport {
|
||||
comps := iace.GetComponentLibrary()
|
||||
energies := iace.GetEnergySources()
|
||||
patterns := iace.AllPatterns()
|
||||
|
||||
energyByID := map[string]iace.EnergySourceEntry{}
|
||||
for _, e := range energies {
|
||||
energyByID[e.ID] = e
|
||||
}
|
||||
|
||||
report := ConsistencyReport{TotalComponents: len(comps)}
|
||||
|
||||
for _, c := range comps {
|
||||
if len(c.TypicalHazardCategories) == 0 {
|
||||
report.Consistent++
|
||||
continue
|
||||
}
|
||||
effective := buildEffectiveTags(c, energyByID)
|
||||
covered := categoriesCoveredByPatterns(effective, c.MapsToComponentType, patterns)
|
||||
|
||||
var missing []string
|
||||
for _, cat := range c.TypicalHazardCategories {
|
||||
if !covered[cat] {
|
||||
missing = append(missing, cat)
|
||||
}
|
||||
}
|
||||
if len(missing) == 0 {
|
||||
report.Consistent++
|
||||
continue
|
||||
}
|
||||
|
||||
result := ComponentResult{
|
||||
ComponentID: c.ID,
|
||||
NameDE: c.NameDE,
|
||||
DeclaredCategories: c.TypicalHazardCategories,
|
||||
}
|
||||
for cat := range covered {
|
||||
result.CoveredCategories = append(result.CoveredCategories, cat)
|
||||
}
|
||||
sort.Strings(result.CoveredCategories)
|
||||
for _, cat := range missing {
|
||||
result.MissingForCategories = append(result.MissingForCategories, CategoryGap{
|
||||
Category: cat,
|
||||
SuggestedTags: suggestTagsForCategory(cat, effective, patterns),
|
||||
})
|
||||
}
|
||||
report.Incomplete++
|
||||
report.IncompleteComponents = append(report.IncompleteComponents, result)
|
||||
}
|
||||
|
||||
sort.Slice(report.IncompleteComponents, func(i, j int) bool {
|
||||
return report.IncompleteComponents[i].ComponentID < report.IncompleteComponents[j].ComponentID
|
||||
})
|
||||
return report
|
||||
}
|
||||
|
||||
func buildEffectiveTags(c iace.ComponentLibraryEntry, energyByID map[string]iace.EnergySourceEntry) map[string]bool {
|
||||
set := map[string]bool{}
|
||||
for _, t := range c.Tags {
|
||||
set[t] = true
|
||||
}
|
||||
for _, eID := range c.TypicalEnergySources {
|
||||
e, ok := energyByID[eID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, t := range e.Tags {
|
||||
set[t] = true
|
||||
}
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
// categoriesCoveredByPatterns iterates patterns and finds which
|
||||
// GeneratedHazardCats can fire given the component's effective tags.
|
||||
// We ignore lifecycle, op-state, and human-role filters — those are
|
||||
// project-level. The audit asks "can the library produce ANY hazard in
|
||||
// this category for this component if the project configures everything
|
||||
// reasonably?"
|
||||
func categoriesCoveredByPatterns(tags map[string]bool, _ string, patterns []iace.HazardPattern) map[string]bool {
|
||||
covered := map[string]bool{}
|
||||
for _, p := range patterns {
|
||||
if !tagsCover(tags, p.RequiredComponentTags) {
|
||||
continue
|
||||
}
|
||||
if !tagsCover(tags, p.RequiredEnergyTags) {
|
||||
continue
|
||||
}
|
||||
for _, cat := range p.GeneratedHazardCats {
|
||||
covered[cat] = true
|
||||
}
|
||||
}
|
||||
return covered
|
||||
}
|
||||
|
||||
func tagsCover(have map[string]bool, required []string) bool {
|
||||
for _, t := range required {
|
||||
if !have[t] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// suggestTagsForCategory looks at patterns that DO generate this category
|
||||
// and identifies the tags that would close the gap. Returns the tags most
|
||||
// commonly required by patterns in that category, minus what the component
|
||||
// already has.
|
||||
func suggestTagsForCategory(cat string, have map[string]bool, patterns []iace.HazardPattern) []string {
|
||||
counts := map[string]int{}
|
||||
for _, p := range patterns {
|
||||
matchCat := false
|
||||
for _, c := range p.GeneratedHazardCats {
|
||||
if c == cat {
|
||||
matchCat = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !matchCat {
|
||||
continue
|
||||
}
|
||||
for _, t := range p.RequiredComponentTags {
|
||||
if !have[t] {
|
||||
counts[t]++
|
||||
}
|
||||
}
|
||||
for _, t := range p.RequiredEnergyTags {
|
||||
if !have[t] {
|
||||
counts[t]++
|
||||
}
|
||||
}
|
||||
}
|
||||
type kv struct {
|
||||
tag string
|
||||
n int
|
||||
}
|
||||
var sorted []kv
|
||||
for t, n := range counts {
|
||||
sorted = append(sorted, kv{t, n})
|
||||
}
|
||||
sort.Slice(sorted, func(i, j int) bool { return sorted[i].n > sorted[j].n })
|
||||
var out []string
|
||||
for i, s := range sorted {
|
||||
if i >= 6 {
|
||||
break
|
||||
}
|
||||
out = append(out, s.tag)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// runEchoImpl checks if each meaningful phrase from the limits-form is
|
||||
// echoed by at least one generated hazard. A phrase that names a concrete
|
||||
// scenario, fault, or constraint must reappear (semantically) in some
|
||||
// hazard's name, scenario, or description. Phrases without echo are gaps:
|
||||
// the engineer documented the risk but the engine never lifted it into
|
||||
// the hazard register.
|
||||
//
|
||||
// Echo detection here is a lightweight Jaccard overlap of content tokens
|
||||
// (not embeddings) — robust enough for the demonstrative diagnostic and
|
||||
// keeps the audit fully deterministic without an external model. The
|
||||
// caller can later swap in a vector-based scorer.
|
||||
func init() {
|
||||
runEchoImpl = runEcho
|
||||
}
|
||||
|
||||
// Significant limits-form fields. Each item is (key, label). We only
|
||||
// audit the freeform fields where engineers describe risks — list/enum
|
||||
// fields (operating_modes, person_groups, industry_sectors) are out of
|
||||
// scope because they carry no narrative phrases.
|
||||
var echoFields = []struct {
|
||||
key string
|
||||
label string
|
||||
}{
|
||||
{"general_description", "Allg. Beschreibung"},
|
||||
{"intended_purpose", "Bestimmungsgemaesse Verwendung"},
|
||||
{"variants", "Varianten"},
|
||||
{"foreseeable_misuses", "Vorhersehbare Fehlanwendung"},
|
||||
{"spatial_limits", "Raeumliche Grenzen"},
|
||||
{"temporal_limits", "Zeitliche Grenzen"},
|
||||
{"operating_conditions", "Betriebsbedingungen"},
|
||||
{"energy_supply", "Energieversorgung"},
|
||||
{"mechanical_interfaces", "Mechanische Schnittstellen"},
|
||||
{"electrical_interfaces", "Elektrische Schnittstellen"},
|
||||
{"software_interfaces", "Software-Schnittstellen"},
|
||||
{"pneumatic_hydraulic_interfaces", "Pneumatik/Hydraulik"},
|
||||
{"qualification_requirements", "Personenqualifikation"},
|
||||
}
|
||||
|
||||
var sentenceSplit = regexp.MustCompile(`[.!?]\s+|\n+`)
|
||||
var wordRE = regexp.MustCompile(`[a-zäöüßA-ZÄÖÜ]{4,}`)
|
||||
|
||||
// echoThreshold — minimum Jaccard overlap (between sentence content
|
||||
// tokens and a hazard's content tokens) above which the sentence is
|
||||
// considered echoed. Tuned by hand to give meaningful results without a
|
||||
// labeled corpus; the audit reports the actual best score for each
|
||||
// orphaned phrase so a human can re-tune if needed.
|
||||
const echoThreshold = 0.18
|
||||
|
||||
func runEcho(form map[string]any, hazards []map[string]any) EchoReport {
|
||||
limits := unwrapLimits(form)
|
||||
|
||||
// Precompute hazard token bags once
|
||||
type bag struct {
|
||||
tokens map[string]bool
|
||||
text string
|
||||
}
|
||||
var hazardBags []bag
|
||||
for _, h := range hazards {
|
||||
txt := joinHazardText(h)
|
||||
toks := contentTokenSet(txt)
|
||||
hazardBags = append(hazardBags, bag{tokens: toks, text: txt})
|
||||
}
|
||||
|
||||
report := EchoReport{}
|
||||
for _, fld := range echoFields {
|
||||
raw, _ := limits[fld.key].(string)
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
continue
|
||||
}
|
||||
for _, sent := range sentenceSplit.Split(raw, -1) {
|
||||
sent = strings.TrimSpace(sent)
|
||||
if len(sent) < 30 {
|
||||
// Skip very short fragments
|
||||
continue
|
||||
}
|
||||
report.TotalPhrases++
|
||||
st := contentTokenSet(sent)
|
||||
if len(st) < 3 {
|
||||
continue
|
||||
}
|
||||
bestScore := 0.0
|
||||
for _, hb := range hazardBags {
|
||||
score := jaccard(st, hb.tokens)
|
||||
if score > bestScore {
|
||||
bestScore = score
|
||||
}
|
||||
}
|
||||
if bestScore >= echoThreshold {
|
||||
report.Echoed++
|
||||
continue
|
||||
}
|
||||
report.Orphaned++
|
||||
report.OrphanedPhrases = append(report.OrphanedPhrases, OrphanedPhrase{
|
||||
Field: fld.label,
|
||||
Phrase: sent,
|
||||
BestScore: bestScore,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(report.OrphanedPhrases, func(i, j int) bool {
|
||||
// Lowest scores first — most clearly orphaned
|
||||
return report.OrphanedPhrases[i].BestScore < report.OrphanedPhrases[j].BestScore
|
||||
})
|
||||
return report
|
||||
}
|
||||
|
||||
func unwrapLimits(form map[string]any) map[string]any {
|
||||
if inner, ok := form["limits_form"].(map[string]any); ok {
|
||||
return inner
|
||||
}
|
||||
return form
|
||||
}
|
||||
|
||||
func joinHazardText(h map[string]any) string {
|
||||
parts := []string{}
|
||||
for _, k := range []string{"name", "description", "scenario", "trigger_event", "possible_harm", "hazardous_zone", "category", "sub_category"} {
|
||||
if v, ok := h[k].(string); ok {
|
||||
parts = append(parts, v)
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func contentTokenSet(s string) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
for _, m := range wordRE.FindAllString(s, -1) {
|
||||
w := strings.ToLower(m)
|
||||
if stopWords[w] {
|
||||
continue
|
||||
}
|
||||
out[w] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func jaccard(a, b map[string]bool) float64 {
|
||||
if len(a) == 0 || len(b) == 0 {
|
||||
return 0
|
||||
}
|
||||
inter := 0
|
||||
for x := range a {
|
||||
if b[x] {
|
||||
inter++
|
||||
}
|
||||
}
|
||||
union := len(a) + len(b) - inter
|
||||
if union == 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(inter) / float64(union)
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// runHierarchyImpl checks the ISO 12100 / EN 12100 risk-reduction
|
||||
// hierarchy on the generated mitigation set: every safety-relevant
|
||||
// hazard should have at least one "inherently safe design" measure
|
||||
// (design) and additionally either a guarding/protective device
|
||||
// (protection) or an information-for-use measure (information).
|
||||
//
|
||||
// Cyber-, ergonomic-, and software-only hazards have looser
|
||||
// expectations — design alone or information alone may legitimately
|
||||
// suffice. The audit reports which level is missing, not whether the
|
||||
// remaining measures are individually correct. That is a different
|
||||
// check (E2 — semantic quality), out of scope here.
|
||||
func init() {
|
||||
runHierarchyImpl = runHierarchy
|
||||
}
|
||||
|
||||
// hazardExpectsProtection lists hazard categories where a pure
|
||||
// design+information combination is usually not enough — the engine
|
||||
// should produce at least one explicit protective measure (guard,
|
||||
// interlock, sensor, presence detector, …).
|
||||
var hazardExpectsProtection = map[string]bool{
|
||||
"mechanical_hazard": true,
|
||||
"electrical_hazard": true,
|
||||
"thermal_hazard": true,
|
||||
"pneumatic_hydraulic": true,
|
||||
"radiation_hazard": true,
|
||||
"laser_hazard": true,
|
||||
"fire_explosion_hazard": true,
|
||||
"chemical_hazard": true,
|
||||
}
|
||||
|
||||
func runHierarchy(hazards, mitigations []map[string]any) HierarchyReport {
|
||||
report := HierarchyReport{TotalHazards: len(hazards)}
|
||||
|
||||
// Index mitigations by hazard_id
|
||||
byHazard := map[string][]map[string]any{}
|
||||
for _, m := range mitigations {
|
||||
hid, _ := m["hazard_id"].(string)
|
||||
if hid == "" {
|
||||
continue
|
||||
}
|
||||
byHazard[hid] = append(byHazard[hid], m)
|
||||
}
|
||||
|
||||
for _, h := range hazards {
|
||||
hid, _ := h["id"].(string)
|
||||
category, _ := h["category"].(string)
|
||||
name, _ := h["name"].(string)
|
||||
|
||||
levels := levelsForHazard(byHazard[hid])
|
||||
missing := expectedMissing(category, levels)
|
||||
|
||||
if len(missing) == 0 {
|
||||
report.Complete++
|
||||
continue
|
||||
}
|
||||
for _, m := range missing {
|
||||
switch m {
|
||||
case "design":
|
||||
report.MissingDesign++
|
||||
case "protection":
|
||||
report.MissingProtection++
|
||||
case "information":
|
||||
report.MissingInfo++
|
||||
}
|
||||
}
|
||||
report.IncompleteHazards = append(report.IncompleteHazards, HazardHierarchyResult{
|
||||
HazardID: hid,
|
||||
Name: name,
|
||||
Category: category,
|
||||
Levels: levels,
|
||||
MissingLevels: missing,
|
||||
})
|
||||
}
|
||||
|
||||
// Sort: protection-missing first (most consequential), then by category
|
||||
sort.Slice(report.IncompleteHazards, func(i, j int) bool {
|
||||
a := report.IncompleteHazards[i]
|
||||
b := report.IncompleteHazards[j]
|
||||
ap := contains(a.MissingLevels, "protection")
|
||||
bp := contains(b.MissingLevels, "protection")
|
||||
if ap != bp {
|
||||
return ap
|
||||
}
|
||||
return a.Category < b.Category
|
||||
})
|
||||
return report
|
||||
}
|
||||
|
||||
// levelsForHazard returns the distinct reduction-type levels present
|
||||
// for a hazard's mitigation set. Possible values: design, protection,
|
||||
// information.
|
||||
func levelsForHazard(mits []map[string]any) []string {
|
||||
seen := map[string]bool{}
|
||||
for _, m := range mits {
|
||||
rt, _ := m["reduction_type"].(string)
|
||||
switch strings.ToLower(rt) {
|
||||
case "design":
|
||||
seen["design"] = true
|
||||
case "protection", "protective":
|
||||
seen["protection"] = true
|
||||
case "information":
|
||||
seen["information"] = true
|
||||
}
|
||||
}
|
||||
var out []string
|
||||
for k := range seen {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// expectedMissing returns the levels that the hierarchy demands but
|
||||
// the mitigation set does not provide.
|
||||
//
|
||||
// Rule:
|
||||
// - Every hazard with mitigations should have a design measure.
|
||||
// - Categories in hazardExpectsProtection additionally need a
|
||||
// protection measure.
|
||||
// - All hazards should have an information measure unless they
|
||||
// already have both design + protection (the information layer
|
||||
// can then be considered subsumed for the audit's purpose; the
|
||||
// real engine usually still adds it).
|
||||
func expectedMissing(category string, present []string) []string {
|
||||
have := toBoolSet(present)
|
||||
var missing []string
|
||||
if !have["design"] {
|
||||
missing = append(missing, "design")
|
||||
}
|
||||
if hazardExpectsProtection[category] && !have["protection"] {
|
||||
missing = append(missing, "protection")
|
||||
}
|
||||
// Information is only flagged if both design and protection are
|
||||
// also absent — otherwise too noisy. We still surface the case
|
||||
// where information is the SOLE present level: that means the
|
||||
// hazard is mitigated only by warning labels, which is rarely
|
||||
// adequate.
|
||||
if !have["information"] && !have["design"] && !have["protection"] {
|
||||
missing = append(missing, "information")
|
||||
}
|
||||
return missing
|
||||
}
|
||||
|
||||
func contains(list []string, target string) bool {
|
||||
for _, x := range list {
|
||||
if x == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package audit
|
||||
|
||||
// Implementation entry points for Methods B-E. The full algorithms live
|
||||
// in consistency.go, vocabulary.go, echo.go, hierarchy.go respectively.
|
||||
// Until those files land, these wrappers keep main.go compilable and
|
||||
// return a clearly-marked empty report.
|
||||
|
||||
func RunConsistency() ConsistencyReport {
|
||||
return runConsistencyImpl()
|
||||
}
|
||||
|
||||
func RunVocabulary(form map[string]any) VocabularyReport {
|
||||
return runVocabularyImpl(form)
|
||||
}
|
||||
|
||||
func RunEcho(form map[string]any, hazards []map[string]any) EchoReport {
|
||||
return runEchoImpl(form, hazards)
|
||||
}
|
||||
|
||||
func RunHierarchy(hazards, mitigations []map[string]any) HierarchyReport {
|
||||
return runHierarchyImpl(hazards, mitigations)
|
||||
}
|
||||
|
||||
// Default implementations — replaced when each method file lands.
|
||||
// Keeping them as separate functions in one place avoids name clashes
|
||||
// once consistency.go etc. add their real implementations.
|
||||
|
||||
var (
|
||||
runConsistencyImpl = func() ConsistencyReport { return ConsistencyReport{} }
|
||||
runVocabularyImpl = func(form map[string]any) VocabularyReport { return VocabularyReport{} }
|
||||
runEchoImpl = func(form map[string]any, hazards []map[string]any) EchoReport {
|
||||
return EchoReport{}
|
||||
}
|
||||
runHierarchyImpl = func(hazards, mitigations []map[string]any) HierarchyReport {
|
||||
return HierarchyReport{}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,298 @@
|
||||
// Package audit provides static and runtime audits of the IACE pattern
|
||||
// engine — finding pattern reachability, library consistency, and
|
||||
// limits-form coverage gaps without a ground-truth reference.
|
||||
package audit
|
||||
|
||||
import (
|
||||
"sort"
|
||||
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/iace"
|
||||
)
|
||||
|
||||
// ReachabilityResult is the verdict for a single pattern in Method A.
|
||||
type ReachabilityResult struct {
|
||||
PatternID string `json:"pattern_id"`
|
||||
Name string `json:"name_de"`
|
||||
Priority int `json:"priority"`
|
||||
RequiredAllTags []string `json:"required_tags"`
|
||||
UnreachableTags []string `json:"unreachable_tags,omitempty"`
|
||||
Status string `json:"status"` // "reachable" | "weakly_reachable" | "unreachable"
|
||||
ReachableSources []string `json:"reachable_sources,omitempty"`
|
||||
FixSuggestions []string `json:"fix_suggestions,omitempty"`
|
||||
}
|
||||
|
||||
// ReachabilityReport is the full Method A output.
|
||||
type ReachabilityReport struct {
|
||||
TotalPatterns int `json:"total_patterns"`
|
||||
Reachable int `json:"reachable"`
|
||||
WeaklyReachable int `json:"weakly_reachable"`
|
||||
Unreachable int `json:"unreachable"`
|
||||
UniverseTags []string `json:"universe_tags"`
|
||||
UnreachablePatterns []ReachabilityResult `json:"unreachable_patterns"`
|
||||
WeakPatterns []ReachabilityResult `json:"weak_patterns"`
|
||||
}
|
||||
|
||||
// RunReachability evaluates every pattern against the achievable tag universe.
|
||||
//
|
||||
// A pattern is:
|
||||
// - "unreachable" if at least one required tag is not produced by any
|
||||
// component, energy source, or keyword-dictionary entry.
|
||||
// - "weakly_reachable" if all required tags exist in the universe but
|
||||
// no single source (one Component or one EnergySource or one Keyword
|
||||
// entry) supplies all of them at once — i.e., it relies on multiple
|
||||
// parser hits to combine.
|
||||
// - "reachable" if some single source covers all required tags.
|
||||
//
|
||||
// The classification ignores ExcludedComponentTags and runtime filters
|
||||
// (lifecycle/op-state/machine-type), because those are project-level
|
||||
// concerns. The audit answers "could this pattern EVER fire", not
|
||||
// "does it fire for project X".
|
||||
func RunReachability() ReachabilityReport {
|
||||
patterns := iace.AllPatterns()
|
||||
comps := iace.GetComponentLibrary()
|
||||
energies := iace.GetEnergySources()
|
||||
keywords := iace.GetKeywordDictionary()
|
||||
|
||||
// Tag universe: union of every tag emitted anywhere
|
||||
universe := map[string][]string{} // tag → list of source IDs that emit it
|
||||
for _, c := range comps {
|
||||
for _, t := range c.Tags {
|
||||
universe[t] = appendUnique(universe[t], "component:"+c.ID)
|
||||
}
|
||||
}
|
||||
for _, e := range energies {
|
||||
for _, t := range e.Tags {
|
||||
universe[t] = appendUnique(universe[t], "energy:"+e.ID)
|
||||
}
|
||||
}
|
||||
for i, kw := range keywords {
|
||||
for _, t := range kw.ExtraTags {
|
||||
universe[t] = appendUnique(universe[t], keywordLabel(kw, i))
|
||||
}
|
||||
// Keyword entries can also reference components/energies, which
|
||||
// transitively add their tags to the keyword's effective tag set.
|
||||
for _, cID := range kw.ComponentIDs {
|
||||
for _, c := range comps {
|
||||
if c.ID != cID {
|
||||
continue
|
||||
}
|
||||
for _, t := range c.Tags {
|
||||
universe[t] = appendUnique(universe[t], keywordLabel(kw, i))
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, eID := range kw.EnergyIDs {
|
||||
for _, e := range energies {
|
||||
if e.ID != eID {
|
||||
continue
|
||||
}
|
||||
for _, t := range e.Tags {
|
||||
universe[t] = appendUnique(universe[t], keywordLabel(kw, i))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Single-source coverage map: tag → covering sources, but also
|
||||
// per-source tag set so we can check "is there ONE source covering
|
||||
// all required tags".
|
||||
sourceTags := map[string]map[string]bool{}
|
||||
for _, c := range comps {
|
||||
key := "component:" + c.ID
|
||||
sourceTags[key] = toBoolSet(c.Tags)
|
||||
}
|
||||
for _, e := range energies {
|
||||
key := "energy:" + e.ID
|
||||
sourceTags[key] = toBoolSet(e.Tags)
|
||||
}
|
||||
for i, kw := range keywords {
|
||||
key := keywordLabel(kw, i)
|
||||
set := toBoolSet(kw.ExtraTags)
|
||||
for _, cID := range kw.ComponentIDs {
|
||||
for _, c := range comps {
|
||||
if c.ID == cID {
|
||||
for _, t := range c.Tags {
|
||||
set[t] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, eID := range kw.EnergyIDs {
|
||||
for _, e := range energies {
|
||||
if e.ID == eID {
|
||||
for _, t := range e.Tags {
|
||||
set[t] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
sourceTags[key] = set
|
||||
}
|
||||
|
||||
report := ReachabilityReport{TotalPatterns: len(patterns)}
|
||||
|
||||
// Universe tag list (sorted) for the report header
|
||||
for t := range universe {
|
||||
report.UniverseTags = append(report.UniverseTags, t)
|
||||
}
|
||||
sort.Strings(report.UniverseTags)
|
||||
|
||||
for _, p := range patterns {
|
||||
all := dedup(append(append([]string{}, p.RequiredComponentTags...), p.RequiredEnergyTags...))
|
||||
if len(all) == 0 {
|
||||
// Pattern with no tag requirements relies on lifecycle/machine_type
|
||||
// filters only — count as reachable by default.
|
||||
report.Reachable++
|
||||
continue
|
||||
}
|
||||
|
||||
var missing []string
|
||||
for _, t := range all {
|
||||
if _, ok := universe[t]; !ok {
|
||||
missing = append(missing, t)
|
||||
}
|
||||
}
|
||||
|
||||
res := ReachabilityResult{
|
||||
PatternID: p.ID,
|
||||
Name: p.NameDE,
|
||||
Priority: p.Priority,
|
||||
RequiredAllTags: all,
|
||||
}
|
||||
|
||||
if len(missing) > 0 {
|
||||
res.Status = "unreachable"
|
||||
res.UnreachableTags = missing
|
||||
res.FixSuggestions = suggestFixes(p, missing, comps, sourceTags)
|
||||
report.Unreachable++
|
||||
report.UnreachablePatterns = append(report.UnreachablePatterns, res)
|
||||
continue
|
||||
}
|
||||
|
||||
// All tags in universe — check single-source coverage
|
||||
single := findSingleSourceCovers(all, sourceTags)
|
||||
if len(single) > 0 {
|
||||
res.Status = "reachable"
|
||||
res.ReachableSources = single
|
||||
report.Reachable++
|
||||
continue
|
||||
}
|
||||
|
||||
res.Status = "weakly_reachable"
|
||||
res.FixSuggestions = suggestSingleSourceFixes(p, all, comps, sourceTags)
|
||||
report.WeaklyReachable++
|
||||
report.WeakPatterns = append(report.WeakPatterns, res)
|
||||
}
|
||||
|
||||
sort.Slice(report.UnreachablePatterns, func(i, j int) bool {
|
||||
return report.UnreachablePatterns[i].Priority > report.UnreachablePatterns[j].Priority
|
||||
})
|
||||
sort.Slice(report.WeakPatterns, func(i, j int) bool {
|
||||
return report.WeakPatterns[i].Priority > report.WeakPatterns[j].Priority
|
||||
})
|
||||
return report
|
||||
}
|
||||
|
||||
func findSingleSourceCovers(required []string, sourceTags map[string]map[string]bool) []string {
|
||||
var hits []string
|
||||
for src, tags := range sourceTags {
|
||||
ok := true
|
||||
for _, t := range required {
|
||||
if !tags[t] {
|
||||
ok = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if ok {
|
||||
hits = append(hits, src)
|
||||
}
|
||||
}
|
||||
sort.Strings(hits)
|
||||
return hits
|
||||
}
|
||||
|
||||
// suggestFixes proposes concrete library edits for unreachable patterns:
|
||||
// "Add tag X to Component C014 (Hubwerk)" type suggestions.
|
||||
func suggestFixes(p iace.HazardPattern, missing []string, comps []iace.ComponentLibraryEntry, sourceTags map[string]map[string]bool) []string {
|
||||
var out []string
|
||||
// For each missing tag, find candidates: components/energies that
|
||||
// would semantically own that tag based on existing tags overlap.
|
||||
for _, tag := range missing {
|
||||
candidates := nearComponents(p, tag, comps, sourceTags)
|
||||
if len(candidates) > 0 {
|
||||
out = append(out, "Add tag '"+tag+"' to one of: "+joinFirst(candidates, 3))
|
||||
} else {
|
||||
out = append(out, "Tag '"+tag+"' is undefined anywhere — needs a new component or energy source carrying it")
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func suggestSingleSourceFixes(p iace.HazardPattern, all []string, comps []iace.ComponentLibraryEntry, sourceTags map[string]map[string]bool) []string {
|
||||
// Find components that match the most required tags, then suggest
|
||||
// adding the residual ones.
|
||||
best := ""
|
||||
bestCover := 0
|
||||
var bestMissing []string
|
||||
for src, tags := range sourceTags {
|
||||
hit := 0
|
||||
var miss []string
|
||||
for _, t := range all {
|
||||
if tags[t] {
|
||||
hit++
|
||||
} else {
|
||||
miss = append(miss, t)
|
||||
}
|
||||
}
|
||||
if hit > bestCover {
|
||||
best, bestCover, bestMissing = src, hit, miss
|
||||
}
|
||||
}
|
||||
if best == "" || bestCover == 0 {
|
||||
return []string{"No single source covers any required tags — pattern needs a new dedicated component"}
|
||||
}
|
||||
if len(bestMissing) == 0 {
|
||||
return nil
|
||||
}
|
||||
return []string{"Closest single source '" + best + "' covers " + itoa(bestCover) + "/" + itoa(len(all)) + " tags. Add missing tags to it: " + joinFirst(bestMissing, 5)}
|
||||
}
|
||||
|
||||
// nearComponents finds components whose tags overlap most with the pattern's
|
||||
// requirements — these are good candidates to receive the missing tag.
|
||||
func nearComponents(p iace.HazardPattern, missing string, comps []iace.ComponentLibraryEntry, sourceTags map[string]map[string]bool) []string {
|
||||
required := dedup(append(append([]string{}, p.RequiredComponentTags...), p.RequiredEnergyTags...))
|
||||
required = removeOne(required, missing)
|
||||
if len(required) == 0 {
|
||||
return nil
|
||||
}
|
||||
type scored struct {
|
||||
id string
|
||||
score int
|
||||
}
|
||||
var scoredList []scored
|
||||
for _, c := range comps {
|
||||
tagSet := toBoolSet(c.Tags)
|
||||
s := 0
|
||||
for _, t := range required {
|
||||
if tagSet[t] {
|
||||
s++
|
||||
}
|
||||
}
|
||||
if s > 0 {
|
||||
scoredList = append(scoredList, scored{id: c.ID + " (" + c.NameDE + ")", score: s})
|
||||
}
|
||||
}
|
||||
sort.Slice(scoredList, func(i, j int) bool { return scoredList[i].score > scoredList[j].score })
|
||||
var out []string
|
||||
for _, s := range scoredList {
|
||||
out = append(out, s.id)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func keywordLabel(kw iace.KeywordEntry, idx int) string {
|
||||
if len(kw.Keywords) > 0 {
|
||||
return "keyword:" + kw.Keywords[0]
|
||||
}
|
||||
return "keyword:" + itoa(idx)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package audit
|
||||
|
||||
// Stubs for Methods B-E. Each is filled in its own file as the audit
|
||||
// suite grows. Keeping the type contracts here lets the CLI compile
|
||||
// before each method has its full implementation.
|
||||
|
||||
// ============================================================================
|
||||
// Method B — Component Self-Consistency
|
||||
// ============================================================================
|
||||
|
||||
type CategoryGap struct {
|
||||
Category string `json:"category"`
|
||||
SuggestedTags []string `json:"suggested_tags"`
|
||||
}
|
||||
|
||||
type ComponentResult struct {
|
||||
ComponentID string `json:"component_id"`
|
||||
NameDE string `json:"name_de"`
|
||||
DeclaredCategories []string `json:"declared_categories"`
|
||||
CoveredCategories []string `json:"covered_categories"`
|
||||
MissingForCategories []CategoryGap `json:"missing_for_categories,omitempty"`
|
||||
}
|
||||
|
||||
type ConsistencyReport struct {
|
||||
TotalComponents int `json:"total_components"`
|
||||
Consistent int `json:"consistent"`
|
||||
Incomplete int `json:"incomplete"`
|
||||
IncompleteComponents []ComponentResult `json:"incomplete_components"`
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Method C — Limits-Form Vocabulary Diff
|
||||
// ============================================================================
|
||||
|
||||
type DictionarySuggestion struct {
|
||||
Token string `json:"token"`
|
||||
Field string `json:"field"`
|
||||
PatternIDs []string `json:"pattern_ids"`
|
||||
}
|
||||
|
||||
type VocabularyReport struct {
|
||||
UniqueTokens int `json:"unique_tokens"`
|
||||
KnownTokens []string `json:"known_tokens"`
|
||||
UnknownTokens []string `json:"unknown_tokens"`
|
||||
SuggestedDictionaryEntries []DictionarySuggestion `json:"suggested_dictionary_entries"`
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Method D — Limits-Form Echo
|
||||
// ============================================================================
|
||||
|
||||
type OrphanedPhrase struct {
|
||||
Field string `json:"field"`
|
||||
Phrase string `json:"phrase"`
|
||||
BestScore float64 `json:"best_score"`
|
||||
}
|
||||
|
||||
type EchoReport struct {
|
||||
TotalPhrases int `json:"total_phrases"`
|
||||
Echoed int `json:"echoed"`
|
||||
Orphaned int `json:"orphaned"`
|
||||
OrphanedPhrases []OrphanedPhrase `json:"orphaned_phrases"`
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Method E — Hierarchy Completeness
|
||||
// ============================================================================
|
||||
|
||||
type HazardHierarchyResult struct {
|
||||
HazardID string `json:"hazard_id"`
|
||||
Name string `json:"name"`
|
||||
Category string `json:"category"`
|
||||
Levels []string `json:"present_levels"`
|
||||
MissingLevels []string `json:"missing_levels"`
|
||||
}
|
||||
|
||||
type HierarchyReport struct {
|
||||
TotalHazards int `json:"total_hazards"`
|
||||
Complete int `json:"complete"`
|
||||
MissingDesign int `json:"missing_design"`
|
||||
MissingProtection int `json:"missing_protection"`
|
||||
MissingInfo int `json:"missing_information"`
|
||||
IncompleteHazards []HazardHierarchyResult `json:"incomplete_hazards"`
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package audit
|
||||
|
||||
import "strconv"
|
||||
|
||||
func appendUnique(list []string, item string) []string {
|
||||
for _, x := range list {
|
||||
if x == item {
|
||||
return list
|
||||
}
|
||||
}
|
||||
return append(list, item)
|
||||
}
|
||||
|
||||
func toBoolSet(list []string) map[string]bool {
|
||||
s := make(map[string]bool, len(list))
|
||||
for _, x := range list {
|
||||
s[x] = true
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func dedup(list []string) []string {
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, x := range list {
|
||||
if !seen[x] {
|
||||
seen[x] = true
|
||||
out = append(out, x)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func removeOne(list []string, item string) []string {
|
||||
out := make([]string, 0, len(list))
|
||||
for _, x := range list {
|
||||
if x != item {
|
||||
out = append(out, x)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func joinFirst(list []string, n int) string {
|
||||
if len(list) <= n {
|
||||
return joinAll(list)
|
||||
}
|
||||
return joinAll(list[:n]) + ", ..."
|
||||
}
|
||||
|
||||
func joinAll(list []string) string {
|
||||
s := ""
|
||||
for i, x := range list {
|
||||
if i > 0 {
|
||||
s += ", "
|
||||
}
|
||||
s += x
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func itoa(n int) string { return strconv.Itoa(n) }
|
||||
@@ -0,0 +1,153 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/iace"
|
||||
)
|
||||
|
||||
// runVocabularyImpl takes a limits-form payload (the structured machine
|
||||
// description filled in by the engineer) and asks: which of its words
|
||||
// are unknown to the keyword dictionary yet appear in any pattern's
|
||||
// scenario/trigger/harm/zone text? Each such word is a dictionary gap —
|
||||
// the engineer typed a term that some pattern is waiting for, but the
|
||||
// parser cannot translate it into a tag.
|
||||
func init() {
|
||||
runVocabularyImpl = runVocabulary
|
||||
}
|
||||
|
||||
var tokenRE = regexp.MustCompile(`[a-zäöüßA-ZÄÖÜ]{4,}`)
|
||||
|
||||
// German + English stop words that show up in any narrative but carry
|
||||
// no engineering meaning. Kept short on purpose — we only want to drop
|
||||
// obvious filler.
|
||||
var stopWords = map[string]bool{
|
||||
"oder": true, "und": true, "auch": true, "wenn": true, "wird": true,
|
||||
"werden": true, "kann": true, "koennen": true, "soll": true, "muss": true,
|
||||
"sind": true, "eine": true, "einer": true, "einem": true, "einen": true,
|
||||
"diese": true, "dieser": true, "dieses": true, "diesem": true, "diesen": true,
|
||||
"durch": true, "nach": true, "ueber": true, "unter": true, "zwischen": true,
|
||||
"nicht": true, "ohne": true, "fuer": true, "bzw": true, "etc": true,
|
||||
"sowie": true, "siehe": true, "etwa": true, "ggf": true, "the": true,
|
||||
"with": true, "from": true, "this": true, "that": true, "have": true,
|
||||
"insbesondere": true, "ausschliesslich": true, "ebenfalls": true,
|
||||
"jeweils": true, "weitere": true, "weiteren": true, "weiterer": true,
|
||||
}
|
||||
|
||||
func runVocabulary(form map[string]any) VocabularyReport {
|
||||
limits, ok := form["limits_form"].(map[string]any)
|
||||
if !ok {
|
||||
// Form may already be the inner object
|
||||
limits = form
|
||||
}
|
||||
|
||||
tokens := map[string]bool{}
|
||||
for _, v := range limits {
|
||||
extractTokens(v, tokens)
|
||||
}
|
||||
report := VocabularyReport{UniqueTokens: len(tokens)}
|
||||
|
||||
dictTokens := dictionaryVocabulary()
|
||||
|
||||
for tok := range tokens {
|
||||
if stopWords[tok] {
|
||||
continue
|
||||
}
|
||||
if dictTokenHit(tok, dictTokens) {
|
||||
report.KnownTokens = append(report.KnownTokens, tok)
|
||||
} else {
|
||||
report.UnknownTokens = append(report.UnknownTokens, tok)
|
||||
}
|
||||
}
|
||||
sort.Strings(report.KnownTokens)
|
||||
sort.Strings(report.UnknownTokens)
|
||||
|
||||
// For each unknown token check if any pattern names it
|
||||
patterns := iace.AllPatterns()
|
||||
for _, tok := range report.UnknownTokens {
|
||||
hits := patternsMentioning(tok, patterns)
|
||||
if len(hits) == 0 {
|
||||
continue
|
||||
}
|
||||
report.SuggestedDictionaryEntries = append(report.SuggestedDictionaryEntries, DictionarySuggestion{
|
||||
Token: tok,
|
||||
PatternIDs: hits,
|
||||
})
|
||||
}
|
||||
sort.Slice(report.SuggestedDictionaryEntries, func(i, j int) bool {
|
||||
return len(report.SuggestedDictionaryEntries[i].PatternIDs) > len(report.SuggestedDictionaryEntries[j].PatternIDs)
|
||||
})
|
||||
return report
|
||||
}
|
||||
|
||||
func extractTokens(v any, out map[string]bool) {
|
||||
switch x := v.(type) {
|
||||
case string:
|
||||
for _, m := range tokenRE.FindAllString(x, -1) {
|
||||
out[strings.ToLower(m)] = true
|
||||
}
|
||||
case []any:
|
||||
for _, e := range x {
|
||||
extractTokens(e, out)
|
||||
}
|
||||
case map[string]any:
|
||||
for _, e := range x {
|
||||
extractTokens(e, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// dictionaryVocabulary builds the lowercase set of all keyword strings
|
||||
// that the parser will recognize, including normalized forms (umlauts
|
||||
// replaced like in the keyword dictionary).
|
||||
func dictionaryVocabulary() map[string]bool {
|
||||
out := map[string]bool{}
|
||||
for _, kw := range iace.GetKeywordDictionary() {
|
||||
for _, k := range kw.Keywords {
|
||||
out[strings.ToLower(k)] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// dictTokenHit returns true if the token would be matched by any
|
||||
// dictionary entry. Dictionary entries can be substrings, so we treat
|
||||
// the dict as a set of stem-like matchers: a token is "known" if it
|
||||
// equals a dict word OR contains a dict word as substring OR the dict
|
||||
// word contains the token.
|
||||
func dictTokenHit(tok string, dict map[string]bool) bool {
|
||||
if dict[tok] {
|
||||
return true
|
||||
}
|
||||
for d := range dict {
|
||||
if strings.Contains(tok, d) || strings.Contains(d, tok) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// patternsMentioning returns up to 8 pattern IDs whose scenario/trigger/
|
||||
// harm/zone text contains the token (case-insensitive substring).
|
||||
func patternsMentioning(tok string, patterns []iace.HazardPattern) []string {
|
||||
tokLower := strings.ToLower(tok)
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, p := range patterns {
|
||||
hay := strings.ToLower(p.ScenarioDE + " " + p.TriggerDE + " " + p.HarmDE + " " + p.ZoneDE + " " + p.NameDE)
|
||||
if !strings.Contains(hay, tokLower) {
|
||||
continue
|
||||
}
|
||||
if seen[p.ID] {
|
||||
continue
|
||||
}
|
||||
seen[p.ID] = true
|
||||
out = append(out, p.ID)
|
||||
if len(out) >= 8 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -104,39 +104,14 @@ func GetProjectComplianceTriggers(hazards []Hazard, patterns []HazardPattern) *C
|
||||
}
|
||||
}
|
||||
|
||||
// AllPatterns returns every hazard pattern from all pattern sources.
|
||||
// This mirrors the aggregation in NewPatternEngine but returns just the slice.
|
||||
// AllPatterns returns every registered hazard pattern. Delegates to
|
||||
// collectAllPatterns() in pattern_registry.go so new pattern sources only
|
||||
// need to be added in one place. Pre-2026-05-21 this function maintained
|
||||
// a duplicate enumeration which silently drifted from the registry —
|
||||
// CRA, ISO12100-gap, robot-cell, CNC, VDMA, textile-agri, GT-bremse and
|
||||
// secondary-harm patterns were invisible to AllPatterns callers.
|
||||
func AllPatterns() []HazardPattern {
|
||||
p := GetBuiltinHazardPatterns()
|
||||
p = append(p, GetExtendedHazardPatterns()...)
|
||||
p = append(p, GetPressHazardPatterns()...)
|
||||
p = append(p, GetCobotHazardPatterns()...)
|
||||
p = append(p, GetOperationalHazardPatterns()...)
|
||||
p = append(p, GetDGUVExtendedPatterns()...)
|
||||
p = append(p, GetExtendedHazardPatterns2()...)
|
||||
p = append(p, GetElevatorPatterns()...)
|
||||
p = append(p, GetAGVAgriPatterns()...)
|
||||
p = append(p, GetFoodProcessingPatterns()...)
|
||||
p = append(p, GetPackagingPatterns()...)
|
||||
p = append(p, GetLaserPatterns()...)
|
||||
p = append(p, GetMedicalDevicePatterns()...)
|
||||
p = append(p, GetPressureEquipmentPatterns()...)
|
||||
p = append(p, GetConstructionPatterns()...)
|
||||
p = append(p, GetForestryConveyorPatterns()...)
|
||||
p = append(p, GetPlasticsMetalPatterns()...)
|
||||
p = append(p, GetWeldingGlassTextilePatterns()...)
|
||||
p = append(p, GetSpecificMachinePatterns()...)
|
||||
p = append(p, GetSpecificMachinePatterns2()...)
|
||||
p = append(p, GetCyberExtendedPatterns()...)
|
||||
p = append(p, GetCyberExtendedPatterns2()...)
|
||||
p = append(p, GetCyberExtendedPatterns3()...)
|
||||
p = append(p, GetWorkshopPatterns()...)
|
||||
p = append(p, GetMaintenanceExtPatterns()...)
|
||||
p = append(p, GetFinalPatternsA()...)
|
||||
p = append(p, GetFinalPatternsB()...)
|
||||
p = append(p, GetFinalPatternsC()...)
|
||||
p = append(p, GetFinalPatternsD()...)
|
||||
return p
|
||||
return collectAllPatterns()
|
||||
}
|
||||
|
||||
// extractPatternIDs scans a text for "HP" followed by digits and adds
|
||||
|
||||
@@ -36,21 +36,21 @@ func GetComponentLibrary() []ComponentLibraryEntry {
|
||||
{ID: "C003", NameDE: "Foerderband", NameEN: "Conveyor Belt", Category: "mechanical", DescriptionDE: "Endlosband zum Transport von Werkstuecken zwischen Arbeitsstationen.", TypicalHazardCategories: []string{"mechanical_hazard", "ergonomic"}, TypicalEnergySources: []string{"EN01", "EN02"}, MapsToComponentType: "mechanical", Tags: []string{"moving_part", "rotating_part", "entanglement_risk"}, SortOrder: 3},
|
||||
{ID: "C004", NameDE: "Drehtisch", NameEN: "Rotary Table", Category: "mechanical", DescriptionDE: "Rotierender Arbeitstisch fuer Bearbeitungs- oder Montageprozesse.", TypicalHazardCategories: []string{"mechanical_hazard"}, TypicalEnergySources: []string{"EN02"}, MapsToComponentType: "mechanical", Tags: []string{"rotating_part", "high_force"}, SortOrder: 4},
|
||||
{ID: "C005", NameDE: "Linearachse", NameEN: "Linear Axis", Category: "mechanical", DescriptionDE: "Linearfuehrung fuer praezise translatorische Bewegungen.", TypicalHazardCategories: []string{"mechanical_hazard"}, TypicalEnergySources: []string{"EN01"}, MapsToComponentType: "mechanical", Tags: []string{"moving_part", "crush_point"}, SortOrder: 5},
|
||||
{ID: "C006", NameDE: "Spindel", NameEN: "Spindle", Category: "mechanical", DescriptionDE: "Hochdrehende Spindel fuer Fräs-, Bohr- oder Schleifoperationen.", TypicalHazardCategories: []string{"mechanical_hazard", "noise_vibration"}, TypicalEnergySources: []string{"EN02"}, MapsToComponentType: "mechanical", Tags: []string{"rotating_part", "high_speed", "cutting_part"}, SortOrder: 6},
|
||||
{ID: "C006", NameDE: "Spindel", NameEN: "Spindle", Category: "mechanical", DescriptionDE: "Hochdrehende Spindel fuer Fräs-, Bohr- oder Schleifoperationen.", TypicalHazardCategories: []string{"mechanical_hazard", "noise_vibration"}, TypicalEnergySources: []string{"EN02"}, MapsToComponentType: "mechanical", Tags: []string{"rotating_part", "high_speed", "cutting_part", "noise_source"}, SortOrder: 6},
|
||||
{ID: "C007", NameDE: "Saegeblatt", NameEN: "Saw Blade", Category: "mechanical", DescriptionDE: "Rotierendes oder oszillierendes Schneidwerkzeug.", TypicalHazardCategories: []string{"mechanical_hazard"}, TypicalEnergySources: []string{"EN02"}, MapsToComponentType: "mechanical", Tags: []string{"cutting_part", "rotating_part", "high_speed"}, SortOrder: 7},
|
||||
{ID: "C008", NameDE: "Pressenstoessel", NameEN: "Press Ram", Category: "mechanical", DescriptionDE: "Auf- und abfahrender Stoessel einer Presse zum Umformen.", TypicalHazardCategories: []string{"mechanical_hazard"}, TypicalEnergySources: []string{"EN01", "EN05"}, MapsToComponentType: "mechanical", Tags: []string{"moving_part", "high_force", "crush_point"}, SortOrder: 8},
|
||||
{ID: "C009", NameDE: "Walze", NameEN: "Roller", Category: "mechanical", DescriptionDE: "Zylindrische Walze zum Foerdern, Pressen oder Kalandrieren.", TypicalHazardCategories: []string{"mechanical_hazard"}, TypicalEnergySources: []string{"EN02"}, MapsToComponentType: "mechanical", Tags: []string{"rotating_part", "entanglement_risk", "pinch_point"}, SortOrder: 9},
|
||||
{ID: "C010", NameDE: "Kettenantrieb", NameEN: "Chain Drive", Category: "mechanical", DescriptionDE: "Kette und Kettenrad zur Kraftuebertragung.", TypicalHazardCategories: []string{"mechanical_hazard"}, TypicalEnergySources: []string{"EN01"}, MapsToComponentType: "mechanical", Tags: []string{"moving_part", "entanglement_risk"}, SortOrder: 10},
|
||||
{ID: "C011", NameDE: "Zahnradgetriebe", NameEN: "Gear Transmission", Category: "mechanical", DescriptionDE: "Zahnradpaar oder -satz zur Drehzahl-/Drehmomentanpassung.", TypicalHazardCategories: []string{"mechanical_hazard", "noise_vibration"}, TypicalEnergySources: []string{"EN02"}, MapsToComponentType: "mechanical", Tags: []string{"rotating_part", "pinch_point"}, SortOrder: 11},
|
||||
{ID: "C011", NameDE: "Zahnradgetriebe", NameEN: "Gear Transmission", Category: "mechanical", DescriptionDE: "Zahnradpaar oder -satz zur Drehzahl-/Drehmomentanpassung.", TypicalHazardCategories: []string{"mechanical_hazard", "noise_vibration"}, TypicalEnergySources: []string{"EN02"}, MapsToComponentType: "mechanical", Tags: []string{"rotating_part", "pinch_point", "noise_source"}, SortOrder: 11},
|
||||
{ID: "C012", NameDE: "Kupplung", NameEN: "Clutch", Category: "mechanical", DescriptionDE: "Mechanische Kupplung zur An-/Abkopplung von Antriebsstraengen.", TypicalHazardCategories: []string{"mechanical_hazard"}, TypicalEnergySources: []string{"EN02"}, MapsToComponentType: "mechanical", Tags: []string{"rotating_part"}, SortOrder: 12},
|
||||
{ID: "C013", NameDE: "Bremse", NameEN: "Brake", Category: "mechanical", DescriptionDE: "Mechanische oder elektromagnetische Bremse zum Stillsetzen von Antrieben.", TypicalHazardCategories: []string{"mechanical_hazard"}, TypicalEnergySources: []string{"EN01"}, MapsToComponentType: "mechanical", Tags: []string{"moving_part", "stored_energy"}, SortOrder: 13},
|
||||
{ID: "C014", NameDE: "Hubwerk", NameEN: "Hoist", Category: "mechanical", DescriptionDE: "Hebezeug zum vertikalen Bewegen von Lasten.", TypicalHazardCategories: []string{"mechanical_hazard", "ergonomic"}, TypicalEnergySources: []string{"EN01", "EN03"}, MapsToComponentType: "mechanical", Tags: []string{"moving_part", "high_force", "gravity_risk"}, SortOrder: 14},
|
||||
{ID: "C014", NameDE: "Hubwerk", NameEN: "Hoist", Category: "mechanical", DescriptionDE: "Hebezeug zum vertikalen Bewegen von Lasten.", TypicalHazardCategories: []string{"mechanical_hazard", "ergonomic"}, TypicalEnergySources: []string{"EN01", "EN03", "EN04"}, MapsToComponentType: "mechanical", Tags: []string{"moving_part", "high_force", "gravity_risk", "crush_point", "person_under_load"}, SortOrder: 14},
|
||||
{ID: "C015", NameDE: "Werkzeugwechsler", NameEN: "Tool Changer", Category: "mechanical", DescriptionDE: "Automatischer Werkzeugwechsler fuer CNC-Maschinen.", TypicalHazardCategories: []string{"mechanical_hazard"}, TypicalEnergySources: []string{"EN01", "EN05"}, MapsToComponentType: "mechanical", Tags: []string{"moving_part", "pinch_point"}, SortOrder: 15},
|
||||
{ID: "C016", NameDE: "Schweisskopf", NameEN: "Welding Head", Category: "mechanical", DescriptionDE: "Schweisskopf fuer MIG/MAG, WIG oder Laserschweissen.", TypicalHazardCategories: []string{"mechanical_hazard", "thermal_hazard", "electrical_hazard"}, TypicalEnergySources: []string{"EN03", "EN07"}, MapsToComponentType: "mechanical", Tags: []string{"high_temperature", "radiation_risk"}, SortOrder: 16},
|
||||
{ID: "C017", NameDE: "Schraubstation", NameEN: "Screwdriving Station", Category: "mechanical", DescriptionDE: "Automatische Schraubeinheit fuer Montageprozesse.", TypicalHazardCategories: []string{"mechanical_hazard", "noise_vibration"}, TypicalEnergySources: []string{"EN02"}, MapsToComponentType: "mechanical", Tags: []string{"rotating_part"}, SortOrder: 17},
|
||||
{ID: "C017", NameDE: "Schraubstation", NameEN: "Screwdriving Station", Category: "mechanical", DescriptionDE: "Automatische Schraubeinheit fuer Montageprozesse.", TypicalHazardCategories: []string{"mechanical_hazard", "noise_vibration"}, TypicalEnergySources: []string{"EN02"}, MapsToComponentType: "mechanical", Tags: []string{"rotating_part", "noise_source"}, SortOrder: 17},
|
||||
{ID: "C018", NameDE: "Stanzen-Werkzeug", NameEN: "Punching Tool", Category: "mechanical", DescriptionDE: "Stanzwerkzeug zum Ausschneiden von Formen aus Blech oder Folie.", TypicalHazardCategories: []string{"mechanical_hazard"}, TypicalEnergySources: []string{"EN01"}, MapsToComponentType: "mechanical", Tags: []string{"cutting_part", "high_force", "crush_point"}, SortOrder: 18},
|
||||
{ID: "C019", NameDE: "Biegewerkzeug", NameEN: "Bending Tool", Category: "mechanical", DescriptionDE: "Werkzeug zum Biegen von Blech oder Profilen.", TypicalHazardCategories: []string{"mechanical_hazard"}, TypicalEnergySources: []string{"EN01"}, MapsToComponentType: "mechanical", Tags: []string{"moving_part", "high_force", "crush_point"}, SortOrder: 19},
|
||||
{ID: "C020", NameDE: "Vibrationsfoerderer", NameEN: "Vibratory Feeder", Category: "mechanical", DescriptionDE: "Schwingfoerderer zum Sortieren und Zufuehren von Kleinteilen.", TypicalHazardCategories: []string{"mechanical_hazard", "noise_vibration"}, TypicalEnergySources: []string{"EN01"}, MapsToComponentType: "mechanical", Tags: []string{"moving_part", "vibration_source"}, SortOrder: 20},
|
||||
{ID: "C020", NameDE: "Vibrationsfoerderer", NameEN: "Vibratory Feeder", Category: "mechanical", DescriptionDE: "Schwingfoerderer zum Sortieren und Zufuehren von Kleinteilen.", TypicalHazardCategories: []string{"mechanical_hazard", "noise_vibration"}, TypicalEnergySources: []string{"EN01"}, MapsToComponentType: "mechanical", Tags: []string{"moving_part", "vibration_source", "noise_source"}, SortOrder: 20},
|
||||
|
||||
// ── Category: structural (C021-C030) ────────────────────────────────────
|
||||
{ID: "C021", NameDE: "Maschinenrahmen", NameEN: "Machine Frame", Category: "structural", DescriptionDE: "Tragender Rahmen als Grundstruktur der Maschine.", TypicalHazardCategories: []string{"mechanical_hazard"}, TypicalEnergySources: []string{}, MapsToComponentType: "mechanical", Tags: []string{"structural_part"}, SortOrder: 21},
|
||||
@@ -65,19 +65,19 @@ func GetComponentLibrary() []ComponentLibraryEntry {
|
||||
{ID: "C030", NameDE: "Plattform/Buehne", NameEN: "Platform/Walkway", Category: "structural", DescriptionDE: "Begehbare Plattform fuer Bedienung oder Wartung in der Hoehe.", TypicalHazardCategories: []string{"ergonomic", "mechanical_hazard"}, TypicalEnergySources: []string{"EN03"}, MapsToComponentType: "mechanical", Tags: []string{"structural_part", "gravity_risk"}, SortOrder: 30},
|
||||
|
||||
// ── Category: drive (C031-C040) ─────────────────────────────────────────
|
||||
{ID: "C031", NameDE: "Elektromotor (Drehstrom)", NameEN: "AC Motor", Category: "drive", DescriptionDE: "Drehstrom-Asynchronmotor als Hauptantrieb.", TypicalHazardCategories: []string{"electrical_hazard", "mechanical_hazard", "noise_vibration"}, TypicalEnergySources: []string{"EN02", "EN04"}, MapsToComponentType: "electrical", Tags: []string{"rotating_part", "high_voltage", "high_force"}, SortOrder: 31},
|
||||
{ID: "C032", NameDE: "Servomotor", NameEN: "Servo Motor", Category: "drive", DescriptionDE: "Hochdynamischer Servomotor fuer praezise Positionierung.", TypicalHazardCategories: []string{"electrical_hazard", "mechanical_hazard"}, TypicalEnergySources: []string{"EN02", "EN04"}, MapsToComponentType: "electrical", Tags: []string{"rotating_part", "high_speed"}, SortOrder: 32},
|
||||
{ID: "C033", NameDE: "Schrittmotor", NameEN: "Stepper Motor", Category: "drive", DescriptionDE: "Schrittmotor fuer inkrementelle Positionierung.", TypicalHazardCategories: []string{"electrical_hazard"}, TypicalEnergySources: []string{"EN02", "EN04"}, MapsToComponentType: "electrical", Tags: []string{"rotating_part"}, SortOrder: 33},
|
||||
{ID: "C034", NameDE: "Frequenzumrichter", NameEN: "Frequency Converter", Category: "drive", DescriptionDE: "Frequenzumrichter zur stufenlosen Drehzahlregelung.", TypicalHazardCategories: []string{"electrical_hazard", "emc_hazard"}, TypicalEnergySources: []string{"EN04"}, MapsToComponentType: "electrical", Tags: []string{"high_voltage", "stored_energy"}, SortOrder: 34},
|
||||
{ID: "C035", NameDE: "Getriebemotor", NameEN: "Gear Motor", Category: "drive", DescriptionDE: "Motor mit integriertem Getriebe fuer hohes Drehmoment bei niedriger Drehzahl.", TypicalHazardCategories: []string{"mechanical_hazard", "electrical_hazard"}, TypicalEnergySources: []string{"EN02", "EN04"}, MapsToComponentType: "electrical", Tags: []string{"rotating_part", "high_force"}, SortOrder: 35},
|
||||
{ID: "C036", NameDE: "Linearmotor", NameEN: "Linear Motor", Category: "drive", DescriptionDE: "Elektromagnetischer Direktantrieb fuer lineare Bewegung.", TypicalHazardCategories: []string{"electrical_hazard", "mechanical_hazard"}, TypicalEnergySources: []string{"EN01", "EN04"}, MapsToComponentType: "electrical", Tags: []string{"moving_part", "high_speed"}, SortOrder: 36},
|
||||
{ID: "C037", NameDE: "Torque-Motor", NameEN: "Torque Motor", Category: "drive", DescriptionDE: "Direktantriebsmotor fuer hohe Drehmomente ohne Getriebe.", TypicalHazardCategories: []string{"electrical_hazard", "mechanical_hazard"}, TypicalEnergySources: []string{"EN02", "EN04"}, MapsToComponentType: "electrical", Tags: []string{"rotating_part", "high_force"}, SortOrder: 37},
|
||||
{ID: "C038", NameDE: "Elektrischer Stellantrieb", NameEN: "Electric Actuator", Category: "drive", DescriptionDE: "Elektrischer Antrieb fuer Ventile, Klappen oder Schieber.", TypicalHazardCategories: []string{"electrical_hazard"}, TypicalEnergySources: []string{"EN01", "EN04"}, MapsToComponentType: "actuator", Tags: []string{"moving_part"}, SortOrder: 38},
|
||||
{ID: "C031", NameDE: "Elektromotor (Drehstrom)", NameEN: "AC Motor", Category: "drive", DescriptionDE: "Drehstrom-Asynchronmotor als Hauptantrieb.", TypicalHazardCategories: []string{"electrical_hazard", "mechanical_hazard", "noise_vibration"}, TypicalEnergySources: []string{"EN02", "EN04"}, MapsToComponentType: "electrical", Tags: []string{"rotating_part", "high_voltage", "high_force", "noise_source", "electrical_part"}, SortOrder: 31},
|
||||
{ID: "C032", NameDE: "Servomotor", NameEN: "Servo Motor", Category: "drive", DescriptionDE: "Hochdynamischer Servomotor fuer praezise Positionierung.", TypicalHazardCategories: []string{"electrical_hazard", "mechanical_hazard"}, TypicalEnergySources: []string{"EN02", "EN04"}, MapsToComponentType: "electrical", Tags: []string{"rotating_part", "high_speed", "electrical_part"}, SortOrder: 32},
|
||||
{ID: "C033", NameDE: "Schrittmotor", NameEN: "Stepper Motor", Category: "drive", DescriptionDE: "Schrittmotor fuer inkrementelle Positionierung.", TypicalHazardCategories: []string{"electrical_hazard"}, TypicalEnergySources: []string{"EN02", "EN04"}, MapsToComponentType: "electrical", Tags: []string{"rotating_part", "electrical_part"}, SortOrder: 33},
|
||||
{ID: "C034", NameDE: "Frequenzumrichter", NameEN: "Frequency Converter", Category: "drive", DescriptionDE: "Frequenzumrichter zur stufenlosen Drehzahlregelung.", TypicalHazardCategories: []string{"electrical_hazard", "emc_hazard"}, TypicalEnergySources: []string{"EN04"}, MapsToComponentType: "electrical", Tags: []string{"high_voltage", "stored_energy", "electrical_part", "electromagnetic"}, SortOrder: 34},
|
||||
{ID: "C035", NameDE: "Getriebemotor", NameEN: "Gear Motor", Category: "drive", DescriptionDE: "Motor mit integriertem Getriebe fuer hohes Drehmoment bei niedriger Drehzahl.", TypicalHazardCategories: []string{"mechanical_hazard", "electrical_hazard"}, TypicalEnergySources: []string{"EN02", "EN04"}, MapsToComponentType: "electrical", Tags: []string{"rotating_part", "high_force", "electrical_part"}, SortOrder: 35},
|
||||
{ID: "C036", NameDE: "Linearmotor", NameEN: "Linear Motor", Category: "drive", DescriptionDE: "Elektromagnetischer Direktantrieb fuer lineare Bewegung.", TypicalHazardCategories: []string{"electrical_hazard", "mechanical_hazard"}, TypicalEnergySources: []string{"EN01", "EN04"}, MapsToComponentType: "electrical", Tags: []string{"moving_part", "high_speed", "electrical_part"}, SortOrder: 36},
|
||||
{ID: "C037", NameDE: "Torque-Motor", NameEN: "Torque Motor", Category: "drive", DescriptionDE: "Direktantriebsmotor fuer hohe Drehmomente ohne Getriebe.", TypicalHazardCategories: []string{"electrical_hazard", "mechanical_hazard"}, TypicalEnergySources: []string{"EN02", "EN04"}, MapsToComponentType: "electrical", Tags: []string{"rotating_part", "high_force", "electrical_part"}, SortOrder: 37},
|
||||
{ID: "C038", NameDE: "Elektrischer Stellantrieb", NameEN: "Electric Actuator", Category: "drive", DescriptionDE: "Elektrischer Antrieb fuer Ventile, Klappen oder Schieber.", TypicalHazardCategories: []string{"electrical_hazard"}, TypicalEnergySources: []string{"EN01", "EN04"}, MapsToComponentType: "actuator", Tags: []string{"moving_part", "electrical_part"}, SortOrder: 38},
|
||||
{ID: "C039", NameDE: "Spindelantrieb", NameEN: "Spindle Drive", Category: "drive", DescriptionDE: "Kugelgewindetrieb fuer praezise Linearbewegung.", TypicalHazardCategories: []string{"mechanical_hazard"}, TypicalEnergySources: []string{"EN01"}, MapsToComponentType: "mechanical", Tags: []string{"moving_part", "crush_point"}, SortOrder: 39},
|
||||
{ID: "C040", NameDE: "Riemenantrieb", NameEN: "Belt Drive", Category: "drive", DescriptionDE: "Riemen und Riemenscheiben zur Kraftuebertragung.", TypicalHazardCategories: []string{"mechanical_hazard"}, TypicalEnergySources: []string{"EN02"}, MapsToComponentType: "mechanical", Tags: []string{"rotating_part", "entanglement_risk"}, SortOrder: 40},
|
||||
|
||||
// ── Category: hydraulic (C041-C050) ─────────────────────────────────────
|
||||
{ID: "C041", NameDE: "Hydraulikpumpe", NameEN: "Hydraulic Pump", Category: "hydraulic", DescriptionDE: "Pumpe zur Erzeugung des hydraulischen Drucks im System.", TypicalHazardCategories: []string{"pneumatic_hydraulic", "noise_vibration"}, TypicalEnergySources: []string{"EN05"}, MapsToComponentType: "actuator", Tags: []string{"hydraulic_part", "high_pressure"}, SortOrder: 41},
|
||||
{ID: "C041", NameDE: "Hydraulikpumpe", NameEN: "Hydraulic Pump", Category: "hydraulic", DescriptionDE: "Pumpe zur Erzeugung des hydraulischen Drucks im System.", TypicalHazardCategories: []string{"pneumatic_hydraulic", "noise_vibration"}, TypicalEnergySources: []string{"EN05"}, MapsToComponentType: "actuator", Tags: []string{"hydraulic_part", "high_pressure", "noise_source"}, SortOrder: 41},
|
||||
{ID: "C042", NameDE: "Hydraulikzylinder", NameEN: "Hydraulic Cylinder", Category: "hydraulic", DescriptionDE: "Linearaktuator zur Erzeugung hoher Kraefte.", TypicalHazardCategories: []string{"pneumatic_hydraulic", "mechanical_hazard"}, TypicalEnergySources: []string{"EN05"}, MapsToComponentType: "actuator", Tags: []string{"hydraulic_part", "moving_part", "high_force", "high_pressure"}, SortOrder: 42},
|
||||
{ID: "C043", NameDE: "Hydraulikventil", NameEN: "Hydraulic Valve", Category: "hydraulic", DescriptionDE: "Steuer- oder Regelventil im Hydraulikkreislauf.", TypicalHazardCategories: []string{"pneumatic_hydraulic"}, TypicalEnergySources: []string{"EN05"}, MapsToComponentType: "actuator", Tags: []string{"hydraulic_part", "high_pressure"}, SortOrder: 43},
|
||||
{ID: "C044", NameDE: "Hydraulikspeicher", NameEN: "Hydraulic Accumulator", Category: "hydraulic", DescriptionDE: "Druckspeicher zur Pufferung von Druckspitzen.", TypicalHazardCategories: []string{"pneumatic_hydraulic"}, TypicalEnergySources: []string{"EN05"}, MapsToComponentType: "actuator", Tags: []string{"hydraulic_part", "stored_energy", "high_pressure"}, SortOrder: 44},
|
||||
@@ -117,33 +117,33 @@ func GetComponentLibrary() []ComponentLibraryEntry {
|
||||
{ID: "C072", NameDE: "Sicherheits-SPS", NameEN: "Safety PLC", Category: "control", DescriptionDE: "Redundante Sicherheitssteuerung bis SIL 3 / PL e.", TypicalHazardCategories: []string{"safety_function_failure", "software_fault"}, TypicalEnergySources: []string{}, MapsToComponentType: "controller", Tags: []string{"has_software", "programmable", "safety_device"}, SortOrder: 72},
|
||||
{ID: "C073", NameDE: "HMI (Bedienterminal)", NameEN: "HMI (Human Machine Interface)", Category: "control", DescriptionDE: "Bedienpanel mit Touchscreen zur Maschinensteuerung.", TypicalHazardCategories: []string{"hmi_error", "mode_confusion"}, TypicalEnergySources: []string{}, MapsToComponentType: "hmi", Tags: []string{"has_software", "user_interface"}, SortOrder: 73},
|
||||
{ID: "C074", NameDE: "Industrierechner (IPC)", NameEN: "Industrial PC", Category: "control", DescriptionDE: "Industrie-PC fuer komplexe Steuerungs- und Datenverarbeitungsaufgaben.", TypicalHazardCategories: []string{"software_fault", "configuration_error"}, TypicalEnergySources: []string{}, MapsToComponentType: "controller", Tags: []string{"has_software", "programmable", "networked"}, SortOrder: 74},
|
||||
{ID: "C075", NameDE: "Motion Controller", NameEN: "Motion Controller", Category: "control", DescriptionDE: "Achscontroller fuer synchronisierte Mehrachsbewegungen.", TypicalHazardCategories: []string{"software_fault", "mechanical_hazard"}, TypicalEnergySources: []string{}, MapsToComponentType: "controller", Tags: []string{"has_software", "programmable"}, SortOrder: 75},
|
||||
{ID: "C075", NameDE: "Motion Controller", NameEN: "Motion Controller", Category: "control", DescriptionDE: "Achscontroller fuer synchronisierte Mehrachsbewegungen.", TypicalHazardCategories: []string{"software_fault", "mechanical_hazard"}, TypicalEnergySources: []string{}, MapsToComponentType: "controller", Tags: []string{"has_software", "programmable", "moving_part"}, SortOrder: 75},
|
||||
{ID: "C076", NameDE: "Sicherheitsrelais", NameEN: "Safety Relay", Category: "control", DescriptionDE: "Sicherheitsschaltgeraet fuer Not-Halt, Schutztuer etc.", TypicalHazardCategories: []string{"safety_function_failure"}, TypicalEnergySources: []string{}, MapsToComponentType: "controller", Tags: []string{"safety_device"}, SortOrder: 76},
|
||||
{ID: "C077", NameDE: "Antriebsregler", NameEN: "Drive Controller", Category: "control", DescriptionDE: "Intelligenter Antriebsregler mit integrierten Sicherheitsfunktionen.", TypicalHazardCategories: []string{"software_fault", "electrical_hazard"}, TypicalEnergySources: []string{"EN04"}, MapsToComponentType: "controller", Tags: []string{"has_software", "programmable"}, SortOrder: 77},
|
||||
{ID: "C077", NameDE: "Antriebsregler", NameEN: "Drive Controller", Category: "control", DescriptionDE: "Intelligenter Antriebsregler mit integrierten Sicherheitsfunktionen.", TypicalHazardCategories: []string{"software_fault", "electrical_hazard"}, TypicalEnergySources: []string{"EN04"}, MapsToComponentType: "controller", Tags: []string{"has_software", "programmable", "electrical_part"}, SortOrder: 77},
|
||||
{ID: "C078", NameDE: "Remote I/O", NameEN: "Remote I/O Module", Category: "control", DescriptionDE: "Dezentrales Ein-/Ausgangsmodul im Feldbus.", TypicalHazardCategories: []string{"communication_failure"}, TypicalEnergySources: []string{}, MapsToComponentType: "controller", Tags: []string{"networked"}, SortOrder: 78},
|
||||
{ID: "C079", NameDE: "Bedienpult", NameEN: "Control Desk", Category: "control", DescriptionDE: "Zentrales Bedienpult mit Tastern, Schaltern und Anzeigen.", TypicalHazardCategories: []string{"hmi_error", "mode_confusion"}, TypicalEnergySources: []string{}, MapsToComponentType: "hmi", Tags: []string{"user_interface"}, SortOrder: 79},
|
||||
{ID: "C080", NameDE: "Datenschreiber/Logger", NameEN: "Data Logger", Category: "control", DescriptionDE: "Geraet zur Aufzeichnung von Prozessparametern.", TypicalHazardCategories: []string{"logging_audit_failure"}, TypicalEnergySources: []string{}, MapsToComponentType: "controller", Tags: []string{"has_software"}, SortOrder: 80},
|
||||
|
||||
// ── Category: sensor (C081-C090) ────────────────────────────────────────
|
||||
{ID: "C081", NameDE: "Positionssensor", NameEN: "Position Sensor", Category: "sensor", DescriptionDE: "Induktiver, kapazitiver oder optischer Positionssensor.", TypicalHazardCategories: []string{"sensor_spoofing"}, TypicalEnergySources: []string{}, MapsToComponentType: "sensor", Tags: []string{"sensor_part"}, SortOrder: 81},
|
||||
{ID: "C082", NameDE: "Kamerasystem", NameEN: "Camera System", Category: "sensor", DescriptionDE: "Industriekamera fuer Bildverarbeitung und Qualitaetskontrolle.", TypicalHazardCategories: []string{"sensor_spoofing", "false_classification"}, TypicalEnergySources: []string{}, MapsToComponentType: "sensor", Tags: []string{"sensor_part", "networked"}, SortOrder: 82},
|
||||
{ID: "C083", NameDE: "Kraftsensor", NameEN: "Force Sensor", Category: "sensor", DescriptionDE: "Dehnungsmessstreifen oder piezoelektrischer Kraftsensor.", TypicalHazardCategories: []string{"sensor_spoofing"}, TypicalEnergySources: []string{}, MapsToComponentType: "sensor", Tags: []string{"sensor_part"}, SortOrder: 83},
|
||||
{ID: "C084", NameDE: "Temperatursensor", NameEN: "Temperature Sensor", Category: "sensor", DescriptionDE: "Thermocouple oder PT100 zur Temperaturueberwachung.", TypicalHazardCategories: []string{"sensor_spoofing"}, TypicalEnergySources: []string{}, MapsToComponentType: "sensor", Tags: []string{"sensor_part"}, SortOrder: 84},
|
||||
{ID: "C085", NameDE: "Drucksensor", NameEN: "Pressure Sensor", Category: "sensor", DescriptionDE: "Sensor zur Ueberwachung von Druck in Hydraulik- oder Pneumatiksystemen.", TypicalHazardCategories: []string{"sensor_spoofing"}, TypicalEnergySources: []string{}, MapsToComponentType: "sensor", Tags: []string{"sensor_part"}, SortOrder: 85},
|
||||
{ID: "C086", NameDE: "Drehgeber/Encoder", NameEN: "Rotary Encoder", Category: "sensor", DescriptionDE: "Absolut- oder Inkrementaldrehgeber zur Winkel-/Positionsmessung.", TypicalHazardCategories: []string{"sensor_spoofing"}, TypicalEnergySources: []string{}, MapsToComponentType: "sensor", Tags: []string{"sensor_part"}, SortOrder: 86},
|
||||
{ID: "C087", NameDE: "Laserscanner", NameEN: "Laser Scanner", Category: "sensor", DescriptionDE: "Sicherheits-Laserscanner zur Ueberwachung von Schutzzonen.", TypicalHazardCategories: []string{"sensor_spoofing", "safety_function_failure"}, TypicalEnergySources: []string{}, MapsToComponentType: "sensor", Tags: []string{"sensor_part", "safety_device"}, SortOrder: 87},
|
||||
{ID: "C088", NameDE: "Beschleunigungssensor", NameEN: "Accelerometer", Category: "sensor", DescriptionDE: "Sensor zur Vibrations- und Beschleunigungsmessung.", TypicalHazardCategories: []string{"sensor_spoofing"}, TypicalEnergySources: []string{}, MapsToComponentType: "sensor", Tags: []string{"sensor_part"}, SortOrder: 88},
|
||||
{ID: "C089", NameDE: "Durchflusssensor", NameEN: "Flow Sensor", Category: "sensor", DescriptionDE: "Sensor zur Ueberwachung des Volumenstrom.", TypicalHazardCategories: []string{"sensor_spoofing"}, TypicalEnergySources: []string{}, MapsToComponentType: "sensor", Tags: []string{"sensor_part"}, SortOrder: 89},
|
||||
{ID: "C090", NameDE: "Fuellstandsensor", NameEN: "Level Sensor", Category: "sensor", DescriptionDE: "Sensor zur Ueberwachung des Fuellstands in Tanks und Behaeltern.", TypicalHazardCategories: []string{"sensor_spoofing"}, TypicalEnergySources: []string{}, MapsToComponentType: "sensor", Tags: []string{"sensor_part"}, SortOrder: 90},
|
||||
{ID: "C081", NameDE: "Positionssensor", NameEN: "Position Sensor", Category: "sensor", DescriptionDE: "Induktiver, kapazitiver oder optischer Positionssensor.", TypicalHazardCategories: []string{"sensor_spoofing"}, TypicalEnergySources: []string{}, MapsToComponentType: "sensor", Tags: []string{"sensor_part", "cyber"}, SortOrder: 81},
|
||||
{ID: "C082", NameDE: "Kamerasystem", NameEN: "Camera System", Category: "sensor", DescriptionDE: "Industriekamera fuer Bildverarbeitung und Qualitaetskontrolle.", TypicalHazardCategories: []string{"sensor_spoofing", "false_classification"}, TypicalEnergySources: []string{}, MapsToComponentType: "sensor", Tags: []string{"sensor_part", "networked", "cyber", "has_ai"}, SortOrder: 82},
|
||||
{ID: "C083", NameDE: "Kraftsensor", NameEN: "Force Sensor", Category: "sensor", DescriptionDE: "Dehnungsmessstreifen oder piezoelektrischer Kraftsensor.", TypicalHazardCategories: []string{"sensor_spoofing"}, TypicalEnergySources: []string{}, MapsToComponentType: "sensor", Tags: []string{"sensor_part", "cyber"}, SortOrder: 83},
|
||||
{ID: "C084", NameDE: "Temperatursensor", NameEN: "Temperature Sensor", Category: "sensor", DescriptionDE: "Thermocouple oder PT100 zur Temperaturueberwachung.", TypicalHazardCategories: []string{"sensor_spoofing"}, TypicalEnergySources: []string{}, MapsToComponentType: "sensor", Tags: []string{"sensor_part", "cyber"}, SortOrder: 84},
|
||||
{ID: "C085", NameDE: "Drucksensor", NameEN: "Pressure Sensor", Category: "sensor", DescriptionDE: "Sensor zur Ueberwachung von Druck in Hydraulik- oder Pneumatiksystemen.", TypicalHazardCategories: []string{"sensor_spoofing"}, TypicalEnergySources: []string{}, MapsToComponentType: "sensor", Tags: []string{"sensor_part", "cyber"}, SortOrder: 85},
|
||||
{ID: "C086", NameDE: "Drehgeber/Encoder", NameEN: "Rotary Encoder", Category: "sensor", DescriptionDE: "Absolut- oder Inkrementaldrehgeber zur Winkel-/Positionsmessung.", TypicalHazardCategories: []string{"sensor_spoofing"}, TypicalEnergySources: []string{}, MapsToComponentType: "sensor", Tags: []string{"sensor_part", "cyber"}, SortOrder: 86},
|
||||
{ID: "C087", NameDE: "Laserscanner", NameEN: "Laser Scanner", Category: "sensor", DescriptionDE: "Sicherheits-Laserscanner zur Ueberwachung von Schutzzonen.", TypicalHazardCategories: []string{"sensor_spoofing", "safety_function_failure"}, TypicalEnergySources: []string{}, MapsToComponentType: "sensor", Tags: []string{"sensor_part", "safety_device", "cyber"}, SortOrder: 87},
|
||||
{ID: "C088", NameDE: "Beschleunigungssensor", NameEN: "Accelerometer", Category: "sensor", DescriptionDE: "Sensor zur Vibrations- und Beschleunigungsmessung.", TypicalHazardCategories: []string{"sensor_spoofing"}, TypicalEnergySources: []string{}, MapsToComponentType: "sensor", Tags: []string{"sensor_part", "cyber"}, SortOrder: 88},
|
||||
{ID: "C089", NameDE: "Durchflusssensor", NameEN: "Flow Sensor", Category: "sensor", DescriptionDE: "Sensor zur Ueberwachung des Volumenstrom.", TypicalHazardCategories: []string{"sensor_spoofing"}, TypicalEnergySources: []string{}, MapsToComponentType: "sensor", Tags: []string{"sensor_part", "cyber"}, SortOrder: 89},
|
||||
{ID: "C090", NameDE: "Fuellstandsensor", NameEN: "Level Sensor", Category: "sensor", DescriptionDE: "Sensor zur Ueberwachung des Fuellstands in Tanks und Behaeltern.", TypicalHazardCategories: []string{"sensor_spoofing"}, TypicalEnergySources: []string{}, MapsToComponentType: "sensor", Tags: []string{"sensor_part", "cyber"}, SortOrder: 90},
|
||||
|
||||
// ── Category: actuator (C091-C100) ──────────────────────────────────────
|
||||
{ID: "C091", NameDE: "Magnetventil", NameEN: "Solenoid Valve", Category: "actuator", DescriptionDE: "Elektromagnetisch betaetigtes Ventil fuer Pneumatik oder Hydraulik.", TypicalHazardCategories: []string{"pneumatic_hydraulic"}, TypicalEnergySources: []string{"EN05", "EN06"}, MapsToComponentType: "actuator", Tags: []string{"actuator_part"}, SortOrder: 91},
|
||||
{ID: "C091", NameDE: "Magnetventil", NameEN: "Solenoid Valve", Category: "actuator", DescriptionDE: "Elektromagnetisch betaetigtes Ventil fuer Pneumatik oder Hydraulik.", TypicalHazardCategories: []string{"pneumatic_hydraulic"}, TypicalEnergySources: []string{"EN05", "EN06"}, MapsToComponentType: "actuator", Tags: []string{"actuator_part", "hydraulic_part", "pneumatic_part", "high_pressure"}, SortOrder: 91},
|
||||
{ID: "C092", NameDE: "Linearantrieb (elektrisch)", NameEN: "Electric Linear Actuator", Category: "actuator", DescriptionDE: "Elektrischer Linearantrieb fuer Positionieraufgaben.", TypicalHazardCategories: []string{"mechanical_hazard", "electrical_hazard"}, TypicalEnergySources: []string{"EN01", "EN04"}, MapsToComponentType: "actuator", Tags: []string{"actuator_part", "moving_part"}, SortOrder: 92},
|
||||
{ID: "C093", NameDE: "Proportionalventil", NameEN: "Proportional Valve", Category: "actuator", DescriptionDE: "Stetig regelbares Ventil fuer praezise Drucksteuerung.", TypicalHazardCategories: []string{"pneumatic_hydraulic"}, TypicalEnergySources: []string{"EN05", "EN06"}, MapsToComponentType: "actuator", Tags: []string{"actuator_part"}, SortOrder: 93},
|
||||
{ID: "C093", NameDE: "Proportionalventil", NameEN: "Proportional Valve", Category: "actuator", DescriptionDE: "Stetig regelbares Ventil fuer praezise Drucksteuerung.", TypicalHazardCategories: []string{"pneumatic_hydraulic"}, TypicalEnergySources: []string{"EN05", "EN06"}, MapsToComponentType: "actuator", Tags: []string{"actuator_part", "hydraulic_part", "pneumatic_part", "high_pressure"}, SortOrder: 93},
|
||||
{ID: "C094", NameDE: "Heizelement", NameEN: "Heating Element", Category: "actuator", DescriptionDE: "Elektrisches Heizelement fuer Temperierung von Werkzeugen oder Medien.", TypicalHazardCategories: []string{"thermal_hazard", "electrical_hazard"}, TypicalEnergySources: []string{"EN07"}, MapsToComponentType: "actuator", Tags: []string{"actuator_part", "high_temperature"}, SortOrder: 94},
|
||||
{ID: "C095", NameDE: "Kuehlaggregat", NameEN: "Cooling Unit", Category: "actuator", DescriptionDE: "Kuehlanlage fuer Prozesse oder Schaltschraenke.", TypicalHazardCategories: []string{"thermal_hazard"}, TypicalEnergySources: []string{"EN07"}, MapsToComponentType: "actuator", Tags: []string{"actuator_part"}, SortOrder: 95},
|
||||
{ID: "C096", NameDE: "Luefter/Geblaese", NameEN: "Fan/Blower", Category: "actuator", DescriptionDE: "Luefter zur Kuehlung oder Absaugung.", TypicalHazardCategories: []string{"mechanical_hazard", "noise_vibration"}, TypicalEnergySources: []string{"EN02"}, MapsToComponentType: "actuator", Tags: []string{"actuator_part", "rotating_part"}, SortOrder: 96},
|
||||
{ID: "C097", NameDE: "Dosierpumpe", NameEN: "Dosing Pump", Category: "actuator", DescriptionDE: "Praezisionspumpe zur Dosierung von Fluessigkeiten oder Klebstoffen.", TypicalHazardCategories: []string{"pneumatic_hydraulic", "material_environmental"}, TypicalEnergySources: []string{"EN05"}, MapsToComponentType: "actuator", Tags: []string{"actuator_part"}, SortOrder: 97},
|
||||
{ID: "C096", NameDE: "Luefter/Geblaese", NameEN: "Fan/Blower", Category: "actuator", DescriptionDE: "Luefter zur Kuehlung oder Absaugung.", TypicalHazardCategories: []string{"mechanical_hazard", "noise_vibration"}, TypicalEnergySources: []string{"EN02"}, MapsToComponentType: "actuator", Tags: []string{"actuator_part", "rotating_part", "noise_source"}, SortOrder: 96},
|
||||
{ID: "C097", NameDE: "Dosierpumpe", NameEN: "Dosing Pump", Category: "actuator", DescriptionDE: "Praezisionspumpe zur Dosierung von Fluessigkeiten oder Klebstoffen.", TypicalHazardCategories: []string{"pneumatic_hydraulic", "material_environmental"}, TypicalEnergySources: []string{"EN05"}, MapsToComponentType: "actuator", Tags: []string{"actuator_part", "hydraulic_part", "chemical_risk"}, SortOrder: 97},
|
||||
{ID: "C098", NameDE: "Elektromagnet", NameEN: "Electromagnet", Category: "actuator", DescriptionDE: "Elektromagnet fuer Halten, Spannen oder Foerdern.", TypicalHazardCategories: []string{"electrical_hazard", "emc_hazard"}, TypicalEnergySources: []string{"EN04"}, MapsToComponentType: "actuator", Tags: []string{"actuator_part", "stored_energy"}, SortOrder: 98},
|
||||
{ID: "C099", NameDE: "Piezo-Aktuator", NameEN: "Piezo Actuator", Category: "actuator", DescriptionDE: "Piezoelektrischer Aktuator fuer hochpraezise Mikrobewegungen.", TypicalHazardCategories: []string{"electrical_hazard"}, TypicalEnergySources: []string{"EN04"}, MapsToComponentType: "actuator", Tags: []string{"actuator_part"}, SortOrder: 99},
|
||||
{ID: "C100", NameDE: "Spannvorrichtung", NameEN: "Clamping Device", Category: "actuator", DescriptionDE: "Mechanische, pneumatische oder hydraulische Spannvorrichtung.", TypicalHazardCategories: []string{"mechanical_hazard"}, TypicalEnergySources: []string{"EN01", "EN05", "EN06"}, MapsToComponentType: "actuator", Tags: []string{"actuator_part", "clamping_part", "pinch_point"}, SortOrder: 100},
|
||||
@@ -161,15 +161,15 @@ func GetComponentLibrary() []ComponentLibraryEntry {
|
||||
{ID: "C110", NameDE: "Zustimmtaster", NameEN: "Enabling Device", Category: "safety", DescriptionDE: "Dreistufiger Zustimmtaster fuer den Einrichtbetrieb.", TypicalHazardCategories: []string{"safety_function_failure"}, TypicalEnergySources: []string{}, MapsToComponentType: "controller", Tags: []string{"safety_device"}, SortOrder: 110},
|
||||
|
||||
// ── Category: it_network (C111-C120) ────────────────────────────────────
|
||||
{ID: "C111", NameDE: "Industrie-Switch (managed)", NameEN: "Managed Industrial Switch", Category: "it_network", DescriptionDE: "Managed Ethernet Switch fuer das Maschinennetzwerk.", TypicalHazardCategories: []string{"communication_failure", "unauthorized_access"}, TypicalEnergySources: []string{}, MapsToComponentType: "network", Tags: []string{"networked", "it_component"}, SortOrder: 111},
|
||||
{ID: "C112", NameDE: "Industrie-Router", NameEN: "Industrial Router", Category: "it_network", DescriptionDE: "Router zur Segmentierung und Absicherung des Maschinennetzwerks.", TypicalHazardCategories: []string{"communication_failure", "unauthorized_access"}, TypicalEnergySources: []string{}, MapsToComponentType: "network", Tags: []string{"networked", "it_component"}, SortOrder: 112},
|
||||
{ID: "C111", NameDE: "Industrie-Switch (managed)", NameEN: "Managed Industrial Switch", Category: "it_network", DescriptionDE: "Managed Ethernet Switch fuer das Maschinennetzwerk.", TypicalHazardCategories: []string{"communication_failure", "unauthorized_access"}, TypicalEnergySources: []string{}, MapsToComponentType: "network", Tags: []string{"networked", "it_component", "cyber"}, SortOrder: 111},
|
||||
{ID: "C112", NameDE: "Industrie-Router", NameEN: "Industrial Router", Category: "it_network", DescriptionDE: "Router zur Segmentierung und Absicherung des Maschinennetzwerks.", TypicalHazardCategories: []string{"communication_failure", "unauthorized_access"}, TypicalEnergySources: []string{}, MapsToComponentType: "network", Tags: []string{"networked", "it_component", "cyber"}, SortOrder: 112},
|
||||
{ID: "C113", NameDE: "Industrie-Firewall", NameEN: "Industrial Firewall", Category: "it_network", DescriptionDE: "Firewall zum Schutz des OT-Netzwerks vor externen Angriffen.", TypicalHazardCategories: []string{"unauthorized_access"}, TypicalEnergySources: []string{}, MapsToComponentType: "network", Tags: []string{"networked", "it_component", "security_device"}, SortOrder: 113},
|
||||
{ID: "C114", NameDE: "IoT-Gateway", NameEN: "IoT Gateway", Category: "it_network", DescriptionDE: "Gateway fuer die Anbindung von Maschinen an Cloud/Edge.", TypicalHazardCategories: []string{"communication_failure", "unauthorized_access"}, TypicalEnergySources: []string{}, MapsToComponentType: "network", Tags: []string{"networked", "it_component", "has_software"}, SortOrder: 114},
|
||||
{ID: "C115", NameDE: "Edge-Computing-Einheit", NameEN: "Edge Computing Unit", Category: "it_network", DescriptionDE: "Lokale Recheneinheit fuer Datenvorverarbeitung und KI-Inferenz.", TypicalHazardCategories: []string{"software_fault", "communication_failure"}, TypicalEnergySources: []string{}, MapsToComponentType: "network", Tags: []string{"networked", "it_component", "has_software", "has_ai"}, SortOrder: 115},
|
||||
{ID: "C116", NameDE: "WLAN Access Point (Industrie)", NameEN: "Industrial WiFi Access Point", Category: "it_network", DescriptionDE: "Drahtloser Netzwerkzugang im Maschinenumfeld.", TypicalHazardCategories: []string{"communication_failure", "unauthorized_access"}, TypicalEnergySources: []string{}, MapsToComponentType: "network", Tags: []string{"networked", "it_component", "wireless"}, SortOrder: 116},
|
||||
{ID: "C116", NameDE: "WLAN Access Point (Industrie)", NameEN: "Industrial WiFi Access Point", Category: "it_network", DescriptionDE: "Drahtloser Netzwerkzugang im Maschinenumfeld.", TypicalHazardCategories: []string{"communication_failure", "unauthorized_access"}, TypicalEnergySources: []string{}, MapsToComponentType: "network", Tags: []string{"networked", "it_component", "wireless", "cyber"}, SortOrder: 116},
|
||||
{ID: "C117", NameDE: "OPC UA Server", NameEN: "OPC UA Server", Category: "it_network", DescriptionDE: "OPC UA Kommunikationsserver fuer Maschine-zu-Maschine-Vernetzung.", TypicalHazardCategories: []string{"communication_failure", "unauthorized_access"}, TypicalEnergySources: []string{}, MapsToComponentType: "network", Tags: []string{"networked", "it_component", "has_software"}, SortOrder: 117},
|
||||
{ID: "C118", NameDE: "VPN-Appliance", NameEN: "VPN Appliance", Category: "it_network", DescriptionDE: "VPN-Geraet fuer sichere Fernzugriffe auf die Maschinensteuerung.", TypicalHazardCategories: []string{"unauthorized_access"}, TypicalEnergySources: []string{}, MapsToComponentType: "network", Tags: []string{"networked", "it_component", "security_device"}, SortOrder: 118},
|
||||
{ID: "C119", NameDE: "KI-Inferenzmodul", NameEN: "AI Inference Module", Category: "it_network", DescriptionDE: "Dediziertes KI-Modul (GPU/TPU) fuer Echtzeit-Inferenz.", TypicalHazardCategories: []string{"false_classification", "model_drift", "unintended_bias"}, TypicalEnergySources: []string{}, MapsToComponentType: "network", Tags: []string{"has_ai", "has_software", "networked"}, SortOrder: 119},
|
||||
{ID: "C119", NameDE: "KI-Inferenzmodul", NameEN: "AI Inference Module", Category: "it_network", DescriptionDE: "Dediziertes KI-Modul (GPU/TPU) fuer Echtzeit-Inferenz.", TypicalHazardCategories: []string{"false_classification", "model_drift", "unintended_bias"}, TypicalEnergySources: []string{}, MapsToComponentType: "network", Tags: []string{"has_ai", "ai_model", "has_software", "networked", "cyber"}, SortOrder: 119},
|
||||
{ID: "C120", NameDE: "Feldbus-Koppler", NameEN: "Fieldbus Coupler", Category: "it_network", DescriptionDE: "Koppler fuer PROFINET, EtherCAT oder andere Feldbussysteme.", TypicalHazardCategories: []string{"communication_failure"}, TypicalEnergySources: []string{}, MapsToComponentType: "network", Tags: []string{"networked", "it_component"}, SortOrder: 120},
|
||||
|
||||
// ── Extended: Press/Forming Machine Components (C121-C135) ───────────
|
||||
@@ -180,7 +180,7 @@ func GetComponentLibrary() []ComponentLibraryEntry {
|
||||
{ID: "C125", NameDE: "Ruettelplatte / Vibrationsfoerderer", NameEN: "Vibrating Plate / Feeder", Category: "mechanical", DescriptionDE: "Vibrationseinheit zum Sortieren, Ausrichten oder Foerdern von Teilen.", TypicalHazardCategories: []string{"noise_vibration", "ergonomic"}, TypicalEnergySources: []string{"EN01"}, MapsToComponentType: "mechanical", Tags: []string{"vibration_source", "noise_source", "moving_part"}, SortOrder: 125},
|
||||
{ID: "C126", NameDE: "Stempel-Formen-System", NameEN: "Die/Punch Tooling System", Category: "mechanical", DescriptionDE: "Werkzeugset aus Stempel und Matrize fuer Umform- oder Stanzvorgaenge.", TypicalHazardCategories: []string{"mechanical_hazard"}, TypicalEnergySources: []string{"EN01"}, MapsToComponentType: "mechanical", Tags: []string{"moving_part", "high_force", "crush_point", "cutting_part"}, SortOrder: 126},
|
||||
{ID: "C127", NameDE: "Transfersystem (Stangen/Greifer)", NameEN: "Transfer System (Bar/Gripper)", Category: "mechanical", DescriptionDE: "Mechanisches Transportsystem zwischen Bearbeitungsstationen.", TypicalHazardCategories: []string{"mechanical_hazard"}, TypicalEnergySources: []string{"EN01"}, MapsToComponentType: "mechanical", Tags: []string{"moving_part", "shear_risk", "pinch_point"}, SortOrder: 127},
|
||||
{ID: "C128", NameDE: "Aufzugsportal / Hubwerk", NameEN: "Elevator Portal / Hoist", Category: "mechanical", DescriptionDE: "Hebevorrichtung fuer Materialzufuhr (Kisten, Paletten).", TypicalHazardCategories: []string{"mechanical_hazard"}, TypicalEnergySources: []string{"EN01", "EN04"}, MapsToComponentType: "mechanical", Tags: []string{"moving_part", "gravity_risk", "high_force", "person_under_load"}, SortOrder: 128},
|
||||
{ID: "C128", NameDE: "Aufzugsportal / Hubwerk", NameEN: "Elevator Portal / Hoist", Category: "mechanical", DescriptionDE: "Hebevorrichtung fuer Materialzufuhr (Kisten, Paletten).", TypicalHazardCategories: []string{"mechanical_hazard"}, TypicalEnergySources: []string{"EN01", "EN03", "EN04"}, MapsToComponentType: "mechanical", Tags: []string{"moving_part", "gravity_risk", "high_force", "person_under_load", "crush_point"}, SortOrder: 128},
|
||||
{ID: "C129", NameDE: "Fallrohr / Auswurfschacht", NameEN: "Chute / Ejection Channel", Category: "structural", DescriptionDE: "Schwerkraft-basierter Auswurf fuer fertige oder aussortierte Teile.", TypicalHazardCategories: []string{"mechanical_hazard"}, TypicalEnergySources: []string{"EN04"}, MapsToComponentType: "mechanical", Tags: []string{"gravity_risk"}, SortOrder: 129},
|
||||
{ID: "C130", NameDE: "Oelfangschale / Auffangwanne", NameEN: "Oil Drip Tray", Category: "structural", DescriptionDE: "Auffangvorrichtung fuer Hydraulikoel, Schmiermittel, Kuehlmittel.", TypicalHazardCategories: []string{"material_environmental"}, TypicalEnergySources: []string{}, MapsToComponentType: "mechanical", Tags: []string{"chemical_risk"}, SortOrder: 130},
|
||||
{ID: "C131", NameDE: "Druckbegrenzungsventil", NameEN: "Pressure Relief Valve", Category: "hydraulic", DescriptionDE: "Sicherheitsventil zur Druckbegrenzung im Hydraulikkreis.", TypicalHazardCategories: []string{"pneumatic_hydraulic"}, TypicalEnergySources: []string{"EN07"}, MapsToComponentType: "actuator", Tags: []string{"hydraulic_part", "safety_device", "high_pressure"}, SortOrder: 131},
|
||||
|
||||
@@ -83,6 +83,12 @@ type HazardPattern struct {
|
||||
// feeds into the PLr (required Performance Level) computation,
|
||||
// see ComputePLr.
|
||||
DefaultAvoidability int `json:"default_avoidability,omitempty"` // 1 or 2
|
||||
// SecondaryHarms describes consequential damage chains beyond the
|
||||
// classical IACE Hazard→Harm step: end-customer safety, product
|
||||
// liability, food safety, environmental, reputation, financial.
|
||||
// See secondary_harms.go and the strategy discussion (2026-05-20).
|
||||
// Empty for hazards with no downstream chain.
|
||||
SecondaryHarms []SecondaryHarm `json:"secondary_harms,omitempty"`
|
||||
}
|
||||
|
||||
// ComputePLr returns the required Performance Level (PLr) per EN ISO
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
package iace
|
||||
|
||||
// Demonstration patterns showing how the SecondaryHarms field carries
|
||||
// downstream-consequence information through the IACE engine.
|
||||
//
|
||||
// Two real-world scenarios are encoded:
|
||||
//
|
||||
// HP2000 — Glass-shard injection in carbonated-beverage bottling
|
||||
// (the "Cola splitter" example from the IACE strategy
|
||||
// discussion). Primary harm is the operator hit by flying
|
||||
// shards; the secondary chain is product-liability towards
|
||||
// supermarket end-customers.
|
||||
//
|
||||
// HP2001 — Cross-contamination in pharma fill-finish lines.
|
||||
// Primary harm is operator exposure; secondary chain is
|
||||
// patient harm + recall under §74a AMG.
|
||||
//
|
||||
// These two patterns are sufficient as a contract test for the
|
||||
// SecondaryHarms field. Library coverage of more scenarios is a
|
||||
// follow-up task once the persistence layer (DB migration) lands.
|
||||
|
||||
func GetSecondaryHarmDemoPatterns() []HazardPattern {
|
||||
return []HazardPattern{
|
||||
{
|
||||
ID: "HP2000",
|
||||
NameDE: "Glasbruch in Karbonisierungs-Abfueller (Hochdruck)",
|
||||
NameEN: "Glass shatter in carbonated bottling line",
|
||||
RequiredComponentTags: []string{"crush_point", "high_pressure"},
|
||||
RequiredEnergyTags: []string{"pneumatic_pressure"},
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
Priority: 90,
|
||||
MachineTypes: []string{"bottling", "food_processing", "packaging"},
|
||||
ScenarioDE: "Glasflasche platzt unter CO2-Druck waehrend der Abfuellung. " +
|
||||
"Splitter erreichen den Bediener und koennen ferner in nachfolgende " +
|
||||
"Flaschen eingetragen werden.",
|
||||
TriggerDE: "Materialfehler, ueberhoehter Innendruck, Foerderstoss",
|
||||
HarmDE: "Schnittverletzung Auge/Hand des Bedieners",
|
||||
AffectedDE: "Abfueller, Mitarbeiter Linie",
|
||||
ZoneDE: "Karussell, Schutzkapsel, Foerderband-Auslauf",
|
||||
DefaultSeverity: 4,
|
||||
DefaultExposure: 3,
|
||||
ISO12100Section: "6.4.5.5 Schleudernde Teile",
|
||||
SecondaryHarms: []SecondaryHarm{
|
||||
{
|
||||
Type: SecondaryHarmConsumerSafety,
|
||||
Description: "Restsplitter in der Folgeflasche erreichen ueber den Handel " +
|
||||
"den Endkunden. Verletzungsrisiko Mund/Speiseroehre.",
|
||||
LegalBasis: "ProdHaftG §1, VO (EU) Nr. 178/2002 Art. 14",
|
||||
SuggestedMitigations: []string{
|
||||
"Spueltunnel nach Abfuellung",
|
||||
"Inline-Kamera mit Glasbrucherkennung",
|
||||
"Sperrzone fuer 2 Folgeflaschen bei Bruchereignis",
|
||||
"Glasbruchsensor an Karussell mit Linie-Stopp",
|
||||
},
|
||||
Owner: "product_safety",
|
||||
},
|
||||
{
|
||||
Type: SecondaryHarmFoodSafety,
|
||||
Description: "Rueckruf- und Meldepflicht bei Inverkehrbringen unsicherer " +
|
||||
"Lebensmittel; Rueckverfolgbarkeit Chargen-genau erforderlich.",
|
||||
LegalBasis: "VO (EU) 178/2002 Art. 18, 19; LFGB §40",
|
||||
SuggestedMitigations: []string{
|
||||
"Chargen-Tracking bis Endhaendler",
|
||||
"Schnellwarnsystem RASFF aktiviert halten",
|
||||
"Rueckruf-SOP getestet",
|
||||
},
|
||||
Owner: "qm",
|
||||
},
|
||||
{
|
||||
Type: SecondaryHarmReputation,
|
||||
Description: "Pressemitteilung und Aktienkurs-Reaktion bei Verbraucher-" +
|
||||
"verletzungen / behoerdlichem Rueckruf.",
|
||||
LegalBasis: "ISO 31000 Unternehmensrisiko",
|
||||
SuggestedMitigations: []string{
|
||||
"Krisenkommunikations-Plan",
|
||||
"PR-Bereitschaft 24/7",
|
||||
},
|
||||
Owner: "enterprise_risk",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "HP2001",
|
||||
NameDE: "Kreuzkontamination Pharma Fill-Finish",
|
||||
NameEN: "Cross-contamination pharma fill-finish",
|
||||
RequiredComponentTags: []string{"chemical_risk"},
|
||||
RequiredEnergyTags: []string{"pneumatic_pressure"},
|
||||
GeneratedHazardCats: []string{"chemical_hazard"},
|
||||
Priority: 92,
|
||||
MachineTypes: []string{"pharmaceutical", "food_processing"},
|
||||
ScenarioDE: "Wirkstoff-Rueckstand aus Vorcharge im Linienzwischenraum kontaminiert " +
|
||||
"die Folgecharge.",
|
||||
TriggerDE: "Mangelhaftes CIP, Spuelvolumen unterhalb Validierung",
|
||||
HarmDE: "Bedienerexposition bei Probennahme",
|
||||
AffectedDE: "Anlagenbediener, Probenehmer",
|
||||
ZoneDE: "Abfuelllinie zwischen Vorlage und Filler",
|
||||
DefaultSeverity: 4,
|
||||
DefaultExposure: 2,
|
||||
ISO12100Section: "6.4.4 Chemische und biologische Gefaehrdungen",
|
||||
SecondaryHarms: []SecondaryHarm{
|
||||
{
|
||||
Type: SecondaryHarmConsumerSafety,
|
||||
Description: "Patient erhaelt Arzneimittel mit unzulaessiger Beimischung; " +
|
||||
"Wirkungsbeeintraechtigung oder unerwuenschte Wirkung moeglich.",
|
||||
LegalBasis: "AMG §5 (Verkehrsfaehigkeit), §74a (Stufenplan)",
|
||||
SuggestedMitigations: []string{
|
||||
"CIP-Validierung mit TOC- und Conductivity-Limits",
|
||||
"Dedizierte Linien fuer Hochpotente Wirkstoffe",
|
||||
"Stufenplan-Meldung bei Verdacht",
|
||||
},
|
||||
Owner: "qm",
|
||||
},
|
||||
{
|
||||
Type: SecondaryHarmProductLiability,
|
||||
Description: "Haftung des Inverkehrbringers nach AMG §84 (Gefaehrdungshaftung " +
|
||||
"bei Arzneimittelschaeden, verschuldensunabhaengig).",
|
||||
LegalBasis: "AMG §84",
|
||||
SuggestedMitigations: []string{
|
||||
"Deckung Produkthaftpflicht ueber gesetzliches Minimum",
|
||||
"Chargen-Rueckhaltemuster 12 Monate ueber MHD hinaus",
|
||||
},
|
||||
Owner: "legal",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -29,8 +29,18 @@ func GetKeywordDictionary() []KeywordEntry {
|
||||
// ── Foerdertechnik ──────────────────────────────────────────────
|
||||
{Keywords: []string{"foerderband", "transportband", "conveyor"}, ComponentIDs: []string{"C003"}, EnergyIDs: []string{"EN01", "EN02"}, ExtraTags: []string{"entanglement_risk"}},
|
||||
{Keywords: []string{"transfer", "transferanlage", "transfersystem"}, ComponentIDs: []string{"C127"}, ExtraTags: []string{"shear_risk", "pinch_point"}},
|
||||
{Keywords: []string{"aufzug", "elevator", "lift"}, ComponentIDs: []string{"C014", "C128"}, EnergyIDs: []string{"EN04"}, ExtraTags: []string{"gravity_risk", "person_under_load"}},
|
||||
{Keywords: []string{"hubwerk", "hoist", "hubgeraet"}, ComponentIDs: []string{"C128"}, EnergyIDs: []string{"EN04"}, ExtraTags: []string{"gravity_risk", "person_under_load"}},
|
||||
// Hubgeraete: korrigiert auf EN03 (Potentielle/Gravitational) statt
|
||||
// nur EN04 (Elektrisch). Audit-Methode A zeigte, dass HP1014/HP1015/
|
||||
// HP1017/HP1018 (alle Quetsch-Patterns unter absenkender Last) nicht
|
||||
// zuendeten weil sowohl crush_point als auch gravitational fehlten.
|
||||
// EN04 bleibt fuer Steuerstrom-bezogene Patterns mit drin.
|
||||
{Keywords: []string{"aufzug", "elevator", "lift"}, ComponentIDs: []string{"C014", "C128"}, EnergyIDs: []string{"EN03", "EN04"}, ExtraTags: []string{"gravity_risk", "person_under_load", "crush_point"}},
|
||||
{Keywords: []string{"hubwerk", "hoist", "hubgeraet"}, ComponentIDs: []string{"C128"}, EnergyIDs: []string{"EN03", "EN04"}, ExtraTags: []string{"gravity_risk", "person_under_load", "crush_point"}},
|
||||
// Hub-Verben aus Methode-C-Vocabulary-Diff: "absenken/senken/
|
||||
// anheben/heben/hubhoehe" tauchten im Limits-Form auf, der Parser
|
||||
// kannte sie nicht. Konservativ EN03 + Tags, Component bleibt offen.
|
||||
{Keywords: []string{"absenk", "senken", "anheben", "heben"}, EnergyIDs: []string{"EN03"}, ExtraTags: []string{"gravity_risk", "person_under_load", "crush_point"}},
|
||||
{Keywords: []string{"hubhoehe", "hubweg", "hubgeschwindig"}, EnergyIDs: []string{"EN03"}, ExtraTags: []string{"gravity_risk", "crush_point"}},
|
||||
{Keywords: []string{"ruettel", "vibration", "vibrationsfoerderer"}, ComponentIDs: []string{"C125"}, ExtraTags: []string{"vibration_source", "noise_source"}},
|
||||
{Keywords: []string{"fallrohr", "auswurf", "chute"}, ComponentIDs: []string{"C129"}, EnergyIDs: []string{"EN04"}, ExtraTags: []string{"gravity_risk"}},
|
||||
{Keywords: []string{"kistenwechsel", "bin change"}, ComponentIDs: []string{"C134"}, ExtraTags: []string{"ergonomic", "gravity_risk"}},
|
||||
|
||||
@@ -42,5 +42,6 @@ func collectAllPatterns() []HazardPattern {
|
||||
patterns = append(patterns, GetGTBremseHazardPatterns()...) // HP1710-HP1729 GT Bremse coverage gaps
|
||||
patterns = append(patterns, GetISO12100GapPatterns()...) // HP1900-HP1909 ISO 12100 Annex B gaps (Vakuum, Federn, Rutsch, Hochdruckinjektion, Ersticken)
|
||||
patterns = append(patterns, GetCRAPatterns()...) // HP1910-HP1918 CRA / DIN EN 40000-1-2 cyber-resilience spur
|
||||
patterns = append(patterns, GetSecondaryHarmDemoPatterns()...) // HP2000-HP2001 secondary harm chain demos (Cola splitter, Pharma)
|
||||
return patterns
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package iace
|
||||
|
||||
// SecondaryHarm models the consequential damage chain triggered by a primary
|
||||
// hazard. The classical IACE / ISO-12100 model treats Hazard -> Harm as a
|
||||
// single step ("operator gets crushed"). BreakPilot extends this with a
|
||||
// follow-on chain so the risk assessment can address:
|
||||
//
|
||||
// - consumer_safety: end customer exposed to defective product
|
||||
// (e.g. glass shards in a bottled drink that reaches a supermarket)
|
||||
// - product_liability: manufacturer liability under ProdHaftG / EU PLD
|
||||
// - food_safety: traceability and recall obligations (VO 178/2002)
|
||||
// - environmental: spill, contamination, waste-disposal consequence
|
||||
// - reputation: brand damage that escalates to investor / market level
|
||||
// - financial: direct cost (lawsuit, recall, fine)
|
||||
//
|
||||
// This struct is the data contract; persistence is deferred to a future
|
||||
// migration. The pattern library can already attach SecondaryHarms to a
|
||||
// HazardPattern; the API layer surfaces them on hazard generation.
|
||||
//
|
||||
// See memory project_attribution_strategy.md plus the "Cola splitter" worked
|
||||
// example from the IACE strategy discussion (2026-05-20).
|
||||
type SecondaryHarm struct {
|
||||
// Type is one of the SecondaryHarmType* constants below.
|
||||
Type string `json:"type"`
|
||||
|
||||
// Description is a single sentence describing the secondary harm
|
||||
// scenario in concrete terms ("Splitter in Folgeflasche bei
|
||||
// Karussell-Abfueller -> Endkunde verletzt").
|
||||
Description string `json:"description"`
|
||||
|
||||
// LegalBasis cites the legal framework that turns the secondary harm
|
||||
// into an actionable obligation (e.g. "ProdHaftG §1" or "VO 178/2002
|
||||
// Art. 14"). Helps auditors trace the obligation.
|
||||
LegalBasis string `json:"legal_basis,omitempty"`
|
||||
|
||||
// SuggestedMitigations is a free-text list of measures specific to
|
||||
// the secondary chain (e.g. "Spueltunnel", "Inline-Kamera",
|
||||
// "Glasbruchsensor"). Distinct from the primary-mitigations because
|
||||
// they protect downstream stakeholders, not the operator.
|
||||
SuggestedMitigations []string `json:"suggested_mitigations,omitempty"`
|
||||
|
||||
// Owner identifies the role responsible for handling this secondary
|
||||
// harm in the customer organisation. Common values:
|
||||
// "qm" / "product_safety" / "enterprise_risk" / "legal"
|
||||
// Empty if responsibility is shared.
|
||||
Owner string `json:"owner,omitempty"`
|
||||
}
|
||||
|
||||
// SecondaryHarmType constants — kept short and stable.
|
||||
const (
|
||||
SecondaryHarmConsumerSafety = "consumer_safety"
|
||||
SecondaryHarmProductLiability = "product_liability"
|
||||
SecondaryHarmFoodSafety = "food_safety"
|
||||
SecondaryHarmEnvironmental = "environmental"
|
||||
SecondaryHarmReputation = "reputation"
|
||||
SecondaryHarmFinancial = "financial"
|
||||
)
|
||||
|
||||
// AllSecondaryHarmTypes returns the canonical six categories in the order
|
||||
// they should appear in UI dropdowns.
|
||||
func AllSecondaryHarmTypes() []string {
|
||||
return []string{
|
||||
SecondaryHarmConsumerSafety,
|
||||
SecondaryHarmProductLiability,
|
||||
SecondaryHarmFoodSafety,
|
||||
SecondaryHarmEnvironmental,
|
||||
SecondaryHarmReputation,
|
||||
SecondaryHarmFinancial,
|
||||
}
|
||||
}
|
||||
|
||||
// SecondaryHarmLabelDE returns the human-readable German label.
|
||||
func SecondaryHarmLabelDE(t string) string {
|
||||
switch t {
|
||||
case SecondaryHarmConsumerSafety:
|
||||
return "Endkundensicherheit"
|
||||
case SecondaryHarmProductLiability:
|
||||
return "Produkthaftung"
|
||||
case SecondaryHarmFoodSafety:
|
||||
return "Lebensmittelsicherheit"
|
||||
case SecondaryHarmEnvironmental:
|
||||
return "Umweltschaden"
|
||||
case SecondaryHarmReputation:
|
||||
return "Reputation/Marke"
|
||||
case SecondaryHarmFinancial:
|
||||
return "Finanzieller Schaden"
|
||||
}
|
||||
return t
|
||||
}
|
||||
@@ -60,7 +60,30 @@ func (tr *TagResolver) ResolveEnergyTags(energyIDs []string) []string {
|
||||
return tags
|
||||
}
|
||||
|
||||
// ResolveTags combines component, energy, and custom tags into a unified set.
|
||||
// tagSynonyms maps short pattern-side tag names to the canonical
|
||||
// library-side tags. The library uses descriptive identifiers
|
||||
// ("electrical_energy") while many patterns were authored with short
|
||||
// forms ("electrical"). Without this map, the pattern's RequiredTag
|
||||
// "electrical" never matches a real component's "electrical_energy",
|
||||
// and the entire pattern silently never fires. The audit (Method A)
|
||||
// surfaced ~40 such ghost-patterns.
|
||||
//
|
||||
// Each entry expands the parser's tag set when a known synonym appears,
|
||||
// so both forms work for matching. This is the least-invasive fix —
|
||||
// no pattern bodies are touched. The long-term goal is to converge
|
||||
// on a single canonical vocabulary; until then the map documents which
|
||||
// pairs are considered equivalent.
|
||||
var tagSynonyms = map[string][]string{
|
||||
"electrical_energy": {"electrical"},
|
||||
"pneumatic_pressure": {"pneumatic"},
|
||||
"hydraulic_pressure": {"hydraulic"},
|
||||
"electrical": {"electrical_energy"},
|
||||
"pneumatic": {"pneumatic_pressure"},
|
||||
"hydraulic": {"hydraulic_pressure"},
|
||||
}
|
||||
|
||||
// ResolveTags combines component, energy, and custom tags into a unified set,
|
||||
// applying the synonym map so patterns authored with either tag form match.
|
||||
func (tr *TagResolver) ResolveTags(componentIDs, energyIDs, customTags []string) []string {
|
||||
seen := make(map[string]bool)
|
||||
var all []string
|
||||
@@ -71,6 +94,12 @@ func (tr *TagResolver) ResolveTags(componentIDs, energyIDs, customTags []string)
|
||||
seen[t] = true
|
||||
all = append(all, t)
|
||||
}
|
||||
for _, syn := range tagSynonyms[t] {
|
||||
if !seen[syn] {
|
||||
seen[syn] = true
|
||||
all = append(all, syn)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -71,6 +71,8 @@ _ROUTER_MODULES = [
|
||||
"compliance_report_routes",
|
||||
"whistleblower_routes",
|
||||
"tcf_routes",
|
||||
"founding_wizard_routes",
|
||||
"licenses_routes",
|
||||
]
|
||||
|
||||
_loaded_count = 0
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,285 @@
|
||||
"""
|
||||
P18 — Erweiterter Banner-Block fuer die Email.
|
||||
|
||||
Rendert die Daten aus dem consent-tester die heute weggeworfen wurden:
|
||||
- 3-Phasen-Cookie-Tabelle (before_consent / after_reject / after_accept)
|
||||
- Banner-Quality-Score (completeness/correctness/violations)
|
||||
- Per-Category-Tracker-Listing
|
||||
- Violations-Liste mit Rechtsgrundlagen
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def _color_for(pct: int) -> str:
|
||||
return ("#16a34a" if pct >= 80 else
|
||||
"#d97706" if pct >= 50 else "#dc2626")
|
||||
|
||||
|
||||
def _short_phase_label(key: str) -> str:
|
||||
return {
|
||||
"before_consent": "Vor Consent",
|
||||
"after_reject": "Nach Ablehnung",
|
||||
"after_accept": "Nach Annahme",
|
||||
}.get(key, key)
|
||||
|
||||
|
||||
def _phase_color(key: str, cookie_count: int) -> str:
|
||||
if key == "before_consent":
|
||||
return "#16a34a" if cookie_count == 0 else "#dc2626"
|
||||
if key == "after_reject":
|
||||
return "#16a34a" if cookie_count <= 1 else "#d97706"
|
||||
return "#94a3b8"
|
||||
|
||||
|
||||
def build_banner_deep_html(banner_result: dict | None) -> str:
|
||||
"""Render: Banner-Quality + Phases + Violations.
|
||||
|
||||
Konsumiert das volle consent-tester-Response. Komplementiert
|
||||
`build_provider_list_html` (das nur Summary + TCF-Vendor-Tabelle macht).
|
||||
"""
|
||||
if not banner_result:
|
||||
return ""
|
||||
|
||||
parts: list[str] = [
|
||||
'<div style="font-family:-apple-system,BlinkMacSystemFont,sans-serif;'
|
||||
'max-width:700px;margin:0 auto 16px;padding:14px 18px;'
|
||||
'background:#fff;border:1px solid #cbd5e1;border-radius:8px">'
|
||||
'<h3 style="margin:0 0 12px;font-size:14px;color:#0f172a">'
|
||||
'Cookie-Banner — technische Analyse</h3>'
|
||||
]
|
||||
|
||||
# 1) Quality-Score-Cards
|
||||
compl = banner_result.get("completeness_pct")
|
||||
corr = banner_result.get("correctness_pct")
|
||||
summary = banner_result.get("summary") or {}
|
||||
n_critical = summary.get("critical", 0)
|
||||
n_high = summary.get("high", 0)
|
||||
if compl is not None or corr is not None:
|
||||
parts.append(
|
||||
'<table style="width:100%;border-collapse:separate;'
|
||||
'border-spacing:6px;margin-bottom:10px"><tr>'
|
||||
)
|
||||
if compl is not None:
|
||||
c = _color_for(int(compl))
|
||||
parts.append(
|
||||
f'<td style="width:33%;padding:8px 10px;background:#f8fafc;'
|
||||
f'border-radius:5px;border-left:3px solid {c}">'
|
||||
f'<div style="font-size:10px;color:#64748b;text-transform:uppercase">'
|
||||
f'Vollstaendigkeit</div>'
|
||||
f'<div style="font-size:18px;font-weight:700;color:{c}">{compl}%</div>'
|
||||
f'</td>'
|
||||
)
|
||||
if corr is not None:
|
||||
c = _color_for(int(corr))
|
||||
parts.append(
|
||||
f'<td style="width:33%;padding:8px 10px;background:#f8fafc;'
|
||||
f'border-radius:5px;border-left:3px solid {c}">'
|
||||
f'<div style="font-size:10px;color:#64748b;text-transform:uppercase">'
|
||||
f'Korrektheit</div>'
|
||||
f'<div style="font-size:18px;font-weight:700;color:{c}">{corr}%</div>'
|
||||
f'</td>'
|
||||
)
|
||||
viol_c = ("#dc2626" if n_critical + n_high > 0 else
|
||||
"#d97706" if (summary.get("total_violations") or 0) > 0 else
|
||||
"#16a34a")
|
||||
parts.append(
|
||||
f'<td style="width:33%;padding:8px 10px;background:#f8fafc;'
|
||||
f'border-radius:5px;border-left:3px solid {viol_c}">'
|
||||
f'<div style="font-size:10px;color:#64748b;text-transform:uppercase">'
|
||||
f'Verstoesse</div>'
|
||||
f'<div style="font-size:18px;font-weight:700;color:{viol_c}">'
|
||||
f'{summary.get("total_violations", 0)}'
|
||||
f'<span style="font-size:11px;color:#64748b;margin-left:6px">'
|
||||
f'(crit:{n_critical} high:{n_high})</span></div></td>'
|
||||
)
|
||||
parts.append('</tr></table>')
|
||||
|
||||
# 2) 3-Phasen-Tabelle
|
||||
phases = banner_result.get("phases") or {}
|
||||
if phases:
|
||||
parts.append(
|
||||
'<div style="font-size:11px;color:#475569;margin:8px 0 4px;'
|
||||
'font-weight:600">Cookie-Setzungen pro Phase '
|
||||
'(echter Browser-Test):</div>'
|
||||
'<table style="width:100%;border-collapse:collapse;font-size:11px;'
|
||||
'margin-bottom:10px;border:1px solid #e2e8f0">'
|
||||
'<thead><tr style="background:#f1f5f9;color:#475569;text-align:left">'
|
||||
'<th style="padding:5px 8px">Phase</th>'
|
||||
'<th style="padding:5px 8px;text-align:center">Cookies</th>'
|
||||
'<th style="padding:5px 8px;text-align:center">Tracker</th>'
|
||||
'<th style="padding:5px 8px">Auffaelligkeiten</th>'
|
||||
'</tr></thead><tbody>'
|
||||
)
|
||||
for key in ("before_consent", "after_reject", "after_accept"):
|
||||
ph = phases.get(key) or {}
|
||||
if not isinstance(ph, dict): continue
|
||||
cookies = ph.get("cookies") or []
|
||||
trackers = ph.get("tracking_services") or []
|
||||
new_track = ph.get("new_tracking") or []
|
||||
violations = ph.get("violations") or []
|
||||
undoc = ph.get("undocumented") or []
|
||||
color = _phase_color(key, len(cookies))
|
||||
issues_parts = []
|
||||
if violations: issues_parts.append(f"{len(violations)} Verstoss")
|
||||
if new_track: issues_parts.append(f"{len(new_track)} neue Tracker")
|
||||
if undoc: issues_parts.append(f"{len(undoc)} undokumentiert")
|
||||
issues_str = ", ".join(issues_parts) or "—"
|
||||
parts.append(
|
||||
f'<tr style="border-top:1px solid #e2e8f0">'
|
||||
f'<td style="padding:5px 8px;color:#1e293b;font-weight:600">'
|
||||
f'<span style="display:inline-block;width:6px;height:6px;'
|
||||
f'border-radius:50%;background:{color};margin-right:6px"></span>'
|
||||
f'{_short_phase_label(key)}</td>'
|
||||
f'<td style="padding:5px 8px;text-align:center;color:{color};'
|
||||
f'font-weight:600">{len(cookies)}</td>'
|
||||
f'<td style="padding:5px 8px;text-align:center">{len(trackers)}</td>'
|
||||
f'<td style="padding:5px 8px;color:#475569">{issues_str}</td>'
|
||||
f'</tr>'
|
||||
)
|
||||
parts.append('</tbody></table>')
|
||||
|
||||
# 3) Per-Category-Tracker
|
||||
cats = banner_result.get("category_tests") or []
|
||||
if cats:
|
||||
non_essential = [c for c in cats if c.get("category") != "necessary"]
|
||||
if non_essential:
|
||||
parts.append(
|
||||
'<div style="font-size:11px;color:#475569;margin:8px 0 4px;'
|
||||
'font-weight:600">Provider-Listing pro Banner-Kategorie:</div>'
|
||||
'<table style="width:100%;border-collapse:collapse;font-size:11px;'
|
||||
'margin-bottom:10px;border:1px solid #e2e8f0">'
|
||||
'<thead><tr style="background:#f1f5f9;color:#475569;text-align:left">'
|
||||
'<th style="padding:5px 8px">Kategorie</th>'
|
||||
'<th style="padding:5px 8px;text-align:center">Anbieter</th>'
|
||||
'<th style="padding:5px 8px">Hinweis</th>'
|
||||
'</tr></thead><tbody>'
|
||||
)
|
||||
for c in non_essential:
|
||||
n = len(c.get("tracking_services") or [])
|
||||
label = c.get("category_label") or c.get("category", "?")
|
||||
pdv = c.get("provider_details_visible")
|
||||
# P19: echtes Signal aus Click-Through-Test
|
||||
if pdv is False:
|
||||
color, hint = "#dc2626", ("Banner zeigt KEINE Provider-"
|
||||
"Details — keine informierte Einwilligung")
|
||||
elif pdv is True:
|
||||
color, hint = "#16a34a", ""
|
||||
elif n == 0:
|
||||
color, hint = "#d97706", ("Keine Anbieter erkannt (vermutlich "
|
||||
"kein Provider-Listing im Banner)")
|
||||
else:
|
||||
color, hint = "#16a34a", ""
|
||||
parts.append(
|
||||
f'<tr style="border-top:1px solid #e2e8f0">'
|
||||
f'<td style="padding:5px 8px">{label}</td>'
|
||||
f'<td style="padding:5px 8px;text-align:center;color:{color};'
|
||||
f'font-weight:600">{n}</td>'
|
||||
f'<td style="padding:5px 8px;color:#dc2626;font-size:10px">'
|
||||
f'{hint}</td></tr>'
|
||||
)
|
||||
parts.append('</tbody></table>')
|
||||
|
||||
# 4) Violations mit Rechtsgrundlage
|
||||
violations = (banner_result.get("banner_checks") or {}).get("violations", [])
|
||||
if violations:
|
||||
parts.append(
|
||||
'<div style="font-size:11px;color:#475569;margin:8px 0 4px;'
|
||||
'font-weight:600">Erkannte Banner-Verstoesse:</div>'
|
||||
'<ul style="margin:0 0 8px 18px;padding:0;font-size:11px;color:#1e293b">'
|
||||
)
|
||||
for v in violations[:8]:
|
||||
sev = (v.get("severity") or "MEDIUM").upper()
|
||||
sev_c = ("#dc2626" if sev in ("CRITICAL", "HIGH") else
|
||||
"#d97706" if sev == "MEDIUM" else "#94a3b8")
|
||||
parts.append(
|
||||
f'<li style="margin-bottom:6px">'
|
||||
f'<span style="display:inline-block;background:{sev_c};color:#fff;'
|
||||
f'font-size:9px;padding:1px 5px;border-radius:3px;margin-right:6px">'
|
||||
f'{sev}</span>{v.get("text", "")[:200]}'
|
||||
f'<div style="font-size:10px;color:#94a3b8;margin-top:2px;'
|
||||
f'font-style:italic">Quelle: {v.get("legal_ref", "")}</div></li>'
|
||||
)
|
||||
parts.append('</ul>')
|
||||
|
||||
# 5) P59b: Cookie-Behavior-Findings (deklariert vs. tatsaechlich)
|
||||
cb_findings = banner_result.get("cookie_behavior_findings") or []
|
||||
if cb_findings:
|
||||
parts.append(
|
||||
'<div style="margin:14px 0 4px;padding:8px 12px;'
|
||||
'background:#fef9e7;border-left:3px solid #d97706;border-radius:4px">'
|
||||
'<div style="font-size:12px;color:#92400e;font-weight:600;'
|
||||
'margin-bottom:6px">Cookie-Verhaltens-Check '
|
||||
'(P59 — deklarierter Zweck vs. tatsaechliches Verhalten)</div>'
|
||||
'<ul style="margin:0 0 0 18px;padding:0;font-size:11px;color:#1e293b">'
|
||||
)
|
||||
for f in cb_findings[:20]:
|
||||
sev = (f.get("severity") or "MEDIUM").upper()
|
||||
sev_c = ("#dc2626" if sev in ("CRITICAL", "HIGH") else
|
||||
"#d97706" if sev == "MEDIUM" else "#94a3b8")
|
||||
cname = f.get("cookie_name", "?")
|
||||
parts.append(
|
||||
f'<li style="margin-bottom:6px">'
|
||||
f'<span style="display:inline-block;background:{sev_c};color:#fff;'
|
||||
f'font-size:9px;padding:1px 5px;border-radius:3px;margin-right:6px">'
|
||||
f'{sev}</span><code style="font-size:10px;background:#f1f5f9;'
|
||||
f'padding:1px 4px;border-radius:2px">{cname}</code>: '
|
||||
f'{f.get("text", "")[:280]}'
|
||||
f'<div style="font-size:10px;color:#94a3b8;margin-top:2px;'
|
||||
f'font-style:italic">Quelle: {f.get("legal_ref", "")} · '
|
||||
f'Layer {f.get("layer", "?")}</div></li>'
|
||||
)
|
||||
parts.append('</ul></div>')
|
||||
|
||||
# 6) P61: Untergeschobene Cookies/Vendors (Vendor-Package)
|
||||
impl_findings = banner_result.get("implicit_vendor_findings") or []
|
||||
if impl_findings:
|
||||
# Gruppiert nach primary_vendor: pro Primary die mitgelaufenen Items
|
||||
by_primary: dict[str, list[dict]] = {}
|
||||
for f in impl_findings:
|
||||
by_primary.setdefault(f["primary_vendor"], []).append(f["implicit"])
|
||||
parts.append(
|
||||
'<div style="margin:14px 0 4px;padding:8px 12px;'
|
||||
'background:#fef3c7;border-left:3px solid #d97706;border-radius:4px">'
|
||||
'<div style="font-size:12px;color:#92400e;font-weight:600;'
|
||||
'margin-bottom:6px">Untergeschobene Cookies / Vendors '
|
||||
'(P61 — mit Hauptanbieter automatisch mitgeladen)</div>'
|
||||
'<div style="font-size:10px;color:#92400e;margin-bottom:8px">'
|
||||
'Diese Cookies/Vendors kommen automatisch mit dem deklarierten '
|
||||
'Hauptanbieter mit — Marketing-Manager waehlen sie oft nicht '
|
||||
'bewusst aus, sie sind aber zustimmungspflichtig.</div>'
|
||||
)
|
||||
for primary, impls in by_primary.items():
|
||||
parts.append(
|
||||
f'<div style="font-size:11px;color:#1e293b;margin:6px 0">'
|
||||
f'<strong>{primary}</strong> bringt automatisch:</div>'
|
||||
'<ul style="margin:0 0 8px 18px;padding:0;font-size:11px;color:#1e293b">'
|
||||
)
|
||||
for impl in impls:
|
||||
tag = ('<span style="font-size:9px;background:#dc2626;color:#fff;'
|
||||
'padding:1px 5px;border-radius:3px;margin-right:6px">'
|
||||
'COOKIE</span>' if impl["type"] == "cookie" else
|
||||
'<span style="font-size:9px;background:#7c3aed;color:#fff;'
|
||||
'padding:1px 5px;border-radius:3px;margin-right:6px">'
|
||||
'VENDOR</span>')
|
||||
cat_color = {"marketing": "#dc2626", "statistics": "#d97706",
|
||||
"functional": "#0891b2", "essential": "#16a34a"}.get(
|
||||
impl.get("category", ""), "#475569")
|
||||
parts.append(
|
||||
f'<li style="margin-bottom:5px">{tag}'
|
||||
f'<code style="font-size:10px;background:#f1f5f9;'
|
||||
f'padding:1px 4px;border-radius:2px">{impl["name"]}</code> '
|
||||
f'<span style="font-size:9px;color:{cat_color};'
|
||||
f'margin-left:4px">[{impl.get("category","?")}]</span>'
|
||||
f'<div style="font-size:10px;color:#475569;margin-top:2px">'
|
||||
f'{impl.get("why","")[:240]}</div>'
|
||||
f'<div style="font-size:9px;color:#94a3b8;font-style:italic">'
|
||||
f'Quelle: <a href="{impl.get("source_url","")}" '
|
||||
f'style="color:#94a3b8">{impl.get("source_url","")[:80]}</a>'
|
||||
f'</div></li>'
|
||||
)
|
||||
parts.append('</ul>')
|
||||
parts.append('</div>')
|
||||
|
||||
parts.append('</div>')
|
||||
return "".join(parts)
|
||||
@@ -0,0 +1,249 @@
|
||||
"""
|
||||
P18 — Critical-Findings-Block fuer die Executive-Summary.
|
||||
|
||||
Analysiert die echten Daten (banner_checks, phases, scorecard, results) und
|
||||
rendert einen ROTEN Sofortmassnahmen-Block GANZ OBEN in der Email — mit
|
||||
Quellenangaben (DSK, EDPB, EuGH, Behoerden-Buessgeld-Faelle) und konkreten
|
||||
Sofortmassnahmen.
|
||||
|
||||
Regel: Block wird nur gerendert wenn echte kritische Verstoesse vorliegen.
|
||||
Bei sauberen Sites bleibt er weg.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def _truncate_words(text: str, max_chars: int) -> str:
|
||||
"""P65: Truncate at word boundary, never mid-word."""
|
||||
if not text or len(text) <= max_chars:
|
||||
return text
|
||||
cut = text[:max_chars]
|
||||
last_space = cut.rfind(" ")
|
||||
if last_space > max_chars // 2:
|
||||
cut = cut[:last_space]
|
||||
return cut.rstrip(",;:.") + "…"
|
||||
|
||||
|
||||
# Bekannte Buessgeld-Praezedenzfaelle als Quellen-Hint
|
||||
_BUSSGELD_REFS = {
|
||||
"no_provider_per_category": "CNIL France 2023 — TikTok 5 Mio EUR (fehlende Vendor-Transparenz)",
|
||||
"dse_unvollstaendig": "BayLDA 2024 — diverse Mittelstand-Faelle, 5k–50k EUR",
|
||||
"cookie_doc_missing": "LfDI BW 2023 — fehlende Cookie-Erklaerung, 30k EUR",
|
||||
"dark_pattern_reject": "EDPB Guidelines 3/2022 + DSK 2024 — Bussgeldrahmen Art. 83 DSGVO",
|
||||
"schrems_ii": "EuGH C-311/18 (Schrems II) — Bussgeldrahmen bis 4% Konzern-Umsatz",
|
||||
"impressum_im_banner": "LG Rostock 3 O 22/19 — Impressum-Pflicht ueberlagernder Banner",
|
||||
}
|
||||
|
||||
|
||||
def _detect_critical_issues(
|
||||
banner_result: dict | None,
|
||||
scorecard: dict | None,
|
||||
results: list,
|
||||
) -> list[dict]:
|
||||
"""Erkenne kritische Verstoesse aus den vorliegenden Daten."""
|
||||
issues: list[dict] = []
|
||||
br = banner_result or {}
|
||||
sc = scorecard or {}
|
||||
|
||||
# 1) Banner-Violations (HIGH/CRITICAL) aus consent-tester
|
||||
for v in (br.get("banner_checks") or {}).get("violations", []):
|
||||
sev = (v.get("severity") or "").upper()
|
||||
if sev in ("CRITICAL", "HIGH"):
|
||||
issues.append({
|
||||
"key": "banner_violation",
|
||||
"title": _truncate_words(v.get("text", ""), 260),
|
||||
"severity": sev,
|
||||
"action": _action_for_banner_violation(v),
|
||||
"source": v.get("legal_ref", ""),
|
||||
"bussgeld": _BUSSGELD_REFS.get("impressum_im_banner")
|
||||
if "impressum" in (v.get("text") or "").lower()
|
||||
else _BUSSGELD_REFS.get("dark_pattern_reject"),
|
||||
})
|
||||
|
||||
# 2) Category-Tests: Banner zeigt keine Provider-Details pro Kategorie.
|
||||
# Bevorzugt das echte Signal aus dem Click-Through-Test (P19):
|
||||
# provider_details_visible. Fallback: leere tracking_services.
|
||||
cat_tests = br.get("category_tests") or []
|
||||
cats_without_details = [
|
||||
c for c in cat_tests
|
||||
if c.get("category") != "necessary"
|
||||
and (c.get("provider_details_visible") is False
|
||||
or (c.get("provider_details_visible") is None
|
||||
and not c.get("tracking_services")))
|
||||
]
|
||||
if cats_without_details and len(cat_tests) >= 2:
|
||||
cats = ", ".join(c.get("category_label", c.get("category", "?"))
|
||||
for c in cats_without_details)
|
||||
issues.append({
|
||||
"key": "no_provider_per_category",
|
||||
"title": f"Cookie-Banner: Kategorien ({cats}) zeigen keine "
|
||||
f"Provider-/Cookie-Details",
|
||||
"severity": "HIGH",
|
||||
"action": ("Pro Banner-Kategorie eine Liste der eingebundenen "
|
||||
"Anbieter + Cookie-Details (Name, Zweck, Speicherdauer, "
|
||||
"Drittlandtransfer) sichtbar machen — am besten als "
|
||||
"ausklappbares Detail-Panel. Sonst ist die "
|
||||
"Einwilligung nicht 'informiert' nach Art. 7 DSGVO "
|
||||
"und gilt als unwirksam."),
|
||||
"source": "Art. 7 Abs. 1 DSGVO, EDPB Guidelines 2/2023, DSK 2024",
|
||||
"bussgeld": _BUSSGELD_REFS["no_provider_per_category"],
|
||||
})
|
||||
|
||||
# 3) DSGVO/TDDDG-Score < 30%: DSE rechtswidrig
|
||||
pct = int((sc.get("totals") or {}).get("pct", 100))
|
||||
if pct and pct < 30:
|
||||
issues.append({
|
||||
"key": "dse_unvollstaendig",
|
||||
"title": f"Datenschutzerklaerung erfuellt nur {pct}% der Pflichten",
|
||||
"severity": "HIGH",
|
||||
"action": ("Vollstaendig nach Art. 13 DSGVO ueberarbeiten: "
|
||||
"Verantwortlicher, Zwecke, Rechtsgrundlage, "
|
||||
"Speicherdauer, Drittland-Transfers, alle Betroffenen-"
|
||||
"rechte, konkrete Aufsichtsbehoerde."),
|
||||
"source": "Art. 13 DSGVO + Art. 14 (alternativ), DSK-OH Telemedien 2024",
|
||||
"bussgeld": _BUSSGELD_REFS["dse_unvollstaendig"],
|
||||
})
|
||||
|
||||
# 4) Cookie-Richtlinie fehlt komplett (nicht erreichbar)
|
||||
cookie_missing = any(
|
||||
(r.doc_type == "cookie" if hasattr(r, "doc_type") else
|
||||
r.get("doc_type") == "cookie")
|
||||
and ((r.error if hasattr(r, "error") else r.get("error", "")) or "")
|
||||
.startswith("Auf der Website nicht gefunden")
|
||||
for r in (results or [])
|
||||
)
|
||||
cookie_deduped = any(
|
||||
(r.doc_type == "cookie" if hasattr(r, "doc_type") else
|
||||
r.get("doc_type") == "cookie")
|
||||
and "Nicht separat vorhanden" in
|
||||
((r.error if hasattr(r, "error") else r.get("error", "")) or "")
|
||||
for r in (results or [])
|
||||
)
|
||||
if cookie_missing or cookie_deduped:
|
||||
issues.append({
|
||||
"key": "cookie_doc_missing",
|
||||
"title": ("Keine eigenstaendige Cookie-Richtlinie"
|
||||
if cookie_deduped
|
||||
else "Cookie-Richtlinie nicht auffindbar"),
|
||||
"severity": "HIGH",
|
||||
"action": ("Separate Cookie-Richtlinie-Seite erstellen mit "
|
||||
"tabellarischer Auflistung aller Cookies (Name, "
|
||||
"Anbieter, Zweck, Speicherdauer, Drittlandtransfer). "
|
||||
"Direkt aus dem Banner verlinken."),
|
||||
"source": "Art. 13 DSGVO, §25 TDDDG, DSK-OH Telemedien 2024",
|
||||
"bussgeld": _BUSSGELD_REFS["cookie_doc_missing"],
|
||||
})
|
||||
|
||||
# 5) Schrems-II-Risiko: Google/Meta/Microsoft im Banner, aber keine SCC/DPF
|
||||
# Detection: pre-/post-consent-cookies in den phases enthalten US-Tracker
|
||||
phases = br.get("phases") or {}
|
||||
has_us_tracker = False
|
||||
for ph in phases.values():
|
||||
if not isinstance(ph, dict):
|
||||
continue
|
||||
for t in (ph.get("tracking_services") or []):
|
||||
if isinstance(t, dict):
|
||||
name = (t.get("name", "") or "").lower()
|
||||
else:
|
||||
name = str(t).lower()
|
||||
if any(w in name for w in ("google", "meta", "facebook",
|
||||
"microsoft", "linkedin", "tiktok")):
|
||||
has_us_tracker = True
|
||||
break
|
||||
if has_us_tracker:
|
||||
issues.append({
|
||||
"key": "schrems_ii",
|
||||
"title": "US-Tracker geladen — Schrems-II-Risiko",
|
||||
"severity": "HIGH",
|
||||
"action": ("Pro Drittland-Anbieter dokumentieren: SCC (Art. 46 "
|
||||
"DSGVO) ODER DPF-Zertifizierung pruefen + in der "
|
||||
"Datenschutzerklaerung explizit benennen."),
|
||||
"source": "Art. 44 ff. DSGVO, EuGH C-311/18 (Schrems II)",
|
||||
"bussgeld": _BUSSGELD_REFS["schrems_ii"],
|
||||
})
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
def _action_for_banner_violation(v: dict) -> str:
|
||||
text = (v.get("text") or "").lower()
|
||||
if "impressum" in text:
|
||||
return ("Impressum-Link direkt im Banner ergaenzen — bei "
|
||||
"ueberlagerndem Banner Pflicht nach §5 TMG.")
|
||||
if "ablehnen" in text or "dark pattern" in text:
|
||||
return ("'Ablehnen'-Button visuell gleichwertig zu 'Akzeptieren' "
|
||||
"gestalten (gleiche Groesse, Farbe, Position).")
|
||||
if "widerruf" in text or "cookie-einstellungen" in text:
|
||||
return ("Floating-Icon oder Footer-Link 'Cookie-Einstellungen' "
|
||||
"permanent einblenden — Widerruf so einfach wie Erteilung.")
|
||||
return ("Banner-Verstoss beheben gemaess der genannten Rechtsgrundlage.")
|
||||
|
||||
|
||||
def build_critical_findings_html(
|
||||
banner_result: dict | None,
|
||||
scorecard: dict | None,
|
||||
results: list,
|
||||
) -> str:
|
||||
"""Render der Audit-Zusammenfassung fuer die Geschaeftsfuehrung.
|
||||
|
||||
P89: Co-Pilot-Tonalitaet statt Panik-Rot.
|
||||
- Sachlich blau statt alarmistisch rot
|
||||
- "Themen die besprochen werden sollten" statt "VERSTOESSE"
|
||||
- Realistische Zeitschaetzung (4-8 Wochen)
|
||||
- Buessgeld-Risiko in separater, dezenter Section ganz unten
|
||||
- Konfidenz-Hinweis "False-Positives moeglich"
|
||||
"""
|
||||
issues = _detect_critical_issues(banner_result, scorecard, results)
|
||||
if not issues:
|
||||
return ""
|
||||
|
||||
items = []
|
||||
for idx, i in enumerate(issues, 1):
|
||||
# P87-Vorbereitung: keine HIGH-Badges mehr — wir nummerieren stattdessen
|
||||
items.append(
|
||||
f'<div style="margin-bottom:10px;padding:10px 14px;'
|
||||
f'background:#fff;border-radius:6px;'
|
||||
f'border-left:3px solid #2563eb">'
|
||||
f'<div style="font-size:13px;font-weight:600;color:#1e293b;'
|
||||
f'margin-bottom:4px">'
|
||||
f'<span style="display:inline-block;background:#dbeafe;color:#1e40af;'
|
||||
f'padding:1px 8px;border-radius:10px;font-size:10px;'
|
||||
f'margin-right:8px;font-weight:600">Thema {idx}</span>'
|
||||
f'{i["title"]}</div>'
|
||||
f'<div style="font-size:11px;color:#475569;margin-top:6px">'
|
||||
f'<strong>Empfehlung:</strong> {i["action"]}</div>'
|
||||
f'<div style="font-size:10px;color:#94a3b8;margin-top:4px;'
|
||||
f'font-style:italic">Hintergrund: {i.get("source","")}</div>'
|
||||
f'</div>'
|
||||
)
|
||||
|
||||
n = len(issues)
|
||||
plural = "Themen" if n != 1 else "Thema"
|
||||
return (
|
||||
'<div style="font-family:-apple-system,BlinkMacSystemFont,sans-serif;'
|
||||
'max-width:700px;margin:0 auto 18px;padding:18px 22px;'
|
||||
'background:#f0f9ff;border:1px solid #bfdbfe;border-radius:10px">'
|
||||
'<div style="font-size:11px;color:#1e40af;text-transform:uppercase;'
|
||||
'letter-spacing:1.2px;margin-bottom:4px;font-weight:600">'
|
||||
'Zusammenfassung fuer die Geschaeftsfuehrung</div>'
|
||||
f'<h2 style="margin:0 0 8px;font-size:18px;color:#1e293b">'
|
||||
f'{n} {plural} zur Besprechung mit DSB, Marketing und Entwicklung</h2>'
|
||||
'<p style="margin:0 0 14px;font-size:12px;color:#475569;line-height:1.5">'
|
||||
'Wir haben Datenschutzerklaerung, Cookie-Banner, Impressum und '
|
||||
'eingebundene Anbieter technisch analysiert. Die folgenden Punkte '
|
||||
'sollten in den naechsten Wochen geklaert werden — typische '
|
||||
'Umsetzungsdauer 4-8 Wochen (DSB-Review → Marketing-Agentur '
|
||||
'→ Entwicklung → Freigabe). Detaillierte technische '
|
||||
'Analyse mit weiteren Findings finden Sie unten.</p>'
|
||||
+ "".join(items) +
|
||||
'<div style="margin-top:14px;padding:10px 12px;background:#f1f5f9;'
|
||||
'border-radius:6px;font-size:10px;color:#64748b;line-height:1.5">'
|
||||
'<strong style="color:#475569">Hinweis:</strong> Automatisierte '
|
||||
'Audits enthalten False-Positives. Wo unsicher, bitte mit DSB pruefen '
|
||||
'oder uns Feedback geben — wir lernen daraus. '
|
||||
'Rechtliche Risiken (Bussgeld-Rahmen Art. 83 DSGVO bis 4 % des '
|
||||
'weltweiten Jahresumsatzes, realistisch 0,1-1 % bei Erstverstoss '
|
||||
'nach CNIL/LfDI-Massstab) werden weiter unten pro Finding eingeordnet.'
|
||||
'</div>'
|
||||
'</div>'
|
||||
)
|
||||
@@ -26,6 +26,47 @@ def _fmt_eur_range(low: int, high: int) -> str:
|
||||
return f"{low:,}–{high:,} €".replace(",", ".")
|
||||
|
||||
|
||||
def _build_score_band_block(pct: int, color: str) -> list[str]:
|
||||
"""P34 — eine Zeile unter den KPIs: Score-Einordnung."""
|
||||
band, hint = _score_band_explanation(pct)
|
||||
return [
|
||||
f'<div style="margin-top:10px;padding:10px 14px;'
|
||||
f'background:rgba(255,255,255,0.04);border-left:3px solid {color};'
|
||||
f'border-radius:4px">'
|
||||
f'<div style="font-size:11px;color:#cbd5e1">'
|
||||
f'<strong style="color:{color}">{band} ({pct}%)</strong> — {hint}'
|
||||
f'</div></div>',
|
||||
]
|
||||
|
||||
|
||||
def _score_band_explanation(pct: int) -> tuple[str, str]:
|
||||
"""P34 — Was bedeutet der Score: wo MUESSTE man stehen.
|
||||
|
||||
Returns (label, what_to_expect)."""
|
||||
if pct >= 85:
|
||||
return (
|
||||
"Sehr gut", "Praxis-uebliche DSGVO-Risikolage. "
|
||||
"Standard-Pflege reicht — jaehrliche Pruefung empfohlen.",
|
||||
)
|
||||
if pct >= 70:
|
||||
return (
|
||||
"Akzeptabel", "Branchen-Median. Verbleibende Findings sind "
|
||||
"meist Formalia — Empfehlung: einmaliges Aufraeumen, dann "
|
||||
"Halbjahres-Check.",
|
||||
)
|
||||
if pct >= 50:
|
||||
return (
|
||||
"Handlungsbedarf", "Mehrere wesentliche Themen offen. "
|
||||
"Empfehlung: priorisierte Abarbeitung der HIGH-Findings "
|
||||
"binnen 4-8 Wochen mit DSB + Web-Team.",
|
||||
)
|
||||
return (
|
||||
"Erhoehtes Risiko", "Mehrere Kern-Pflichten fehlen oder sind "
|
||||
"veraltet. Empfehlung: kurzfristiger Termin mit DSB / Rechtsabteilung "
|
||||
"und Web-Team zur Priorisierung.",
|
||||
)
|
||||
|
||||
|
||||
def build_exec_summary_html(
|
||||
scorecard: dict | None,
|
||||
previous_scorecard: dict | None,
|
||||
@@ -117,6 +158,9 @@ def build_exec_summary_html(
|
||||
|
||||
'</table>',
|
||||
|
||||
# P34 — Score-Einordnung "wer wo stehen muss"
|
||||
*(_build_score_band_block(pct, score_color) if scorecard else []),
|
||||
|
||||
# CTAs
|
||||
'<div style="margin-top:14px;padding-top:12px;border-top:1px solid '
|
||||
'rgba(255,255,255,0.1);text-align:center">',
|
||||
|
||||
@@ -234,255 +234,9 @@ def _category_label(kat: str) -> str:
|
||||
}.get(kat, kat or "—")
|
||||
|
||||
|
||||
def build_vvt_table_html(vendors: list[dict]) -> str:
|
||||
"""Render the per-vendor VVT-style table for the email report.
|
||||
# VVT-Tabelle (gruppiert + P60/P60b Pattern-Notice) wurde in
|
||||
# vvt_table_renderer.py ausgelagert, damit dieses File unter dem
|
||||
# 500-LOC-Hardcap bleibt. Re-export, damit bestehende Aufrufer (z.B.
|
||||
# agent_compliance_check_routes) unveraendert weiter funktionieren.
|
||||
from compliance.api.vvt_table_renderer import build_vvt_table_html # noqa: E402,F401
|
||||
|
||||
Splits vendors into 3-4 sections by recipient_type (Art. 30(1)(d)
|
||||
DSGVO):
|
||||
|
||||
1. INTERNAL — own departments / own systems
|
||||
2. GROUP_COMPANY — parent/subsidiary (if any)
|
||||
3. PROCESSOR — Auftragsverarbeiter (AVV-pflichtig)
|
||||
4. CONTROLLER — joint/independent controllers (Meta, Google,
|
||||
LinkedIn — they build own profiles)
|
||||
5. AUTHORITY / OTHER — rest
|
||||
|
||||
Within each section: rows sorted by compliance_score ascending so
|
||||
the weakest entries surface first.
|
||||
"""
|
||||
if not vendors:
|
||||
return ""
|
||||
|
||||
# Import here to avoid pulling backend service deps at module load
|
||||
from compliance.services.vendor_classifier import RECIPIENT_TYPE_SECTIONS
|
||||
|
||||
# Bucket vendors by recipient_type
|
||||
by_type: dict[str, list[dict]] = {}
|
||||
for v in vendors:
|
||||
rt = (v.get("recipient_type") or "OTHER").upper()
|
||||
by_type.setdefault(rt, []).append(v)
|
||||
|
||||
# Top summary
|
||||
n_total = len(vendors)
|
||||
n_internal = sum(1 for v in vendors
|
||||
if (v.get("recipient_type") or "").upper()
|
||||
in ("INTERNAL", "GROUP_COMPANY"))
|
||||
n_external = n_total - n_internal
|
||||
n_critical = sum(1 for v in vendors if v.get("compliance_score", 0) < 50)
|
||||
|
||||
summary_parts = [f"{n_total} Verarbeitungen erfasst"]
|
||||
if n_internal and n_external:
|
||||
summary_parts.append(
|
||||
f"— {n_internal} eigene + {n_external} externe Empfaenger"
|
||||
)
|
||||
if n_critical:
|
||||
summary_parts.append(
|
||||
f', <strong style="color:#dc2626">{n_critical} unter 50%</strong>'
|
||||
)
|
||||
else:
|
||||
summary_parts.append("— alle ueber 50%")
|
||||
summary = " ".join(summary_parts)
|
||||
|
||||
out: list[str] = [
|
||||
'<div style="font-family:-apple-system,BlinkMacSystemFont,sans-serif;'
|
||||
'max-width:760px;margin:0 auto 16px;padding:12px 16px;'
|
||||
'background:#fafafa;border:1px solid #e5e7eb;border-radius:8px">',
|
||||
'<h3 style="margin:0 0 4px;font-size:14px;color:#334155">'
|
||||
'VVT-Vorschlag: Verarbeitungstaetigkeiten und Empfaenger aus der '
|
||||
'Cookie-Richtlinie</h3>',
|
||||
f'<p style="margin:0 0 10px;font-size:11px;color:#6b7280">{summary}. '
|
||||
'Gruppiert nach Empfaengerkategorie (Art. 30(1)(d) DSGVO). Innerhalb '
|
||||
'jeder Gruppe nach Compliance-Score sortiert. Bei eigenen '
|
||||
'Verarbeitungen (INTERNAL/GROUP) werden Opt-Out und Privacy-Link '
|
||||
'NICHT als Pflicht gewertet — der Widerruf erfolgt ueber das '
|
||||
'Cookie-Banner, Privacy ist in der Haupt-DSI dokumentiert.</p>',
|
||||
]
|
||||
|
||||
for rtype, section_label in RECIPIENT_TYPE_SECTIONS:
|
||||
rows = by_type.get(rtype) or []
|
||||
if not rows:
|
||||
continue
|
||||
rows = sorted(rows, key=lambda v: v.get("compliance_score", 0))
|
||||
n = len(rows)
|
||||
n_bad = sum(1 for v in rows if v.get("compliance_score", 0) < 50)
|
||||
bad_hint = (f' <span style="color:#dc2626">({n_bad} unter 50%)</span>'
|
||||
if n_bad else "")
|
||||
out.append(
|
||||
f'<h4 style="margin:14px 0 4px;font-size:12px;color:#1e293b;'
|
||||
f'border-top:1px solid #e2e8f0;padding-top:8px">'
|
||||
f'{section_label} <span style="color:#94a3b8;font-weight:400">'
|
||||
f'({n}){bad_hint}</span></h4>'
|
||||
)
|
||||
out.append(_render_vendor_section(rows))
|
||||
|
||||
out.append('</div>')
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def _render_vendor_section(rows: list[dict]) -> str:
|
||||
body: list[str] = [
|
||||
'<table style="width:100%;border-collapse:collapse;font-size:11px">'
|
||||
'<thead><tr style="background:#f1f5f9;color:#475569;text-align:left">'
|
||||
'<th style="padding:5px 8px">Name</th>'
|
||||
'<th style="padding:5px 8px">Kategorie</th>'
|
||||
'<th style="padding:5px 8px">Sitz</th>'
|
||||
'<th style="padding:5px 8px;text-align:center">Cookies</th>'
|
||||
'<th style="padding:5px 8px;text-align:center">Opt-Out</th>'
|
||||
'<th style="padding:5px 8px;text-align:center">Privacy</th>'
|
||||
'<th style="padding:5px 8px;text-align:right">Score</th>'
|
||||
'</tr></thead><tbody>',
|
||||
]
|
||||
for v in rows:
|
||||
body.append(_render_vendor_row_full(v))
|
||||
body.append('</tbody></table>')
|
||||
return "".join(body)
|
||||
|
||||
|
||||
def _render_vendor_row_full(v: dict) -> str:
|
||||
rtype = (v.get("recipient_type") or "OTHER").upper()
|
||||
is_own = rtype in ("INTERNAL", "GROUP_COMPANY")
|
||||
cat = (v.get("category") or "").lower()
|
||||
is_necessary = cat in ("necessary", "strictlynecessary")
|
||||
|
||||
name = v.get("name") or "Unbekannt"
|
||||
category = _category_label(v.get("category", ""))
|
||||
country = v.get("country") or ("—" if is_own else "—")
|
||||
cookies = v.get("cookies") or []
|
||||
n_cookies = len(cookies)
|
||||
score = int(v.get("compliance_score", 0))
|
||||
flags = v.get("compliance_flags") or []
|
||||
|
||||
# Opt-Out: nicht erforderlich fuer eigene Verarbeitung oder
|
||||
# technisch notwendige Cookies (§25 Abs. 2 TDDDG).
|
||||
opt_na_reason = ("Nicht erforderlich (eigene Verarbeitung — "
|
||||
"Widerruf ueber Cookie-Banner)") if is_own else (
|
||||
"Nicht erforderlich (§25 Abs. 2 TDDDG — technisch notwendig)"
|
||||
if is_necessary else None
|
||||
)
|
||||
opt_status = _link_status_badge(
|
||||
v.get("opt_out_url"), v.get("opt_out_ok"), v.get("opt_out_status"),
|
||||
na_label=opt_na_reason,
|
||||
)
|
||||
# Privacy: nicht erforderlich fuer eigene Verarbeitung (Haupt-DSI).
|
||||
privacy_na_reason = (
|
||||
"Nicht erforderlich (eigene Verarbeitung — durch Haupt-DSI abgedeckt)"
|
||||
if is_own else None
|
||||
)
|
||||
privacy_status = _link_status_badge(
|
||||
v.get("privacy_policy_url"), v.get("privacy_ok"),
|
||||
v.get("privacy_status"), na_label=privacy_na_reason,
|
||||
)
|
||||
score_color = ("#16a34a" if score >= 80 else
|
||||
"#d97706" if score >= 50 else "#dc2626")
|
||||
|
||||
# Score-Erklaerung: was wurde gewertet, was fehlt
|
||||
# Annahme: Score = bestandene Kriterien / Gesamtkriterien * 100.
|
||||
# Typisch 5 Kriterien fuer EXT: country, cookies, opt_out, privacy, scoring.
|
||||
# Bei INTERNAL/GROUP: opt_out + privacy nicht gewertet (3 Kriterien).
|
||||
n_criteria = 3 if is_own else 5
|
||||
n_failed = len(flags) if flags else 0
|
||||
score_tooltip = (
|
||||
f"{n_criteria - n_failed} von {n_criteria} Kriterien erfuellt"
|
||||
+ (f" — fehlt: {', '.join(_flag_short(f) for f in flags[:3])}"
|
||||
if flags else "")
|
||||
)
|
||||
|
||||
# Inline-Aktions-Anweisungen pro Flag
|
||||
actions_html = ""
|
||||
if flags:
|
||||
from compliance.services.finding_action_recipes import recipe_for
|
||||
action_items = []
|
||||
for f in flags:
|
||||
rec = recipe_for(f)
|
||||
if not rec:
|
||||
continue
|
||||
action_items.append(
|
||||
f'<li style="margin-bottom:6px"><strong>{_flag_short(f)}:</strong> '
|
||||
f'{rec.get("what", "")}<br/>'
|
||||
f'<span style="color:#475569"><strong>Was tun:</strong> '
|
||||
f'{rec.get("fix_text", "").splitlines()[0][:200]}</span><br/>'
|
||||
f'<span style="color:#94a3b8;font-size:9px">Quelle: '
|
||||
f'{rec.get("why", "")[:160]}</span></li>'
|
||||
)
|
||||
if action_items:
|
||||
actions_html = (
|
||||
f'<details style="margin-top:4px"><summary style="cursor:pointer;'
|
||||
f'color:#dc2626;font-size:10px">Was muss ich tun? '
|
||||
f'({len(action_items)} Action{"s" if len(action_items) != 1 else ""})</summary>'
|
||||
f'<ul style="margin:4px 0 0 14px;padding:0;font-size:10px;color:#1e293b">'
|
||||
+ "".join(action_items)
|
||||
+ '</ul></details>'
|
||||
)
|
||||
|
||||
flag_str = ""
|
||||
if flags:
|
||||
flag_str = (
|
||||
f'<div style="font-size:10px;color:#94a3b8;margin-top:2px">'
|
||||
f'{", ".join(flags[:4])}</div>'
|
||||
f'{actions_html}'
|
||||
)
|
||||
risk = v.get("compliance_risk") or {}
|
||||
risk_label = risk.get("label") or ""
|
||||
risk_badge = ""
|
||||
if risk_label and risk_label != "unklar":
|
||||
rc = {"kritisch": ("#dc2626", "#fff"), "hoch": ("#fecaca", "#991b1b"),
|
||||
"mittel": ("#fde68a", "#92400e"), "gering": ("#d1fae5", "#065f46")}.get(risk_label, ("#e5e7eb", "#475569"))
|
||||
risk_badge = (f'<span style="margin-left:6px;padding:1px 5px;border-radius:3px;font-size:9px;'
|
||||
f'background:{rc[0]};color:{rc[1]}">Risk: {risk_label}</span>')
|
||||
return (
|
||||
f'<tr style="border-top:1px solid #e2e8f0">'
|
||||
f'<td style="padding:6px 8px;color:#1e293b;font-size:11px">'
|
||||
f'{name}{risk_badge}{flag_str}</td>'
|
||||
f'<td style="padding:6px 8px;color:#475569;font-size:11px">{category}</td>'
|
||||
f'<td style="padding:6px 8px;color:#475569;font-size:11px">{country}</td>'
|
||||
f'<td style="padding:6px 8px;text-align:center;color:#475569;font-size:11px">'
|
||||
f'{n_cookies}</td>'
|
||||
f'<td style="padding:6px 8px;text-align:center">{opt_status}</td>'
|
||||
f'<td style="padding:6px 8px;text-align:center">{privacy_status}</td>'
|
||||
f'<td style="padding:6px 8px;text-align:right;font-weight:600;'
|
||||
f'color:{score_color};font-size:11px" title="{score_tooltip}">'
|
||||
f'{score}%<div style="font-size:9px;font-weight:400;color:#94a3b8">'
|
||||
f'{n_criteria - n_failed}/{n_criteria}</div></td>'
|
||||
f'</tr>'
|
||||
)
|
||||
|
||||
|
||||
def _flag_short(f: str) -> str:
|
||||
"""Lesbare deutsche Form fuer einen Flag-Token."""
|
||||
labels = {
|
||||
"no_cookies_listed": "Cookies fehlen",
|
||||
"no_country": "Sitzland fehlt",
|
||||
"no_privacy_url": "Privacy-Link fehlt",
|
||||
"broken_privacy_url": "Privacy-Link broken",
|
||||
"no_opt_out_url": "Opt-Out fehlt",
|
||||
"broken_opt_out": "Opt-Out broken",
|
||||
}
|
||||
return labels.get(f, f)
|
||||
|
||||
|
||||
def _link_status_badge(
|
||||
url: str | None,
|
||||
ok: bool | None,
|
||||
status: int | None,
|
||||
na_label: str | None = None,
|
||||
) -> str:
|
||||
"""Render the link-status cell.
|
||||
|
||||
- url + ok -> green check
|
||||
- url + broken -> red cross with status
|
||||
- no url + na_label -> neutral em-dash with explanation tooltip
|
||||
(used for INTERNAL/necessary rows where the field isn't required)
|
||||
- no url + no na_label -> red cross (real gap)
|
||||
"""
|
||||
if not url:
|
||||
if na_label:
|
||||
return ('<span style="color:#94a3b8;font-size:11px" '
|
||||
f'title="{na_label}">—</span>')
|
||||
return ('<span style="color:#dc2626;font-size:11px" '
|
||||
'title="Kein Link">✗</span>')
|
||||
if ok:
|
||||
return ('<span style="color:#16a34a;font-size:11px" '
|
||||
f'title="HTTP {status}">✓</span>')
|
||||
status_str = str(status) if status else "?"
|
||||
return ('<span style="color:#dc2626;font-size:11px" '
|
||||
f'title="HTTP {status_str}">✗ ({status_str})</span>')
|
||||
|
||||
@@ -202,51 +202,13 @@ def build_management_summary(results: list[DocCheckResult]) -> str:
|
||||
|
||||
|
||||
def _check_to_action(doc_label: str, check_label: str, hint: str) -> str:
|
||||
"""Convert a failed check into a plain-language action item."""
|
||||
# Map technical check labels to business-language actions
|
||||
label_lower = check_label.lower()
|
||||
"""Convert a failed check into a plain-language action item.
|
||||
|
||||
if "datenschutzbeauftragter" in label_lower or "dsb" in label_lower:
|
||||
return (f"<strong>{doc_label}:</strong> Ihren Datenschutzbeauftragten "
|
||||
f"mit Kontaktdaten erwaehnen. Pflicht ab 20 Mitarbeitern.")
|
||||
|
||||
if "beschwerderecht" in label_lower or "art. 77" in label_lower:
|
||||
return (f"<strong>{doc_label}:</strong> Hinweis auf das Beschwerderecht "
|
||||
f"bei der Aufsichtsbehoerde ergaenzen (Name + Kontakt der Behoerde).")
|
||||
|
||||
if "betroffenenrechte" in label_lower:
|
||||
return (f"<strong>{doc_label}:</strong> Alle Betroffenenrechte "
|
||||
f"(Auskunft, Berichtigung, Loeschung, etc.) einzeln auffuehren.")
|
||||
|
||||
if "verantwortlicher" in label_lower:
|
||||
return (f"<strong>{doc_label}:</strong> Vollstaendige Firmenbezeichnung "
|
||||
f"mit Rechtsform, Adresse, E-Mail und Telefon eintragen.")
|
||||
|
||||
if "interessenabwaegung" in label_lower:
|
||||
return (f"<strong>{doc_label}:</strong> Bei 'berechtigtem Interesse' "
|
||||
f"die Abwaegung dokumentieren. Aufgabe fuer den DSB/Rechtsanwalt.")
|
||||
|
||||
if "widerrufsbelehrung" in label_lower or "widerruf" in label_lower:
|
||||
return (f"<strong>{doc_label}:</strong> Gesetzliche Widerrufsbelehrung "
|
||||
f"mit 14-Tage-Frist und Musterformular bereitstellen.")
|
||||
|
||||
if "loeschkonzept" in label_lower:
|
||||
return (f"<strong>{doc_label}:</strong> Loeschfristen und -prozess "
|
||||
f"dokumentieren. Aufgabe fuer den DSB.")
|
||||
|
||||
if "profiling" in label_lower or "art. 22" in label_lower:
|
||||
return (f"<strong>{doc_label}:</strong> Hinweis ergaenzen ob "
|
||||
f"automatisierte Entscheidungen stattfinden oder nicht.")
|
||||
|
||||
if "nicht im eingereichten text" in label_lower:
|
||||
return (f"<strong>{doc_label}:</strong> Das eingereichte Dokument "
|
||||
f"enthaelt nicht den erwarteten Inhalt. Bitte korrekte URL pruefen.")
|
||||
|
||||
# Generic fallback
|
||||
if hint and len(hint) < 150:
|
||||
return f"<strong>{doc_label}:</strong> {hint[:120]}"
|
||||
|
||||
return f"<strong>{doc_label}:</strong> '{check_label}' muss ergaenzt werden."
|
||||
Implementation lives in doc_action_mappings.check_to_action — kept here
|
||||
as a thin wrapper so the report module stays under the 500-LOC cap.
|
||||
"""
|
||||
from compliance.api.doc_action_mappings import check_to_action
|
||||
return check_to_action(doc_label, check_label, hint)
|
||||
|
||||
|
||||
def build_html_report(
|
||||
|
||||
@@ -24,7 +24,7 @@ from compliance.services.unified_findings_store import (
|
||||
findings_summary,
|
||||
list_findings,
|
||||
)
|
||||
from compliance.services.compliance_audit_log import get_check_run
|
||||
from compliance.services.compliance_audit_log import get_check_run, get_check_payload
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -102,3 +102,18 @@ def get_findings(
|
||||
"count": 0,
|
||||
"findings": [],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/banner/{check_id}")
|
||||
def get_banner_payload(check_id: str) -> dict:
|
||||
"""P20: full banner_result (phases, structured_checks, category_tests,
|
||||
banner_checks.violations) fuer das Voll-Audit-Frontend.
|
||||
"""
|
||||
try:
|
||||
payload = get_check_payload(check_id) or {}
|
||||
banner = payload.get("banner") or {}
|
||||
return {"found": bool(banner), "check_id": check_id, "banner": banner}
|
||||
except Exception as e:
|
||||
logger.exception("get_banner_payload failed for %s", check_id)
|
||||
return {"found": False, "check_id": check_id,
|
||||
"error": str(e)[:200], "banner": {}}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
"""
|
||||
GF-freundliche Action-Texte fuer fehlende Pflichtangaben.
|
||||
|
||||
Ausgelagert aus agent_doc_check_report.py (LOC-Cap). Wandelt einen
|
||||
fehlgeschlagenen DocCheck in eine kurze Handlungsanweisung um, die ein
|
||||
Geschaeftsfuehrer ohne juristisches Vorwissen versteht.
|
||||
|
||||
P66: Cookie-spezifische Findings unterscheiden zwischen Service-Zweck
|
||||
(Anbieter-Beschreibung wie "Akamai = Bot-Schutz") und Cookie-Zweck
|
||||
(welches Cookie wozu) — eine haeufige Verwechslung bei Marketing-Managern.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def _cookie_finding_action(doc_label: str, check_label: str) -> str | None:
|
||||
"""P66 — Cookie-spezifische Mappings."""
|
||||
label_lower = check_label.lower()
|
||||
|
||||
if "zwecke der cookies" in label_lower or label_lower == "zwecke":
|
||||
return (f"<strong>{doc_label}:</strong> Zwecke pro Cookie ergaenzen "
|
||||
f"— nicht pro Anbieter. Service-Beschreibungen ('Akamai = "
|
||||
f"Bot-Schutz') beantworten nicht, was das einzelne Cookie "
|
||||
f"tut. Pflicht: pro Cookie (z.B. <code>_abck</code>) den "
|
||||
f"konkreten Zweck angeben ('Bot-Detection-Token, gueltig "
|
||||
f"24h'). DSK-OH Telemedien 2024 §3.2.")
|
||||
|
||||
if "speicherdauer" in label_lower:
|
||||
return (f"<strong>{doc_label}:</strong> Speicherdauer pro Cookie "
|
||||
f"angeben — nicht pauschal 'siehe Anbieter'. Pflicht: "
|
||||
f"konkreter Wert (z.B. '_ga: 2 Jahre', '_gid: 24h', "
|
||||
f"'PHPSESSID: Session'). Werte aus DevTools > "
|
||||
f"Application > Cookies pruefen, Anbieter-Doku ist "
|
||||
f"oft veraltet. Art. 13 Abs. 2 lit. a DSGVO.")
|
||||
|
||||
if "anbieter" in label_lower or "providers_named" in label_lower:
|
||||
return (f"<strong>{doc_label}:</strong> Konkrete Firmen mit Sitz "
|
||||
f"benennen — nicht 'Drittanbieter' oder 'Marketing-Partner'. "
|
||||
f"Pflicht: voller Firmenname + Rechtsform + Land (z.B. "
|
||||
f"'Google Ireland Limited, Dublin'). Art. 13 Abs. 1 lit. e "
|
||||
f"DSGVO (Empfaenger-Pflicht).")
|
||||
|
||||
if "cookie-tabelle" in label_lower or "cookie_list" in label_lower:
|
||||
return (f"<strong>{doc_label}:</strong> Tabellarische Cookie-Liste "
|
||||
f"mit Name, Anbieter, Zweck und Speicherdauer ergaenzen. "
|
||||
f"Reine Anbieter-Beschreibung ohne Cookie-Namen reicht "
|
||||
f"nicht — Nutzer muss nachvollziehen, welches einzelne "
|
||||
f"Cookie was tut. DSK-OH 2024.")
|
||||
|
||||
if "drittland" in label_lower or "schrems" in label_lower:
|
||||
return (f"<strong>{doc_label}:</strong> Pro US-Anbieter (Google, "
|
||||
f"Meta, AWS, Akamai) klaeren: SCC (Art. 46 DSGVO) oder "
|
||||
f"DPF-Zertifizierung — und in der Cookie-Richtlinie "
|
||||
f"explizit nennen. Pauschales 'Anbieter ausserhalb EU' "
|
||||
f"reicht nicht. EuGH Schrems II.")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def check_to_action(doc_label: str, check_label: str, hint: str) -> str:
|
||||
"""Convert a failed check into a plain-language action item."""
|
||||
label_lower = check_label.lower()
|
||||
|
||||
if "datenschutzbeauftragter" in label_lower or "dsb" in label_lower:
|
||||
return (f"<strong>{doc_label}:</strong> Ihren Datenschutzbeauftragten "
|
||||
f"mit Kontaktdaten erwaehnen. Pflicht ab 20 Mitarbeitern.")
|
||||
if "beschwerderecht" in label_lower or "art. 77" in label_lower:
|
||||
return (f"<strong>{doc_label}:</strong> Hinweis auf das Beschwerderecht "
|
||||
f"bei der Aufsichtsbehoerde ergaenzen (Name + Kontakt der Behoerde).")
|
||||
if "betroffenenrechte" in label_lower:
|
||||
return (f"<strong>{doc_label}:</strong> Alle Betroffenenrechte "
|
||||
f"(Auskunft, Berichtigung, Loeschung, etc.) einzeln auffuehren.")
|
||||
if "verantwortlicher" in label_lower:
|
||||
return (f"<strong>{doc_label}:</strong> Vollstaendige Firmenbezeichnung "
|
||||
f"mit Rechtsform, Adresse, E-Mail und Telefon eintragen.")
|
||||
if "interessenabwaegung" in label_lower:
|
||||
return (f"<strong>{doc_label}:</strong> Bei 'berechtigtem Interesse' "
|
||||
f"die Abwaegung dokumentieren. Aufgabe fuer den DSB/Rechtsanwalt.")
|
||||
if "widerrufsbelehrung" in label_lower or "widerruf" in label_lower:
|
||||
return (f"<strong>{doc_label}:</strong> Gesetzliche Widerrufsbelehrung "
|
||||
f"mit 14-Tage-Frist und Musterformular bereitstellen.")
|
||||
if "loeschkonzept" in label_lower:
|
||||
return (f"<strong>{doc_label}:</strong> Loeschfristen und -prozess "
|
||||
f"dokumentieren. Aufgabe fuer den DSB.")
|
||||
if "profiling" in label_lower or "art. 22" in label_lower:
|
||||
return (f"<strong>{doc_label}:</strong> Hinweis ergaenzen ob "
|
||||
f"automatisierte Entscheidungen stattfinden oder nicht.")
|
||||
if "nicht im eingereichten text" in label_lower:
|
||||
return (f"<strong>{doc_label}:</strong> Das eingereichte Dokument "
|
||||
f"enthaelt nicht den erwarteten Inhalt. Bitte korrekte URL pruefen.")
|
||||
if any(w in label_lower for w in ("rechtswidrig", "illegal",
|
||||
"haftungsausschluss", "disclaimer")):
|
||||
return (f"<strong>{doc_label}:</strong> '{check_label}' muss entfernt "
|
||||
f"werden (Anti-Pattern, rechtlich wirkungslos).")
|
||||
|
||||
mapped = _cookie_finding_action(doc_label, check_label)
|
||||
if mapped:
|
||||
return mapped
|
||||
|
||||
if hint and len(hint) < 300:
|
||||
return f"<strong>{doc_label}:</strong> {hint[:280]}"
|
||||
return f"<strong>{doc_label}:</strong> '{check_label}' muss ergaenzt werden."
|
||||
@@ -0,0 +1,283 @@
|
||||
"""FastAPI-Route fuer den Founding-Wizard Document-Generation.
|
||||
|
||||
POST /v1/founding-wizard/generate
|
||||
Body: FoundingWizardState (Wizard-Eingaben)
|
||||
Returns: {documents: [{document_type, title, content_base64, size_bytes, ...}]}
|
||||
|
||||
Templates werden aus compliance_legal_templates geladen, mit dem Wizard-Context
|
||||
gerendert (Handlebars-light) und als .docx-Bytes (base64) zurueckgegeben.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from compliance.services.founding_wizard import (
|
||||
base_context,
|
||||
markdown_to_docx_bytes,
|
||||
render_template,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/v1/founding-wizard", tags=["founding-wizard"])
|
||||
|
||||
DOC_TITLES = {
|
||||
"articles_of_association": "Satzung",
|
||||
"gesellschafterliste": "Gesellschafterliste",
|
||||
"gf_bestellungsbeschluss": "Bestellungsbeschluss Geschäftsführer",
|
||||
"hrb_anmeldung": "Handelsregister-Anmeldung",
|
||||
"sha": "Shareholders' Agreement (SHA)",
|
||||
"geschaeftsordnung_gf": "Geschäftsordnung der Geschäftsführung",
|
||||
"managing_director_employment_contract": "Geschäftsführerdienstvertrag",
|
||||
"ip_assignment_agreement": "IP-Assignment Agreement",
|
||||
"employment_contract_de": "Arbeitsvertrag",
|
||||
"term_sheet": "Term Sheet",
|
||||
"convertible_loan_agreement": "Wandeldarlehensvertrag",
|
||||
"subscription_agreement": "Beteiligungsvertrag",
|
||||
"esop_plan": "ESOP/VSOP-Plan",
|
||||
"cap_table": "Cap Table",
|
||||
}
|
||||
|
||||
|
||||
class GenerationRequest(BaseModel):
|
||||
current_step: int = 8
|
||||
lifecycle_stage: str = "founding"
|
||||
is_pre_notary: bool = True
|
||||
basics: dict[str, Any] = {}
|
||||
gesellschafter: list[dict[str, Any]] = []
|
||||
capital: dict[str, Any] = {}
|
||||
notar: dict[str, Any] = {}
|
||||
sha: dict[str, Any] = {}
|
||||
gf_contracts: list[dict[str, Any]] = []
|
||||
selected_documents: list[str] = []
|
||||
|
||||
|
||||
class DocumentResult(BaseModel):
|
||||
document_type: str
|
||||
title: str
|
||||
filename: str
|
||||
content_base64: str
|
||||
size_bytes: int
|
||||
generated_at: str
|
||||
placeholders_count: int
|
||||
|
||||
|
||||
class GenerationResponse(BaseModel):
|
||||
documents: list[DocumentResult]
|
||||
warnings: list[str] = []
|
||||
|
||||
|
||||
def _load_template(db: Session, document_type: str) -> dict[str, Any] | None:
|
||||
"""Laedt das neueste published Template fuer den document_type."""
|
||||
row = db.execute(
|
||||
text("""
|
||||
SELECT id, document_type, title, content, placeholders, version, status
|
||||
FROM compliance_legal_templates
|
||||
WHERE document_type = :dt AND status = 'published'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
"""),
|
||||
{"dt": document_type},
|
||||
).first()
|
||||
if not row:
|
||||
return None
|
||||
return {
|
||||
"id": str(row.id),
|
||||
"document_type": row.document_type,
|
||||
"title": row.title,
|
||||
"content": row.content,
|
||||
"placeholders": row.placeholders or [],
|
||||
"version": row.version,
|
||||
}
|
||||
|
||||
|
||||
def _safe_slug(name: str) -> str:
|
||||
"""Erzeugt einen filename-tauglichen Slug aus einem Namen."""
|
||||
import re as _re
|
||||
s = _re.sub(r"[^a-zA-Z0-9_-]+", "_", name.strip())
|
||||
return s.strip("_") or "Person"
|
||||
|
||||
|
||||
def _render_one(
|
||||
db: Session,
|
||||
doc_type: str,
|
||||
context: dict[str, Any],
|
||||
name_suffix: str = "",
|
||||
) -> DocumentResult | None:
|
||||
template = _load_template(db, doc_type)
|
||||
if not template:
|
||||
logger.warning("No template found for document_type=%s", doc_type)
|
||||
return None
|
||||
rendered_md = render_template(template["content"], context)
|
||||
title = template.get("title") or DOC_TITLES.get(doc_type, doc_type)
|
||||
if name_suffix:
|
||||
title = f"{title} — {name_suffix}"
|
||||
docx_bytes = markdown_to_docx_bytes(rendered_md, title=None)
|
||||
from datetime import datetime
|
||||
suffix_slug = f"_{_safe_slug(name_suffix)}" if name_suffix else ""
|
||||
company_slug = _safe_slug(context.get("COMPANY_NAME", "Unternehmen"))
|
||||
return DocumentResult(
|
||||
document_type=doc_type,
|
||||
title=title,
|
||||
filename=f"{doc_type}{suffix_slug}_{company_slug}.docx",
|
||||
content_base64=base64.b64encode(docx_bytes).decode("ascii"),
|
||||
size_bytes=len(docx_bytes),
|
||||
generated_at=datetime.utcnow().isoformat() + "Z",
|
||||
placeholders_count=len(template.get("placeholders") or []),
|
||||
)
|
||||
|
||||
|
||||
# Dokumente die PRO Person (Gründer/GF) generiert werden
|
||||
PER_PERSON_DOCS = {
|
||||
"ip_assignment_agreement", # Pro Gründer einer (individuelles IP)
|
||||
"managing_director_employment_contract", # Pro GF einer
|
||||
}
|
||||
|
||||
|
||||
def _build_person_context(
|
||||
base_ctx: dict[str, Any],
|
||||
person: dict[str, Any],
|
||||
doc_type: str,
|
||||
gf_contract: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Erweitert base_context um person-spezifische Felder fuer Per-Person-Dokumente."""
|
||||
ctx = dict(base_ctx)
|
||||
name = person.get("name", "")
|
||||
ctx["ASSIGNOR_NAME"] = name
|
||||
ctx["ASSIGNOR_BIRTHDATE"] = person.get("geburtsdatum", "")
|
||||
ctx["ASSIGNOR_ADDRESS"] = person.get("adresse", "")
|
||||
ctx["ASSIGNOR_ROLE"] = person.get("internal_role") or "Gründer und Geschäftsführer"
|
||||
ctx["HAS_ACADEMIC_BACKGROUND"] = bool(person.get("has_academic_background"))
|
||||
# GF-Vertrag spezifisch
|
||||
ctx["GF_NAME"] = name
|
||||
ctx["GF_BIRTHDATE"] = person.get("geburtsdatum", "")
|
||||
ctx["GF_ADDRESS"] = person.get("adresse", "")
|
||||
ctx["GF_INTERNAL_TITLE"] = person.get("internal_role", "Geschäftsführer")
|
||||
# IP-Bereiche: Person-spezifisch wenn vorhanden
|
||||
ip_areas = person.get("ip_areas") or []
|
||||
if ip_areas:
|
||||
if isinstance(ip_areas, list):
|
||||
ctx["IP_LIST_DETAILS"] = "\n".join(
|
||||
f"- {area}" for area in ip_areas
|
||||
)
|
||||
else:
|
||||
ctx["IP_LIST_DETAILS"] = str(ip_areas)
|
||||
# GF-Contract Daten anwenden wenn vorhanden
|
||||
if gf_contract:
|
||||
if gf_contract.get("gross_annual_salary_eur"):
|
||||
ctx["GROSS_ANNUAL_SALARY_EUR"] = f"{gf_contract['gross_annual_salary_eur']:,}".replace(",", ".")
|
||||
ctx["HAS_BONUS"] = bool(gf_contract.get("has_bonus"))
|
||||
ctx["HAS_COMPANY_CAR"] = bool(gf_contract.get("has_company_car"))
|
||||
ctx["HAS_BAV"] = bool(gf_contract.get("has_bav"))
|
||||
ctx["VACATION_DAYS"] = gf_contract.get("vacation_days", 30)
|
||||
ctx["KUENDIGUNGSFRIST_GESELLSCHAFT_MONATE"] = gf_contract.get("kuendigungsfrist_gesellschaft_monate", 6)
|
||||
ctx["KUENDIGUNGSFRIST_GF_MONATE"] = gf_contract.get("kuendigungsfrist_gf_monate", 3)
|
||||
ctx["HAS_PARA_181_RELEASE"] = bool(gf_contract.get("para_181_release"))
|
||||
ctx["SV_STATUS"] = gf_contract.get("sv_status", "sozialversicherungsfrei")
|
||||
return ctx
|
||||
|
||||
|
||||
@router.post("/generate", response_model=GenerationResponse)
|
||||
def generate_documents(req: GenerationRequest, request: Request) -> GenerationResponse:
|
||||
"""Hauptendpunkt: nimmt Wizard-State entgegen, generiert DOCX fuer alle ausgewaehlten Dokumente."""
|
||||
# Database session is provided via FastAPI dependency injection in production.
|
||||
# Hier vereinfacht direkt aus dem request state (verwendet Hauptverbindung)
|
||||
from classroom_engine.database import SessionLocal
|
||||
db: Session = SessionLocal()
|
||||
try:
|
||||
context = base_context(req.model_dump())
|
||||
results: list[DocumentResult] = []
|
||||
warnings: list[str] = []
|
||||
|
||||
# Gesellschafter + GF-Listen aus Request
|
||||
gesellschafter = req.gesellschafter
|
||||
gf_list = [g for g in gesellschafter if g.get("is_geschaeftsfuehrer")]
|
||||
gf_contracts_map = {
|
||||
c["gesellschafter_id"]: c
|
||||
for c in req.gf_contracts
|
||||
if c.get("gesellschafter_id")
|
||||
}
|
||||
|
||||
for doc_type in req.selected_documents:
|
||||
if doc_type in PER_PERSON_DOCS:
|
||||
# Pro Person ein Dokument
|
||||
if doc_type == "ip_assignment_agreement":
|
||||
# IP-Assignment: pro Gründer (alle Gesellschafter, nicht nur GFs)
|
||||
persons = gesellschafter or [{}]
|
||||
elif doc_type == "managing_director_employment_contract":
|
||||
# GF-Vertrag: nur pro GF
|
||||
persons = gf_list or [{}]
|
||||
else:
|
||||
persons = [{}]
|
||||
if not persons:
|
||||
warnings.append(f"Keine Personen für '{doc_type}' vorhanden")
|
||||
continue
|
||||
for p in persons:
|
||||
contract = gf_contracts_map.get(p.get("id"))
|
||||
person_ctx = _build_person_context(context, p, doc_type, contract)
|
||||
result = _render_one(
|
||||
db, doc_type, person_ctx,
|
||||
name_suffix=p.get("name", "")
|
||||
)
|
||||
if result is None:
|
||||
warnings.append(f"Template '{doc_type}' nicht in Datenbank gefunden")
|
||||
break
|
||||
results.append(result)
|
||||
else:
|
||||
# Standard: ein Dokument pro Auswahl
|
||||
result = _render_one(db, doc_type, context)
|
||||
if result is None:
|
||||
warnings.append(f"Template '{doc_type}' nicht in Datenbank gefunden")
|
||||
continue
|
||||
results.append(result)
|
||||
|
||||
if not results:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Keines der angeforderten Dokumente konnte generiert werden. "
|
||||
f"Warnings: {warnings}"
|
||||
)
|
||||
|
||||
return GenerationResponse(documents=results, warnings=warnings)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/templates")
|
||||
def list_available_templates(request: Request) -> dict[str, Any]:
|
||||
"""Listet alle verfuegbaren Templates mit Kategorisierung."""
|
||||
from classroom_engine.database import SessionLocal
|
||||
db: Session = SessionLocal()
|
||||
try:
|
||||
rows = db.execute(
|
||||
text("""
|
||||
SELECT document_type, title, description, version, status,
|
||||
lifecycle_stage, functional_category
|
||||
FROM compliance_legal_templates
|
||||
WHERE status = 'published'
|
||||
ORDER BY functional_category, document_type
|
||||
""")
|
||||
).fetchall()
|
||||
return {
|
||||
"templates": [
|
||||
{
|
||||
"document_type": r.document_type,
|
||||
"title": r.title,
|
||||
"description": r.description,
|
||||
"version": r.version,
|
||||
"lifecycle_stage": list(r.lifecycle_stage or []),
|
||||
"functional_category": r.functional_category,
|
||||
}
|
||||
for r in rows
|
||||
],
|
||||
"count": len(rows),
|
||||
}
|
||||
finally:
|
||||
db.close()
|
||||
@@ -0,0 +1,306 @@
|
||||
"""License attribution endpoints — Task #23 Stufe 1-4.
|
||||
|
||||
The audit (Task #22) classified all 314,811 canonical_controls into
|
||||
license_rule 1/2/3. The frontend, PDF renderer, and tech-file generator
|
||||
now need to surface that classification in the form of:
|
||||
|
||||
- Stufe 1: a global /licenses overview page
|
||||
- Stufe 2: an auto-footer in every exported PDF
|
||||
- Stufe 3: an inline source badge on every rendered hazard/measure
|
||||
- Stufe 4: a sources appendix in tech-file bundles
|
||||
|
||||
This module exposes three endpoints that all four stages consume:
|
||||
|
||||
GET /api/compliance/licenses/overview
|
||||
Global aggregation by rule + per-source counts. Drives Stufe 1.
|
||||
|
||||
POST /api/compliance/licenses/aggregate
|
||||
Body: {"control_uuids": ["uuid1", ...]}.
|
||||
Returns per-rule grouping with source breakdown. Used by PDF
|
||||
footer (Stufe 2) and tech-file appendix (Stufe 4) to build the
|
||||
"sources used in this document" list.
|
||||
|
||||
GET /api/compliance/licenses/source-info/{control_uuid}
|
||||
Single-control lookup for the inline source badge tooltip
|
||||
(Stufe 3). Returns rule, source regulation, attribution text.
|
||||
|
||||
Why a new module instead of extending canonical_control_routes:
|
||||
- canonical_control_routes serves the legacy SPDX-style license matrix
|
||||
(canonical_control_licenses + canonical_control_sources, ~10 rows).
|
||||
- This module is built on regulation_registry (252 rows) + the
|
||||
license_rule on each control. Both schemas coexist; this module
|
||||
doesn't disturb the legacy endpoints.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from classroom_engine.database import get_db
|
||||
|
||||
router = APIRouter(prefix="/licenses", tags=["licenses"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Rule labels — used by frontend renderer
|
||||
# ============================================================================
|
||||
|
||||
RULE_LABELS = {
|
||||
1: {
|
||||
"code": "R1",
|
||||
"label_de": "Wörtlich übernehmbar",
|
||||
"label_en": "Verbatim, no attribution required",
|
||||
"render_full_text": True,
|
||||
"attribution_required": False,
|
||||
},
|
||||
2: {
|
||||
"code": "R2",
|
||||
"label_de": "Wörtlich mit Attribution",
|
||||
"label_en": "Verbatim with attribution",
|
||||
"render_full_text": True,
|
||||
"attribution_required": True,
|
||||
},
|
||||
3: {
|
||||
"code": "R3",
|
||||
"label_de": "Nur Identifier zitieren",
|
||||
"label_en": "Identifier citation only",
|
||||
"render_full_text": False,
|
||||
"attribution_required": False,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Response Schemas
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class SourceCount(BaseModel):
|
||||
regulation_id: str
|
||||
regulation_name_de: Optional[str]
|
||||
license_rule: int
|
||||
license_type: Optional[str]
|
||||
attribution: Optional[str]
|
||||
jurisdiction: Optional[str]
|
||||
source_type: Optional[str]
|
||||
n_controls: int
|
||||
|
||||
|
||||
class RuleBucket(BaseModel):
|
||||
rule: int
|
||||
label_de: str
|
||||
label_en: str
|
||||
attribution_required: bool
|
||||
render_full_text: bool
|
||||
total_controls: int
|
||||
distinct_sources: int
|
||||
sources: list[SourceCount]
|
||||
|
||||
|
||||
class OverviewResponse(BaseModel):
|
||||
total_controls: int
|
||||
buckets: list[RuleBucket]
|
||||
|
||||
|
||||
class AggregateRequest(BaseModel):
|
||||
control_uuids: list[UUID]
|
||||
|
||||
|
||||
class AggregateResponse(BaseModel):
|
||||
total_in_request: int
|
||||
matched: int
|
||||
buckets: list[RuleBucket]
|
||||
|
||||
|
||||
class SourceInfo(BaseModel):
|
||||
control_uuid: UUID
|
||||
license_rule: Optional[int]
|
||||
license_label_de: Optional[str]
|
||||
attribution_required: bool
|
||||
render_full_text: bool
|
||||
regulation_id: Optional[str]
|
||||
regulation_name_de: Optional[str]
|
||||
license_type: Optional[str]
|
||||
attribution: Optional[str]
|
||||
source_url: Optional[str]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Endpoints
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def _bucket(rule: int, sources: list[SourceCount]) -> RuleBucket:
|
||||
meta = RULE_LABELS.get(rule, RULE_LABELS[3])
|
||||
return RuleBucket(
|
||||
rule=rule,
|
||||
label_de=meta["label_de"],
|
||||
label_en=meta["label_en"],
|
||||
attribution_required=meta["attribution_required"],
|
||||
render_full_text=meta["render_full_text"],
|
||||
total_controls=sum(s.n_controls for s in sources),
|
||||
distinct_sources=len(sources),
|
||||
sources=sources,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/overview", response_model=OverviewResponse)
|
||||
def licenses_overview(db: Session = Depends(get_db)) -> OverviewResponse:
|
||||
"""Global aggregation: total controls by rule, with per-source breakdown.
|
||||
|
||||
Drives Stufe 1 (the /licenses page).
|
||||
"""
|
||||
rows = db.execute(text("""
|
||||
SELECT
|
||||
COALESCE(cpl.source_regulation, '(no source)') AS regulation_name,
|
||||
cc.license_rule,
|
||||
COUNT(DISTINCT cc.id) AS n
|
||||
FROM compliance.canonical_controls cc
|
||||
LEFT JOIN compliance.control_parent_links cpl ON cpl.control_uuid = cc.id
|
||||
WHERE cc.license_rule IS NOT NULL
|
||||
GROUP BY 1, 2
|
||||
""")).fetchall()
|
||||
|
||||
reg_rows = db.execute(text("""
|
||||
SELECT regulation_name_de, regulation_id, license_type, attribution,
|
||||
jurisdiction, source_type
|
||||
FROM compliance.regulation_registry
|
||||
""")).fetchall()
|
||||
reg_by_name = {r.regulation_name_de: r for r in reg_rows if r.regulation_name_de}
|
||||
|
||||
by_rule: dict[int, list[SourceCount]] = {1: [], 2: [], 3: []}
|
||||
seen: dict[tuple[int, str], int] = {}
|
||||
total = 0
|
||||
for row in rows:
|
||||
rule = int(row.license_rule)
|
||||
name = row.regulation_name
|
||||
n = int(row.n)
|
||||
key = (rule, name)
|
||||
# multiple cpl entries per control deduplicate via DISTINCT, but a
|
||||
# control with several source_regulations still gets counted once
|
||||
# per regulation — that's the design.
|
||||
seen[key] = seen.get(key, 0) + n
|
||||
total += n
|
||||
|
||||
for (rule, name), n in seen.items():
|
||||
reg = reg_by_name.get(name)
|
||||
by_rule.setdefault(rule, []).append(SourceCount(
|
||||
regulation_id=reg.regulation_id if reg else name,
|
||||
regulation_name_de=name,
|
||||
license_rule=rule,
|
||||
license_type=reg.license_type if reg else None,
|
||||
attribution=reg.attribution if reg else None,
|
||||
jurisdiction=reg.jurisdiction if reg else None,
|
||||
source_type=reg.source_type if reg else None,
|
||||
n_controls=n,
|
||||
))
|
||||
|
||||
for r in by_rule.values():
|
||||
r.sort(key=lambda s: -s.n_controls)
|
||||
buckets = [_bucket(rule, sources) for rule, sources in sorted(by_rule.items())]
|
||||
return OverviewResponse(total_controls=total, buckets=buckets)
|
||||
|
||||
|
||||
@router.post("/aggregate", response_model=AggregateResponse)
|
||||
def aggregate_for_controls(
|
||||
body: AggregateRequest,
|
||||
db: Session = Depends(get_db),
|
||||
) -> AggregateResponse:
|
||||
"""Per-control license aggregation for PDF footer (Stufe 2) and
|
||||
tech-file sources appendix (Stufe 4).
|
||||
|
||||
Returns a per-rule breakdown of which sources contributed to the
|
||||
supplied control set. The frontend renderer turns this into the
|
||||
"Verwendete Quellen" footer.
|
||||
"""
|
||||
if not body.control_uuids:
|
||||
return AggregateResponse(total_in_request=0, matched=0, buckets=[])
|
||||
|
||||
rows = db.execute(text("""
|
||||
SELECT
|
||||
COALESCE(cpl.source_regulation, '(unknown)') AS regulation_name,
|
||||
cc.license_rule,
|
||||
COUNT(DISTINCT cc.id) AS n
|
||||
FROM compliance.canonical_controls cc
|
||||
LEFT JOIN compliance.control_parent_links cpl ON cpl.control_uuid = cc.id
|
||||
WHERE cc.id = ANY(:ids) AND cc.license_rule IS NOT NULL
|
||||
GROUP BY 1, 2
|
||||
"""), {"ids": [str(u) for u in body.control_uuids]}).fetchall()
|
||||
|
||||
reg_rows = db.execute(text("""
|
||||
SELECT regulation_name_de, regulation_id, license_type, attribution,
|
||||
jurisdiction, source_type
|
||||
FROM compliance.regulation_registry
|
||||
""")).fetchall()
|
||||
reg_by_name = {r.regulation_name_de: r for r in reg_rows if r.regulation_name_de}
|
||||
|
||||
by_rule: dict[int, list[SourceCount]] = {1: [], 2: [], 3: []}
|
||||
matched_total = 0
|
||||
for row in rows:
|
||||
rule = int(row.license_rule)
|
||||
n = int(row.n)
|
||||
matched_total += n
|
||||
reg = reg_by_name.get(row.regulation_name)
|
||||
by_rule.setdefault(rule, []).append(SourceCount(
|
||||
regulation_id=reg.regulation_id if reg else row.regulation_name,
|
||||
regulation_name_de=row.regulation_name,
|
||||
license_rule=rule,
|
||||
license_type=reg.license_type if reg else None,
|
||||
attribution=reg.attribution if reg else None,
|
||||
jurisdiction=reg.jurisdiction if reg else None,
|
||||
source_type=reg.source_type if reg else None,
|
||||
n_controls=n,
|
||||
))
|
||||
for r in by_rule.values():
|
||||
r.sort(key=lambda s: -s.n_controls)
|
||||
buckets = [_bucket(rule, sources) for rule, sources in sorted(by_rule.items()) if sources]
|
||||
return AggregateResponse(
|
||||
total_in_request=len(body.control_uuids),
|
||||
matched=matched_total,
|
||||
buckets=buckets,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/source-info/{control_uuid}", response_model=SourceInfo)
|
||||
def source_info_for_control(
|
||||
control_uuid: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
) -> SourceInfo:
|
||||
"""Single-control source info for the inline source badge (Stufe 3).
|
||||
|
||||
Used by the React `<SourceBadge>` component to populate its tooltip.
|
||||
"""
|
||||
row = db.execute(text("""
|
||||
SELECT cc.license_rule, cpl.source_regulation AS regulation_name,
|
||||
r.regulation_id, r.license_type, r.attribution, r.url AS source_url
|
||||
FROM compliance.canonical_controls cc
|
||||
LEFT JOIN compliance.control_parent_links cpl ON cpl.control_uuid = cc.id
|
||||
LEFT JOIN compliance.regulation_registry r ON r.regulation_name_de = cpl.source_regulation
|
||||
WHERE cc.id = :uuid
|
||||
LIMIT 1
|
||||
"""), {"uuid": str(control_uuid)}).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="control not found")
|
||||
|
||||
rule = int(row.license_rule) if row.license_rule is not None else None
|
||||
meta = RULE_LABELS.get(rule, {}) if rule else {}
|
||||
return SourceInfo(
|
||||
control_uuid=control_uuid,
|
||||
license_rule=rule,
|
||||
license_label_de=meta.get("label_de"),
|
||||
attribution_required=meta.get("attribution_required", False),
|
||||
render_full_text=meta.get("render_full_text", False),
|
||||
regulation_id=row.regulation_id,
|
||||
regulation_name_de=row.regulation_name,
|
||||
license_type=row.license_type,
|
||||
attribution=row.attribution,
|
||||
source_url=row.source_url,
|
||||
)
|
||||
@@ -0,0 +1,97 @@
|
||||
"""
|
||||
P62 — Marketing-Manager-freundlicher Scope-Disclaimer ("Was wir sehen / nicht sehen").
|
||||
|
||||
Erklaert in 30 Sekunden was unser Audit tatsaechlich pruefen kann und wo
|
||||
die Grenzen sind. Ziel: vermeidet falsches Vertrauen in einen 100%-Score
|
||||
und macht klar, wo Marketing/IT zusaetzlich pruefen muss.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def build_scope_disclaimer_html() -> str:
|
||||
"""Render: was wir sehen + was wir NICHT sehen koennen."""
|
||||
return (
|
||||
'<div style="font-family:-apple-system,BlinkMacSystemFont,sans-serif;'
|
||||
'max-width:700px;margin:8px auto 16px;padding:14px 18px;'
|
||||
'background:#f0f9ff;border:1px solid #bfdbfe;border-radius:8px">'
|
||||
'<h3 style="margin:0 0 8px;font-size:13px;color:#1e40af">'
|
||||
'Was diese Pruefung leistet — und wo ihre Grenzen liegen</h3>'
|
||||
|
||||
'<div style="font-size:11px;color:#1e293b;margin-bottom:10px">'
|
||||
'Wir sind ein <strong>technisches Audit-Tool</strong>, kein Anwalt. '
|
||||
'Ein 100%-Score bedeutet nicht "rechtssicher" — er bedeutet "alle '
|
||||
'Pruefkriterien automatisch erfuellt". Folgendes koennen wir vs. '
|
||||
'koennen wir nicht:</div>'
|
||||
|
||||
'<table style="width:100%;border-collapse:collapse;font-size:11px;'
|
||||
'margin-bottom:8px">'
|
||||
'<thead><tr style="background:#dbeafe;color:#1e40af;text-align:left">'
|
||||
'<th style="padding:5px 8px;width:50%">Was wir sehen</th>'
|
||||
'<th style="padding:5px 8px;width:50%">Was wir NICHT sehen</th>'
|
||||
'</tr></thead>'
|
||||
'<tbody>'
|
||||
|
||||
'<tr style="border-top:1px solid #bfdbfe">'
|
||||
'<td style="padding:5px 8px;color:#1e293b">'
|
||||
'✓ Cookies/Storage im Browser nach Klick auf Akzeptieren/Ablehnen'
|
||||
'</td>'
|
||||
'<td style="padding:5px 8px;color:#475569">'
|
||||
'✗ Server-seitiges Tracking (Meta Conversion API, GA4 Measurement '
|
||||
'Protocol — der Browser sieht nichts davon)'
|
||||
'</td></tr>'
|
||||
|
||||
'<tr style="border-top:1px solid #bfdbfe">'
|
||||
'<td style="padding:5px 8px;color:#1e293b">'
|
||||
'✓ Vendor-Listen aus dem Banner (TCF, CMP-Settings, Phase-G Klick-Tour)'
|
||||
'</td>'
|
||||
'<td style="padding:5px 8px;color:#475569">'
|
||||
'✗ Wer die Daten beim Vendor tatsaechlich erhaelt / weiterleitet '
|
||||
'(z.B. Google verteilt intern an Ads/Marketing-Plattform)'
|
||||
'</td></tr>'
|
||||
|
||||
'<tr style="border-top:1px solid #bfdbfe">'
|
||||
'<td style="padding:5px 8px;color:#1e293b">'
|
||||
'✓ Texte und Pflichtangaben in DSE/Cookie-Richtlinie/Impressum'
|
||||
'</td>'
|
||||
'<td style="padding:5px 8px;color:#475569">'
|
||||
'✗ Ob die internen Prozesse (Loeschkonzept, AVV-Pflege, '
|
||||
'Mitarbeiter-Schulungen) tatsaechlich gelebt werden'
|
||||
'</td></tr>'
|
||||
|
||||
'<tr style="border-top:1px solid #bfdbfe">'
|
||||
'<td style="padding:5px 8px;color:#1e293b">'
|
||||
'✓ Banner-UI-Verstoesse (Dark Patterns, ungleichgewichtige Buttons, '
|
||||
'fehlender Reject-Mechanismus)'
|
||||
'</td>'
|
||||
'<td style="padding:5px 8px;color:#475569">'
|
||||
'✗ Ob das Banner auf <em>jeder</em> Unterseite identisch ist '
|
||||
'(wir messen die Einstiegsseite)'
|
||||
'</td></tr>'
|
||||
|
||||
'<tr style="border-top:1px solid #bfdbfe">'
|
||||
'<td style="padding:5px 8px;color:#1e293b">'
|
||||
'✓ Untergeschobene Cookies (z.B. Google Tag Manager bringt automatisch '
|
||||
'GA + Ads — siehe P61-Block unten)'
|
||||
'</td>'
|
||||
'<td style="padding:5px 8px;color:#475569">'
|
||||
'✗ Drittland-Transfer auf Vertragsebene — ob ein SCC/DPF wirklich '
|
||||
'vorliegt, koennen nur Sie selbst pruefen'
|
||||
'</td></tr>'
|
||||
|
||||
'</tbody></table>'
|
||||
|
||||
'<div style="font-size:10px;color:#475569;margin-top:8px;'
|
||||
'padding-top:6px;border-top:1px dashed #bfdbfe">'
|
||||
'<strong>Hinweis fuer Marketing & Geschaeftsfuehrung:</strong> '
|
||||
'Selbst wenn dieser Bericht keinen Verstoss findet, kann ein '
|
||||
'individueller Bescheid einer Aufsichtsbehoerde oder eine Klage '
|
||||
'(NOYB, Verbraucherschutz, Sammelklage) zu einem anderen Ergebnis '
|
||||
'kommen — etwa wenn beim Vendor selbst (Server-Side) personenbezogene '
|
||||
'Daten verarbeitet werden, die wir browser-seitig nicht sehen. '
|
||||
'Dieser Bericht ersetzt keine anwaltliche Pruefung, hilft aber, '
|
||||
'<strong>technisch belegbare Verstoesse</strong> sofort zu schliessen.'
|
||||
'</div>'
|
||||
|
||||
'</div>'
|
||||
)
|
||||
@@ -0,0 +1,330 @@
|
||||
"""
|
||||
VVT-Tabelle fuer den Email-Report — pro Vendor eine Zeile, gruppiert
|
||||
nach Empfaengerkategorie (Art. 30(1)(d) DSGVO).
|
||||
|
||||
Ausgelagert aus agent_doc_check_extras.py (LOC-Cap). Enthaelt:
|
||||
* build_vvt_table_html — Haupteinstieg, gruppiert + summary + P60 notice
|
||||
* _render_vendor_section / _render_vendor_row_full — Zeilenrenderer
|
||||
* _link_status_badge / _flag_short — kleine Helper
|
||||
|
||||
P60b Fuzzy-Match: Vendors mit teilweise befuellten Feldern (z.B. Sitzland
|
||||
eingetragen) fallen nicht aus der Pattern-Notice raus, nur weil ihr
|
||||
Flag-Set um 1-2 Items kleiner ist. Jaccard >= 0.7 deckt das ab.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def _category_label(kat: str) -> str:
|
||||
return {
|
||||
"necessary": "Notwendig", "strictlynecessary": "Notwendig",
|
||||
"preferences": "Praeferenzen", "functional": "Funktional",
|
||||
"statistics": "Statistik", "marketing": "Marketing",
|
||||
"unclassified": "Unklassifiziert",
|
||||
}.get((kat or "").lower(), kat or "—")
|
||||
|
||||
|
||||
def _flag_short(f: str) -> str:
|
||||
"""Lesbare deutsche Form fuer einen Flag-Token."""
|
||||
labels = {
|
||||
"no_cookies_listed": "Cookies fehlen",
|
||||
"no_country": "Sitzland fehlt",
|
||||
"no_privacy_url": "Privacy-Link fehlt",
|
||||
"broken_privacy_url": "Privacy-Link broken",
|
||||
"no_opt_out_url": "Opt-Out fehlt",
|
||||
"broken_opt_out": "Opt-Out broken",
|
||||
}
|
||||
return labels.get(f, f)
|
||||
|
||||
|
||||
def _link_status_badge(
|
||||
url: str | None,
|
||||
ok: bool | None,
|
||||
status: int | None,
|
||||
na_label: str | None = None,
|
||||
) -> str:
|
||||
if not url:
|
||||
if na_label:
|
||||
return ('<span style="color:#94a3b8;font-size:11px" '
|
||||
f'title="{na_label}">—</span>')
|
||||
return ('<span style="color:#dc2626;font-size:11px" '
|
||||
'title="Kein Link">✗</span>')
|
||||
if ok:
|
||||
return ('<span style="color:#16a34a;font-size:11px" '
|
||||
f'title="HTTP {status}">✓</span>')
|
||||
status_str = str(status) if status else "?"
|
||||
return ('<span style="color:#dc2626;font-size:11px" '
|
||||
f'title="HTTP {status_str}">✗ ({status_str})</span>')
|
||||
|
||||
|
||||
def _build_pattern_notice(vendors: list[dict]) -> str:
|
||||
"""P60 + P60b: globale Notice wenn viele Vendors aehnliche Flag-Sets haben.
|
||||
|
||||
Mutiert vendors[].`_actions_in_global_notice` so dass die Zeilenrenderer
|
||||
redundante per-row-Actions ueberspringen koennen.
|
||||
"""
|
||||
from collections import Counter
|
||||
flag_sets: Counter = Counter()
|
||||
for v in vendors:
|
||||
flags = v.get("compliance_flags") or []
|
||||
if flags:
|
||||
flag_sets[tuple(sorted(flags))] += 1
|
||||
if not flag_sets:
|
||||
return ""
|
||||
|
||||
most_common, _ = flag_sets.most_common(1)[0]
|
||||
most_common_set = set(most_common)
|
||||
|
||||
def _similar(flags: tuple) -> bool:
|
||||
fs = set(flags)
|
||||
if not fs or not most_common_set:
|
||||
return False
|
||||
inter = len(fs & most_common_set)
|
||||
union = len(fs | most_common_set)
|
||||
return union > 0 and (inter / union) >= 0.7
|
||||
|
||||
n_match = sum(cnt for fs, cnt in flag_sets.items() if _similar(fs))
|
||||
share = n_match / max(1, len(vendors))
|
||||
if not (n_match >= 8 and share >= 0.5):
|
||||
return ""
|
||||
|
||||
from compliance.services.finding_action_recipes import recipe_for
|
||||
labels = [_flag_short(f) for f in most_common]
|
||||
shared_actions: list[str] = []
|
||||
for f in most_common:
|
||||
rec = recipe_for(f)
|
||||
if rec:
|
||||
shared_actions.append(
|
||||
f'<li><strong>{_flag_short(f)}:</strong> '
|
||||
f'{rec.get("fix_text", "").splitlines()[0][:180]}</li>'
|
||||
)
|
||||
|
||||
for v in vendors:
|
||||
if _similar(tuple(sorted(v.get("compliance_flags") or []))):
|
||||
v["_actions_in_global_notice"] = True
|
||||
|
||||
return (
|
||||
f'<div style="margin:8px 0 12px;padding:10px 14px;'
|
||||
f'background:#fef3c7;border-left:3px solid #d97706;'
|
||||
f'border-radius:4px;font-size:11px;color:#92400e">'
|
||||
f'<strong>Wiederkehrendes Muster ({n_match} von {len(vendors)} '
|
||||
f'Anbietern, {int(share*100)}%):</strong> '
|
||||
f'Bei diesen Anbietern fehlen jeweils: '
|
||||
f'<em>{", ".join(labels)}</em>. '
|
||||
f'Vermutlich systembedingt (z.B. Settings-Export liefert '
|
||||
f'nur Namen, oder Banner-API blockiert Detail-Extraktion). '
|
||||
f'Die globalen Empfehlungen unten gelten fuer all diese Eintraege; '
|
||||
f'in der Tabelle werden sie nicht pro Zeile wiederholt.'
|
||||
+ (f'<ul style="margin:8px 0 0 0;padding-left:20px">{"".join(shared_actions)}</ul>'
|
||||
if shared_actions else '')
|
||||
+ '</div>'
|
||||
)
|
||||
|
||||
|
||||
def build_vvt_table_html(vendors: list[dict]) -> str:
|
||||
"""Render per-vendor VVT-style table for the email."""
|
||||
if not vendors:
|
||||
return ""
|
||||
|
||||
from compliance.services.vendor_classifier import RECIPIENT_TYPE_SECTIONS
|
||||
|
||||
by_type: dict[str, list[dict]] = {}
|
||||
for v in vendors:
|
||||
rt = (v.get("recipient_type") or "OTHER").upper()
|
||||
by_type.setdefault(rt, []).append(v)
|
||||
|
||||
n_total = len(vendors)
|
||||
n_internal = sum(
|
||||
1 for v in vendors
|
||||
if (v.get("recipient_type") or "").upper() in ("INTERNAL", "GROUP_COMPANY")
|
||||
)
|
||||
n_external = n_total - n_internal
|
||||
n_critical = sum(1 for v in vendors if v.get("compliance_score", 0) < 50)
|
||||
|
||||
summary_parts = [f"{n_total} Verarbeitungen erfasst"]
|
||||
if n_internal and n_external:
|
||||
summary_parts.append(
|
||||
f"— {n_internal} eigene + {n_external} externe Empfaenger"
|
||||
)
|
||||
if n_critical:
|
||||
summary_parts.append(
|
||||
f', <strong style="color:#dc2626">{n_critical} unter 50%</strong>'
|
||||
)
|
||||
else:
|
||||
summary_parts.append("— alle ueber 50%")
|
||||
summary = " ".join(summary_parts)
|
||||
|
||||
pattern_notice = _build_pattern_notice(vendors)
|
||||
|
||||
out: list[str] = [
|
||||
'<div style="font-family:-apple-system,BlinkMacSystemFont,sans-serif;'
|
||||
'max-width:760px;margin:0 auto 16px;padding:12px 16px;'
|
||||
'background:#fafafa;border:1px solid #e5e7eb;border-radius:8px">',
|
||||
'<h3 style="margin:0 0 4px;font-size:14px;color:#334155">'
|
||||
'Vorschlag fuer das Verarbeitungsverzeichnis (Art. 30 DSGVO)</h3>',
|
||||
# P91: Co-Pilot-Tonalitaet — Wahrscheinlichkeit statt Garantie,
|
||||
# Empfehlung statt "Verstoss-Liste".
|
||||
f'<p style="margin:0 0 8px;font-size:11px;color:#6b7280;line-height:1.5">'
|
||||
f'Wir haben <strong>{n_total} Verarbeitungen</strong> aus dem '
|
||||
f'Cookie-Banner abgeleitet, mit unserer globalen Anbieter-Bibliothek '
|
||||
f'abgeglichen und nach Empfaengerkategorie (Art. 30(1)(d) DSGVO) '
|
||||
f'gruppiert. Bei einer Reduktion der eingebundenen Anbieter, dem '
|
||||
f'Wechsel zu europaeischen Alternativen und konsequenter Pruefung '
|
||||
f'der tatsaechlich benoetigten Cookies ist eine Reduktion des '
|
||||
f'Tracking-Footprints sowie Lizenz-Einsparungen wahrscheinlich. '
|
||||
f'Eine fundierte Bewertung erfordert die Abstimmung mit dem '
|
||||
f'Datenschutzbeauftragten.</p>'
|
||||
f'<p style="margin:0 0 10px;font-size:11px;color:#6b7280">'
|
||||
f'{summary}. Innerhalb jeder Gruppe nach Verbesserungspotenzial '
|
||||
f'sortiert. Bei eigenen Verarbeitungen (INTERNAL/GROUP) sind '
|
||||
f'Opt-Out und Privacy-Link '
|
||||
'NICHT als Pflicht gewertet — der Widerruf erfolgt ueber das '
|
||||
'nicht erforderlich (Widerruf ueber Banner, Privacy in der '
|
||||
'Haupt-Datenschutzerklaerung dokumentiert).</p>',
|
||||
pattern_notice,
|
||||
]
|
||||
|
||||
for rtype, section_label in RECIPIENT_TYPE_SECTIONS:
|
||||
rows = by_type.get(rtype) or []
|
||||
if not rows:
|
||||
continue
|
||||
rows = sorted(rows, key=lambda v: v.get("compliance_score", 0))
|
||||
n = len(rows)
|
||||
n_bad = sum(1 for v in rows if v.get("compliance_score", 0) < 50)
|
||||
bad_hint = (f' <span style="color:#dc2626">({n_bad} unter 50%)</span>'
|
||||
if n_bad else "")
|
||||
out.append(
|
||||
f'<h4 style="margin:14px 0 4px;font-size:12px;color:#1e293b;'
|
||||
f'border-top:1px solid #e2e8f0;padding-top:8px">'
|
||||
f'{section_label} <span style="color:#94a3b8;font-weight:400">'
|
||||
f'({n}){bad_hint}</span></h4>'
|
||||
)
|
||||
out.append(_render_vendor_section(rows))
|
||||
|
||||
out.append('</div>')
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def _render_vendor_section(rows: list[dict]) -> str:
|
||||
body: list[str] = [
|
||||
'<table style="width:100%;border-collapse:collapse;font-size:11px">'
|
||||
'<thead><tr style="background:#f1f5f9;color:#475569;text-align:left">'
|
||||
'<th style="padding:5px 8px">Name</th>'
|
||||
'<th style="padding:5px 8px">Kategorie</th>'
|
||||
'<th style="padding:5px 8px">Sitz</th>'
|
||||
'<th style="padding:5px 8px;text-align:center">Cookies</th>'
|
||||
'<th style="padding:5px 8px;text-align:center">Opt-Out</th>'
|
||||
'<th style="padding:5px 8px;text-align:center">Privacy</th>'
|
||||
'<th style="padding:5px 8px;text-align:right">Score</th>'
|
||||
'</tr></thead><tbody>',
|
||||
]
|
||||
for v in rows:
|
||||
body.append(_render_vendor_row_full(v))
|
||||
body.append('</tbody></table>')
|
||||
return "".join(body)
|
||||
|
||||
|
||||
def _render_vendor_row_full(v: dict) -> str:
|
||||
rtype = (v.get("recipient_type") or "OTHER").upper()
|
||||
is_own = rtype in ("INTERNAL", "GROUP_COMPANY")
|
||||
cat = (v.get("category") or "").lower()
|
||||
is_necessary = cat in ("necessary", "strictlynecessary")
|
||||
|
||||
name = v.get("name") or "Unbekannt"
|
||||
category = _category_label(v.get("category", ""))
|
||||
country = v.get("country") or "—"
|
||||
cookies = v.get("cookies") or []
|
||||
n_cookies = len(cookies)
|
||||
score = int(v.get("compliance_score", 0))
|
||||
flags = v.get("compliance_flags") or []
|
||||
|
||||
opt_na_reason = ("Nicht erforderlich (eigene Verarbeitung — "
|
||||
"Widerruf ueber Cookie-Banner)") if is_own else (
|
||||
"Nicht erforderlich (§25 Abs. 2 TDDDG — technisch notwendig)"
|
||||
if is_necessary else None
|
||||
)
|
||||
opt_status = _link_status_badge(
|
||||
v.get("opt_out_url"), v.get("opt_out_ok"), v.get("opt_out_status"),
|
||||
na_label=opt_na_reason,
|
||||
)
|
||||
privacy_na_reason = (
|
||||
"Nicht erforderlich (eigene Verarbeitung — durch Haupt-DSI abgedeckt)"
|
||||
if is_own else None
|
||||
)
|
||||
privacy_status = _link_status_badge(
|
||||
v.get("privacy_policy_url"), v.get("privacy_ok"),
|
||||
v.get("privacy_status"), na_label=privacy_na_reason,
|
||||
)
|
||||
score_color = ("#16a34a" if score >= 80 else
|
||||
"#d97706" if score >= 50 else "#dc2626")
|
||||
|
||||
n_criteria = 3 if is_own else 5
|
||||
n_failed = len(flags) if flags else 0
|
||||
score_tooltip = (
|
||||
f"{n_criteria - n_failed} von {n_criteria} Kriterien erfuellt"
|
||||
+ (f" — fehlt: {', '.join(_flag_short(f) for f in flags[:3])}"
|
||||
if flags else "")
|
||||
)
|
||||
|
||||
actions_html = ""
|
||||
skip_actions = bool(v.get("_actions_in_global_notice"))
|
||||
if flags and not skip_actions:
|
||||
from compliance.services.finding_action_recipes import recipe_for
|
||||
action_items = []
|
||||
for f in flags:
|
||||
rec = recipe_for(f)
|
||||
if not rec:
|
||||
continue
|
||||
action_items.append(
|
||||
f'<li style="margin-bottom:6px"><strong>{_flag_short(f)}:</strong> '
|
||||
f'{rec.get("what", "")}<br/>'
|
||||
f'<span style="color:#475569"><strong>Was tun:</strong> '
|
||||
f'{rec.get("fix_text", "").splitlines()[0][:200]}</span><br/>'
|
||||
f'<span style="color:#94a3b8;font-size:9px">Quelle: '
|
||||
f'{rec.get("why", "")[:160]}</span></li>'
|
||||
)
|
||||
if action_items:
|
||||
actions_html = (
|
||||
f'<details style="margin-top:4px"><summary style="cursor:pointer;'
|
||||
f'color:#dc2626;font-size:10px">Was muss ich tun? '
|
||||
f'({len(action_items)} Action{"s" if len(action_items) != 1 else ""})</summary>'
|
||||
f'<ul style="margin:4px 0 0 14px;padding:0;font-size:10px;color:#1e293b">'
|
||||
+ "".join(action_items)
|
||||
+ '</ul></details>'
|
||||
)
|
||||
|
||||
flag_str = ""
|
||||
if flags:
|
||||
flag_str = (
|
||||
f'<div style="font-size:10px;color:#94a3b8;margin-top:2px">'
|
||||
f'{", ".join(flags[:4])}</div>'
|
||||
f'{actions_html}'
|
||||
)
|
||||
risk = v.get("compliance_risk") or {}
|
||||
risk_label = risk.get("label") or ""
|
||||
risk_badge = ""
|
||||
if risk_label and risk_label != "unklar":
|
||||
rc = {
|
||||
"kritisch": ("#dc2626", "#fff"),
|
||||
"hoch": ("#fecaca", "#991b1b"),
|
||||
"mittel": ("#fde68a", "#92400e"),
|
||||
"gering": ("#d1fae5", "#065f46"),
|
||||
}.get(risk_label, ("#e5e7eb", "#475569"))
|
||||
risk_badge = (f'<span style="margin-left:6px;padding:1px 5px;border-radius:3px;font-size:9px;'
|
||||
f'background:{rc[0]};color:{rc[1]}">Risk: {risk_label}</span>')
|
||||
return (
|
||||
f'<tr style="border-top:1px solid #e2e8f0">'
|
||||
f'<td style="padding:6px 8px;color:#1e293b;font-size:11px">'
|
||||
f'{name}{risk_badge}{flag_str}</td>'
|
||||
f'<td style="padding:6px 8px;color:#475569;font-size:11px">{category}</td>'
|
||||
f'<td style="padding:6px 8px;color:#475569;font-size:11px">{country}</td>'
|
||||
f'<td style="padding:6px 8px;text-align:center;color:#475569;font-size:11px">'
|
||||
f'{n_cookies}</td>'
|
||||
f'<td style="padding:6px 8px;text-align:center">{opt_status}</td>'
|
||||
f'<td style="padding:6px 8px;text-align:center">{privacy_status}</td>'
|
||||
f'<td style="padding:6px 8px;text-align:right;font-weight:600;'
|
||||
f'color:{score_color};font-size:11px" title="{score_tooltip}">'
|
||||
f'{score}%<div style="font-size:9px;font-weight:400;color:#94a3b8">'
|
||||
f'{n_criteria - n_failed}/{n_criteria}</div></td>'
|
||||
f'</tr>'
|
||||
)
|
||||
@@ -0,0 +1,213 @@
|
||||
"""
|
||||
A — Audit-Transparenz / Audit-Quality-Checks.
|
||||
|
||||
Wenn der Crawler nicht alles gefunden hat, MUSS die Mail das prominent
|
||||
zeigen — sonst denkt der User 'alles gut' obwohl die Datenlage Luecken
|
||||
hat.
|
||||
|
||||
Erkennt 4 Quality-Failures:
|
||||
1. banner_detected=False trotz vorhandenem Cookie-Doc → CMP-Tool ungeladen
|
||||
2. cookie_doc >= 30k chars aber cmp_vendors < 10 → Vendor-Extract unvollstaendig
|
||||
3. doc_text submitted aber 0 chars geladen → Crawler-Failure
|
||||
4. cmp_vendors > 0 aber alle aus llm_cascade ohne Library-Match → vermutl. unvollstaendig
|
||||
|
||||
Diese Findings landen IMMER im GF-1-Pager (auch wenn kein anderes
|
||||
HIGH-Finding da ist) — sie sagen "die Datenlage ist unvollstaendig,
|
||||
manuelle Pruefung empfohlen".
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _word_count(text: str | None) -> int:
|
||||
if not text:
|
||||
return 0
|
||||
return len(text.split())
|
||||
|
||||
|
||||
def check_banner_not_detected(
|
||||
banner_result: dict | None,
|
||||
cookie_doc_text: str | None,
|
||||
) -> dict | None:
|
||||
"""1) Banner nicht geladen aber Cookie-Doc vorhanden → CMP-Tool kaputt."""
|
||||
if not isinstance(banner_result, dict):
|
||||
return None
|
||||
detected = banner_result.get("banner_detected")
|
||||
if detected is None or detected is True:
|
||||
return None
|
||||
if not cookie_doc_text or len(cookie_doc_text) < 5000:
|
||||
return None
|
||||
return {
|
||||
"severity": "HIGH",
|
||||
"code": "audit_banner_not_detected",
|
||||
"label": "Audit-Vorbehalt: Cookie-Banner konnte vom Crawler nicht "
|
||||
"geladen werden",
|
||||
"area": "Cookie-Banner",
|
||||
"owner": "DSB + Marketing/CMP-Admin",
|
||||
"detail": (
|
||||
"Unser Crawler konnte das CMP-Tool dieser Site nicht analysieren — "
|
||||
"weder Vendor-Liste noch Cookie-Verhalten konnten geprueft werden. "
|
||||
"Moegliche Ursachen: Anti-Bot-Schutz (Akamai/Cloudflare/DataDome) "
|
||||
"blockiert Playwright; das CMP-Skript laed nur fuer bestimmte "
|
||||
"Geo-Regionen; ein neues CMP-Tool das wir noch nicht unterstuetzen. "
|
||||
"Empfehlung: manuelle Pruefung des Banners durch DSB, alternativ "
|
||||
"Cookie-Tabelle im Audit-Tool direkt einfuegen (Copy-Paste-Modus)."
|
||||
),
|
||||
"legal_basis": "Art. 5 (2) DSGVO Rechenschaftspflicht — der Audit-"
|
||||
"Befund muss transparent zwischen 'geprueft & OK' und "
|
||||
"'nicht pruefbar' unterscheiden.",
|
||||
}
|
||||
|
||||
|
||||
def check_vendor_extract_incomplete(
|
||||
cookie_doc_text: str | None,
|
||||
cmp_vendors: list | None,
|
||||
) -> dict | None:
|
||||
"""2) Cookie-Doc gross aber wenig Vendors → Extract unvollstaendig.
|
||||
|
||||
Dynamische Schwelle nach Doc-Groesse:
|
||||
* 3k-6k Wörter → mind. 10 Vendors erwartet
|
||||
* 6k-10k Wörter → mind. 20 Vendors
|
||||
* 10k-15k Wörter → mind. 30 Vendors
|
||||
* 15k+ Wörter → mind. 40 Vendors
|
||||
"""
|
||||
wc = _word_count(cookie_doc_text)
|
||||
n_vendors = len(cmp_vendors or [])
|
||||
if wc < 3000:
|
||||
return None
|
||||
# Erwartete Vendor-Anzahl heuristisch nach Doc-Groesse
|
||||
if wc >= 15000:
|
||||
expected = 40
|
||||
elif wc >= 10000:
|
||||
expected = 30
|
||||
elif wc >= 6000:
|
||||
expected = 20
|
||||
else:
|
||||
expected = 10
|
||||
if n_vendors >= expected:
|
||||
return None
|
||||
return {
|
||||
"severity": "HIGH" if wc >= 8000 else "MEDIUM",
|
||||
"code": "audit_vendor_extract_thin",
|
||||
"label": (
|
||||
f"Audit-Vorbehalt: Cookie-Richtlinie hat {wc:,} Wörter, "
|
||||
f"erwartet ~{expected} Vendors, extrahiert nur {n_vendors}"
|
||||
).replace(",", "."),
|
||||
"area": "Vendor-Liste / VVT",
|
||||
"owner": "DSB + Marketing",
|
||||
"detail": (
|
||||
f"Bei einer Cookie-Richtlinie mit {wc:,} Woertern erwarten wir "
|
||||
f"typischerweise {expected}+ unique Vendors. Die extrahierte Zahl "
|
||||
f"({n_vendors}) ist auffaellig niedrig — entweder hat unser "
|
||||
"Parser/LLM die Tabelle nicht vollstaendig erfasst oder "
|
||||
"Vendors wurden zu konservativ erkannt. Empfehlung: Cookie-"
|
||||
"Tabelle im Copy-Paste-Modus einreichen (Frontend-Toggle "
|
||||
"'Text einfuegen' pro Cookie-Doc-Zeile) — dort parsen wir "
|
||||
"Spalten deterministisch."
|
||||
).replace(",", "."),
|
||||
"legal_basis": "Art. 13(1)(e) DSGVO — die Empfaengerliste muss "
|
||||
"vollstaendig sein; ein unvollstaendiger Audit darf "
|
||||
"nicht als vollstaendig dargestellt werden.",
|
||||
}
|
||||
|
||||
|
||||
def check_url_fetch_failed(doc_entries: list | None) -> list[dict]:
|
||||
"""3) Submitted URL aber 0 oder Mini-Text → Crawler-Failure pro Doc."""
|
||||
out: list[dict] = []
|
||||
for e in (doc_entries or []):
|
||||
if not isinstance(e, dict):
|
||||
continue
|
||||
url = (e.get("url") or "").strip()
|
||||
text = (e.get("text") or "").strip()
|
||||
if not url or len(text) >= 200 or e.get("auto_discovered"):
|
||||
continue
|
||||
dt = e.get("doc_type", "doc")
|
||||
rejected = e.get("rejected_url") or ""
|
||||
out.append({
|
||||
"severity": "MEDIUM",
|
||||
"code": f"audit_url_fetch_failed_{dt}",
|
||||
"label": (
|
||||
f"Audit-Vorbehalt: {dt}-URL konnte nicht geladen werden "
|
||||
f"({len(text)} Zeichen extrahiert)"
|
||||
),
|
||||
"area": dt,
|
||||
"owner": "DSB + Web-Team",
|
||||
"detail": (
|
||||
f"Die eingegebene URL {url[:120]} lieferte weniger als 200 "
|
||||
"Zeichen. Moegliche Ursachen: 404, JS-only Render, Anti-Bot, "
|
||||
"Cookie-Wall. Auto-Discovery hat versucht eine Alternative "
|
||||
"auf der Homepage zu finden — ohne Erfolg. Empfehlung: "
|
||||
"korrekte URL pruefen oder den Text direkt einfuegen "
|
||||
"(Copy-Paste-Modus)."
|
||||
),
|
||||
"legal_basis": "Art. 5 (2) DSGVO Rechenschaftspflicht.",
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def run_all(
|
||||
banner_result: dict | None,
|
||||
cookie_doc_text: str | None,
|
||||
cmp_vendors: list | None,
|
||||
doc_entries: list | None,
|
||||
) -> list[dict]:
|
||||
findings: list[dict] = []
|
||||
try:
|
||||
f1 = check_banner_not_detected(banner_result, cookie_doc_text)
|
||||
if f1:
|
||||
findings.append(f1)
|
||||
except Exception as e:
|
||||
logger.warning("audit_banner_not_detected failed: %s", e)
|
||||
try:
|
||||
f2 = check_vendor_extract_incomplete(cookie_doc_text, cmp_vendors)
|
||||
if f2:
|
||||
findings.append(f2)
|
||||
except Exception as e:
|
||||
logger.warning("audit_vendor_extract_thin failed: %s", e)
|
||||
try:
|
||||
findings.extend(check_url_fetch_failed(doc_entries))
|
||||
except Exception as e:
|
||||
logger.warning("audit_url_fetch_failed failed: %s", e)
|
||||
return findings
|
||||
|
||||
|
||||
def build_audit_quality_block_html(findings: list[dict]) -> str:
|
||||
if not findings:
|
||||
return ""
|
||||
items: list[str] = []
|
||||
for f in findings:
|
||||
sev = f.get("severity", "MEDIUM")
|
||||
sev_color = "#dc2626" if sev == "HIGH" else "#d97706"
|
||||
items.append(
|
||||
f'<li style="margin-bottom:10px;font-size:11px;line-height:1.5">'
|
||||
f'<strong style="color:{sev_color}">[{sev}] {f.get("label","")}</strong>'
|
||||
f'<div style="color:#475569;margin-top:3px">{f.get("detail","")}</div>'
|
||||
f'<div style="color:#94a3b8;margin-top:2px;font-style:italic">'
|
||||
f'{f.get("legal_basis","")}</div>'
|
||||
f'</li>'
|
||||
)
|
||||
return (
|
||||
'<div style="font-family:-apple-system,BlinkMacSystemFont,sans-serif;'
|
||||
'max-width:760px;margin:0 auto 16px;padding:14px 18px;'
|
||||
'background:#fee2e2;border:1px solid #fecaca;border-radius:8px">'
|
||||
'<div style="font-size:11px;color:#991b1b;text-transform:uppercase;'
|
||||
'letter-spacing:1.2px;margin-bottom:4px;font-weight:600">'
|
||||
'Audit-Vorbehalt — Datenlage unvollstaendig</div>'
|
||||
f'<h3 style="margin:0 0 6px;font-size:14px;color:#1e293b">'
|
||||
f'{len(findings)} Punkt'
|
||||
f'{"e" if len(findings) != 1 else ""} bei denen der Audit selbst '
|
||||
f'an Grenzen gestossen ist</h3>'
|
||||
'<p style="margin:0 0 10px;font-size:11px;color:#475569;line-height:1.5">'
|
||||
'Die folgenden Punkte betreffen NICHT die Compliance Ihrer Website, '
|
||||
'sondern die Vollstaendigkeit unserer Pruefung. Bei diesen Bereichen '
|
||||
'sollten Sie den Audit nicht als "alles ok" werten, sondern manuell '
|
||||
'oder im Copy-Paste-Modus nachpruefen.'
|
||||
'</p>'
|
||||
'<ul style="margin:0 0 0 18px;padding:0">'
|
||||
+ "".join(items) +
|
||||
'</ul></div>'
|
||||
)
|
||||
@@ -0,0 +1,458 @@
|
||||
"""
|
||||
P92 + P94 — Banner-Konsistenz-Checks (Post-hoc auf banner_result).
|
||||
|
||||
P92 — CMP-Tool-Verfuegbarkeit:
|
||||
Wenn "Anpassen"/"Einstellungen" angeklickt wurde und das Tool laed
|
||||
nicht (Network-Error, Timeout, weisse Seite, fehlende
|
||||
consent-Elemente nach Klick), ist das ein HIGH-Verstoss — der
|
||||
Nutzer hat formal die Moeglichkeit zur granularen Wahl, aber sie
|
||||
funktioniert nicht.
|
||||
|
||||
P94 — Banner-Init-vs-Cookie-Footer-Konsistenz:
|
||||
Cookie-Liste im Initial-Banner-Settings darf nicht von der Liste
|
||||
im permanenten Cookie-Richtlinien-Dokument abweichen. Wenn Banner
|
||||
12 Cookies nennt, die Cookie-Doc aber 47, ist mindestens eine der
|
||||
beiden Quellen unvollstaendig → MEDIUM-Finding.
|
||||
|
||||
Beide liefern dict mit shape:
|
||||
{"severity": "HIGH"|"MEDIUM", "code": str, "label": str, "detail": str}
|
||||
oder None, wenn der Check nicht greift.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_ANPASSEN_KEYS = (
|
||||
"anpassen", "einstellungen", "customize", "preferences",
|
||||
"settings", "individuelle", "auswahl", "manage",
|
||||
)
|
||||
|
||||
|
||||
def _phases(banner_result: dict) -> dict:
|
||||
if not isinstance(banner_result, dict):
|
||||
return {}
|
||||
return banner_result.get("phases") or {}
|
||||
|
||||
|
||||
def check_cmp_tool_availability(banner_result: dict) -> dict | None:
|
||||
"""P92 — Anpassen-Klick aber Settings-Tool defekt / leer."""
|
||||
phases = _phases(banner_result)
|
||||
settings_ph = phases.get("settings") or phases.get("after_settings_click")
|
||||
if not isinstance(settings_ph, dict):
|
||||
return None
|
||||
|
||||
initial_ph = phases.get("initial") or phases.get("before_accept") or {}
|
||||
initial_text = (initial_ph.get("banner_text") or "").lower()
|
||||
if not any(k in initial_text for k in _ANPASSEN_KEYS):
|
||||
return None # Wenn kein Anpassen-Button gar nicht im Initial-Banner,
|
||||
# ist das P100s Job — nicht hier doppelt melden.
|
||||
|
||||
error = settings_ph.get("error") or settings_ph.get("status_error")
|
||||
settings_text = (settings_ph.get("banner_text") or "").strip()
|
||||
has_categories = bool(
|
||||
settings_ph.get("categories")
|
||||
or settings_ph.get("category_tests")
|
||||
or (settings_ph.get("structured_checks") or [])
|
||||
)
|
||||
has_toggles = bool(re.search(r"checkbox|toggle|switch|aria-checked",
|
||||
(settings_ph.get("banner_html") or ""), re.I))
|
||||
timed_out = bool(settings_ph.get("timeout"))
|
||||
|
||||
failure_signals: list[str] = []
|
||||
if error:
|
||||
failure_signals.append(f'Fehler: {str(error)[:120]}')
|
||||
if timed_out:
|
||||
failure_signals.append('Zeitueberschreitung beim Laden')
|
||||
if len(settings_text) < 80 and not has_categories:
|
||||
failure_signals.append(
|
||||
f'Settings-Bereich nur {len(settings_text)} Zeichen, '
|
||||
'keine Kategorien sichtbar'
|
||||
)
|
||||
if not has_toggles and not has_categories:
|
||||
failure_signals.append(
|
||||
'Keine Checkboxen / Toggles im Settings-Bereich'
|
||||
)
|
||||
|
||||
if not failure_signals:
|
||||
return None
|
||||
|
||||
return {
|
||||
"severity": "HIGH",
|
||||
"code": "cmp_tool_unavailable",
|
||||
"label": 'Cookie-Einstellungen ueber "Anpassen" formal vorhanden, '
|
||||
'Tool laed aber nicht oder ist leer',
|
||||
"detail": " | ".join(failure_signals),
|
||||
"legal_basis": "Art. 7 (3) DSGVO + EDPB 03/2022 — die Moeglichkeit "
|
||||
"zur granularen Auswahl muss tatsaechlich funktionieren.",
|
||||
}
|
||||
|
||||
|
||||
def _normalize_cookie_names(items) -> set[str]:
|
||||
out: set[str] = set()
|
||||
if not items:
|
||||
return out
|
||||
for it in items:
|
||||
if isinstance(it, str):
|
||||
name = it.strip()
|
||||
elif isinstance(it, dict):
|
||||
name = (it.get("name") or it.get("cookie") or it.get("id") or "").strip()
|
||||
else:
|
||||
continue
|
||||
if name and len(name) <= 120:
|
||||
out.add(name.lower())
|
||||
return out
|
||||
|
||||
|
||||
def check_init_banner_vs_cookie_doc(
|
||||
banner_result: dict,
|
||||
cookie_doc_text: str | None,
|
||||
) -> dict | None:
|
||||
"""P94 — Cookie-Liste im Init-Banner vs in der Cookie-Richtlinie."""
|
||||
if not cookie_doc_text or len(cookie_doc_text) < 500:
|
||||
return None
|
||||
|
||||
phases = _phases(banner_result)
|
||||
banner_cookies = _normalize_cookie_names(
|
||||
(phases.get("settings") or {}).get("cookies") or []
|
||||
) | _normalize_cookie_names(
|
||||
(phases.get("initial") or phases.get("before_accept") or {}).get("cookies") or []
|
||||
)
|
||||
|
||||
# Aus dem Cookie-Doc-Text: Cookie-Namen sind typischerweise
|
||||
# camelCase oder _underscored, 4-40 Zeichen, ohne Leerzeichen.
|
||||
candidates = set(re.findall(
|
||||
r"\b([A-Za-z_][A-Za-z0-9_\-\.]{3,40})\b", cookie_doc_text
|
||||
))
|
||||
# Filter: heuristisch wahrscheinliche Cookie-Namen
|
||||
doc_cookies: set[str] = set()
|
||||
for c in candidates:
|
||||
cl = c.lower()
|
||||
if any(p in cl for p in (
|
||||
"_ga", "_gid", "_gcl", "_fbp", "uc_", "ot_",
|
||||
"cookieconsent", "sessionid", "csrf", "ajs_", "amp_",
|
||||
"datadome", "incap_", "_pk_", "wp-", "yt-",
|
||||
)):
|
||||
doc_cookies.add(cl)
|
||||
elif re.match(r"^[a-z][a-z0-9_]{3,30}$", cl) and (
|
||||
"cookie" in cl or "consent" in cl or "track" in cl or "session" in cl
|
||||
):
|
||||
doc_cookies.add(cl)
|
||||
|
||||
if len(doc_cookies) < 5 or not banner_cookies:
|
||||
return None # Datenlage zu duenn fuer sinnvolle Aussage.
|
||||
|
||||
only_in_doc = doc_cookies - banner_cookies
|
||||
only_in_banner = banner_cookies - doc_cookies
|
||||
|
||||
if len(only_in_doc) < 5 and len(only_in_banner) < 3:
|
||||
return None # Tolerable Abweichung.
|
||||
|
||||
severity = "MEDIUM"
|
||||
# HIGH wenn beide Seiten massiv abweichen — dann fehlt klar
|
||||
# die Cross-Reference.
|
||||
if len(only_in_doc) >= 15 and len(only_in_banner) >= 5:
|
||||
severity = "HIGH"
|
||||
|
||||
return {
|
||||
"severity": severity,
|
||||
"code": "banner_cookie_doc_mismatch",
|
||||
"label": (
|
||||
f"Cookie-Liste im Banner-Einstellungen ({len(banner_cookies)}) "
|
||||
f"weicht von Cookie-Richtlinie ({len(doc_cookies)}) ab"
|
||||
),
|
||||
"detail": (
|
||||
f"Nur im Cookie-Dokument: {len(only_in_doc)} Cookies (Beispiele: "
|
||||
f"{', '.join(sorted(only_in_doc)[:5])}). "
|
||||
f"Nur im Banner: {len(only_in_banner)} Cookies. "
|
||||
"Empfehlung: eine der beiden Quellen als Single-Source-of-Truth "
|
||||
"definieren und die andere automatisch generieren."
|
||||
),
|
||||
"legal_basis": (
|
||||
"Art. 13(1)(c) DSGVO + Art. 12 DSGVO — Informationen ueber die "
|
||||
"Verarbeitung muessen vollstaendig und konsistent sein."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
_VENDOR_LIST_SIGNALS = (
|
||||
"google analytics", "google ads", "facebook pixel", "meta pixel",
|
||||
"hotjar", "matomo", "etracker", "salesforce", "hubspot",
|
||||
"linkedin insight", "twitter conversion", "tiktok pixel",
|
||||
"criteo", "the trade desk", "doubleclick",
|
||||
)
|
||||
|
||||
|
||||
def _vendors_mentioned_in_text(text: str) -> set[str]:
|
||||
if not text:
|
||||
return set()
|
||||
t = text.lower()
|
||||
return {v for v in _VENDOR_LIST_SIGNALS if v in t}
|
||||
|
||||
|
||||
def check_three_source_vendor_consistency(
|
||||
doc_texts: dict[str, str] | None,
|
||||
cmp_vendors: list | None,
|
||||
) -> dict | None:
|
||||
"""P33 — 3-Spalten-Konsistenz: DSE vs Cookie-Doc vs Banner-Vendors.
|
||||
|
||||
Wenn ein Vendor (z.B. 'Google Analytics') in der DSE und in der
|
||||
Cookie-Richtlinie genannt wird, aber NICHT in der Banner-Vendor-
|
||||
Liste auftaucht (oder umgekehrt), ist die Drei-Quellen-Aussage
|
||||
nicht konsistent. MEDIUM-Finding mit Liste der jeweils fehlenden
|
||||
Vendors.
|
||||
"""
|
||||
if not doc_texts:
|
||||
return None
|
||||
dse_v = _vendors_mentioned_in_text(doc_texts.get("dse") or "")
|
||||
cookie_v = _vendors_mentioned_in_text(doc_texts.get("cookie") or "")
|
||||
banner_v: set[str] = set()
|
||||
for v in (cmp_vendors or []):
|
||||
name = (v.get("name") or "").lower()
|
||||
for sig in _VENDOR_LIST_SIGNALS:
|
||||
if sig in name or name in sig:
|
||||
banner_v.add(sig)
|
||||
|
||||
sources_with_data = sum(1 for s in (dse_v, cookie_v, banner_v) if s)
|
||||
if sources_with_data < 2:
|
||||
return None
|
||||
|
||||
# Vendors in mind. einer Quelle aber nicht in allen vorhandenen
|
||||
universe = dse_v | cookie_v | banner_v
|
||||
issues: list[str] = []
|
||||
for vendor in sorted(universe):
|
||||
missing_in = []
|
||||
if dse_v and vendor not in dse_v:
|
||||
missing_in.append("DSE")
|
||||
if cookie_v and vendor not in cookie_v:
|
||||
missing_in.append("Cookie-Doc")
|
||||
if banner_v and vendor not in banner_v:
|
||||
missing_in.append("Banner-Liste")
|
||||
if missing_in and len(missing_in) < sources_with_data:
|
||||
issues.append(f'{vendor} (fehlt in: {", ".join(missing_in)})')
|
||||
|
||||
if not issues:
|
||||
return None
|
||||
|
||||
return {
|
||||
"severity": "MEDIUM",
|
||||
"code": "three_source_vendor_inconsistency",
|
||||
"label": (
|
||||
f"{len(issues)} Vendor{'en' if len(issues) != 1 else ''} "
|
||||
"nicht konsistent zwischen DSE, Cookie-Richtlinie und Banner"
|
||||
),
|
||||
"detail": (
|
||||
"Folgende Vendors sind nicht in allen Quellen genannt: "
|
||||
+ "; ".join(issues[:8])
|
||||
+ (" ..." if len(issues) > 8 else "")
|
||||
+ ". Empfehlung: zentrale Vendor-Liste pflegen und in alle "
|
||||
"drei Dokumenttypen propagieren."
|
||||
),
|
||||
"legal_basis": "Art. 13(1)(c)+(e) DSGVO + EDPB 5/2020 — die "
|
||||
"Empfaenger / Drittlandtransfers muessen ueber alle "
|
||||
"Touch-Points konsistent kommuniziert werden.",
|
||||
}
|
||||
|
||||
|
||||
def check_banner_vs_cmp_partner_count(
|
||||
banner_result: dict,
|
||||
cmp_vendors: list | None,
|
||||
) -> dict | None:
|
||||
"""P75 — Banner nennt N Partner, CMP-Payload listet viel mehr.
|
||||
|
||||
Wenn der Banner-Text behauptet "5 Partner" oder "Wir und unsere
|
||||
Partner", die CMP-Payload aber 100+ Vendors enthaelt, wird der
|
||||
User getaeuscht.
|
||||
"""
|
||||
cmp_count = len(cmp_vendors or [])
|
||||
if cmp_count < 20:
|
||||
return None
|
||||
initial_ph = (_phases(banner_result).get("initial")
|
||||
or _phases(banner_result).get("before_accept") or {})
|
||||
banner_text = (initial_ph.get("banner_text") or "")[:5000]
|
||||
if not banner_text:
|
||||
return None
|
||||
m = re.search(r"\b(\d{1,4})\s*(?:partner|drittanbieter|vendor|"
|
||||
r"anbieter|dienstleister)", banner_text, re.I)
|
||||
if not m:
|
||||
return None
|
||||
claimed = int(m.group(1))
|
||||
if claimed >= cmp_count * 0.6:
|
||||
return None # Zahl im Banner ist plausibel.
|
||||
return {
|
||||
"severity": "HIGH",
|
||||
"code": "banner_understates_vendor_count",
|
||||
"label": (
|
||||
f"Banner-Text nennt {claimed} Partner, CMP-Payload listet "
|
||||
f"{cmp_count} Vendors"
|
||||
),
|
||||
"detail": (
|
||||
f"Die im Banner-Text genannte Zahl ({claimed}) unterschaetzt die "
|
||||
f"tatsaechliche Anzahl der Empfaenger ({cmp_count}) deutlich. "
|
||||
"Empfehlung: Banner-Text auf die echte Vendor-Zahl heben oder "
|
||||
"die Vendor-Liste reduzieren."
|
||||
),
|
||||
"legal_basis": (
|
||||
"Art. 13(1)(e) DSGVO + EDPB 5/2020 — die Empfaenger / "
|
||||
"Empfaengerkategorien muessen vollstaendig und nicht "
|
||||
"verharmlosend angegeben sein."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def check_banner_copyability(banner_result: dict) -> dict | None:
|
||||
"""P51a — Banner-Text muss kopierbar sein. CSS user-select:none oder
|
||||
-webkit-user-select:none verhindert das (Article 7(2) DSGVO — verstaendlich
|
||||
und in einer Form, die spaetere Pruefung ermoeglicht).
|
||||
"""
|
||||
if not isinstance(banner_result, dict):
|
||||
return None
|
||||
phases = banner_result.get("phases") or {}
|
||||
initial = phases.get("initial") or phases.get("before_accept") or {}
|
||||
html = (initial.get("banner_html") or "")[:50000].lower()
|
||||
if not html:
|
||||
return None
|
||||
blocked_signals = [
|
||||
"user-select:none", "user-select: none",
|
||||
"-webkit-user-select:none", "-webkit-user-select: none",
|
||||
"-moz-user-select:none", "pointer-events:none",
|
||||
"oncopy=\"return false", "onselectstart=\"return false",
|
||||
]
|
||||
hits = [s for s in blocked_signals if s in html]
|
||||
if not hits:
|
||||
return None
|
||||
return {
|
||||
"severity": "MEDIUM",
|
||||
"code": "banner_not_copyable",
|
||||
"label": "Banner-Text laesst sich nicht kopieren "
|
||||
"(user-select:none / oncopy disabled)",
|
||||
"detail": (
|
||||
f'Im Banner-HTML gefunden: {", ".join(hits[:3])}. Der Nutzer '
|
||||
"kann den Banner-Text nicht in eine Mail / Doku einfuegen, was "
|
||||
"die spaetere Pruefung erschwert. Empfehlung: das CSS entfernen "
|
||||
"oder explizit auf 'auto' setzen."
|
||||
),
|
||||
"legal_basis": "Art. 7 (1)+(2) DSGVO + EDPB 5/2020 — Einwilligungen "
|
||||
"muessen in verstaendlicher und zugaenglicher Form "
|
||||
"erteilt werden; eine spaetere Pruefung darf nicht "
|
||||
"technisch erschwert werden.",
|
||||
}
|
||||
|
||||
|
||||
def check_consent_history(banner_result: dict) -> dict | None:
|
||||
"""P51b — Es muss eine Moeglichkeit geben, die eigene Einwilligungs-
|
||||
Historie einzusehen (Art. 7 (3) — Widerruf muss so einfach wie die
|
||||
Erteilung sein; das setzt voraus dass man WEISS was man einwilligt hat).
|
||||
"""
|
||||
if not isinstance(banner_result, dict):
|
||||
return None
|
||||
phases = banner_result.get("phases") or {}
|
||||
blob_parts: list[str] = []
|
||||
for ph in phases.values():
|
||||
if isinstance(ph, dict):
|
||||
blob_parts.append((ph.get("banner_text") or "")[:5000])
|
||||
blob_parts.append((ph.get("banner_html") or "")[:20000])
|
||||
blob = " ".join(blob_parts).lower()
|
||||
if not blob:
|
||||
return None
|
||||
history_signals = [
|
||||
"meine einwilligung", "consent-historie", "consent history",
|
||||
"einwilligungshistorie", "einwilligungs-historie",
|
||||
"ihre einwilligungen", "datenschutz-cockpit",
|
||||
"privacy dashboard", "einwilligungs-protokoll",
|
||||
"consent record", "consent log",
|
||||
]
|
||||
if any(s in blob for s in history_signals):
|
||||
return None
|
||||
return {
|
||||
"severity": "MEDIUM",
|
||||
"code": "consent_history_missing",
|
||||
"label": "Keine sichtbare Consent-Historie / 'Meine Einwilligungen'-Ansicht",
|
||||
"detail": (
|
||||
"Im Banner und in den verlinkten Footer-Bereichen ist keine "
|
||||
"Moeglichkeit erkennbar, die eigene Einwilligungs-Historie "
|
||||
"einzusehen oder zu exportieren. Empfehlung: einen "
|
||||
"'Meine Einwilligungen'-Bereich verlinken (Borlabs / Cookiebot / "
|
||||
"Usercentrics bieten dafuer fertige Komponenten)."
|
||||
),
|
||||
"legal_basis": "Art. 7 (3) DSGVO + EDPB 5/2020 — der Widerruf muss "
|
||||
"ebenso einfach sein wie die Erteilung, was eine "
|
||||
"Sichtbarmachung der eigenen Einwilligungen voraussetzt.",
|
||||
}
|
||||
|
||||
|
||||
def run_all(banner_result: dict, cookie_doc_text: str | None = None,
|
||||
cmp_vendors: list | None = None,
|
||||
doc_texts: dict[str, str] | None = None) -> list[dict]:
|
||||
findings: list[dict] = []
|
||||
try:
|
||||
f1 = check_cmp_tool_availability(banner_result)
|
||||
if f1:
|
||||
findings.append(f1)
|
||||
except Exception as e:
|
||||
logger.warning("P92 cmp_tool_availability failed: %s", e)
|
||||
try:
|
||||
f2 = check_init_banner_vs_cookie_doc(banner_result, cookie_doc_text)
|
||||
if f2:
|
||||
findings.append(f2)
|
||||
except Exception as e:
|
||||
logger.warning("P94 init_vs_cookie_doc failed: %s", e)
|
||||
try:
|
||||
f3 = check_banner_vs_cmp_partner_count(banner_result, cmp_vendors)
|
||||
if f3:
|
||||
findings.append(f3)
|
||||
except Exception as e:
|
||||
logger.warning("P75 banner_vs_cmp_count failed: %s", e)
|
||||
try:
|
||||
f4 = check_three_source_vendor_consistency(doc_texts, cmp_vendors)
|
||||
if f4:
|
||||
findings.append(f4)
|
||||
except Exception as e:
|
||||
logger.warning("P33 three_source_vendor failed: %s", e)
|
||||
try:
|
||||
f5 = check_banner_copyability(banner_result)
|
||||
if f5:
|
||||
findings.append(f5)
|
||||
except Exception as e:
|
||||
logger.warning("P51a copyability failed: %s", e)
|
||||
try:
|
||||
f6 = check_consent_history(banner_result)
|
||||
if f6:
|
||||
findings.append(f6)
|
||||
except Exception as e:
|
||||
logger.warning("P51b consent_history failed: %s", e)
|
||||
return findings
|
||||
|
||||
|
||||
def build_consistency_block_html(findings: list[dict]) -> str:
|
||||
if not findings:
|
||||
return ""
|
||||
items: list[str] = []
|
||||
for f in findings:
|
||||
sev = f.get("severity", "MEDIUM")
|
||||
sev_color = "#dc2626" if sev == "HIGH" else "#d97706"
|
||||
items.append(
|
||||
f'<li style="margin-bottom:10px;font-size:11px;line-height:1.5">'
|
||||
f'<strong style="color:{sev_color}">[{sev}] {f.get("label","")}</strong>'
|
||||
f'<div style="color:#475569;margin-top:3px">{f.get("detail","")}</div>'
|
||||
f'<div style="color:#94a3b8;margin-top:2px;font-style:italic">'
|
||||
f'{f.get("legal_basis","")}</div>'
|
||||
f'</li>'
|
||||
)
|
||||
return (
|
||||
'<div style="font-family:-apple-system,BlinkMacSystemFont,sans-serif;'
|
||||
'max-width:760px;margin:0 auto 16px;padding:14px 18px;'
|
||||
'background:#fef3c7;border:1px solid #fcd34d;border-radius:8px">'
|
||||
'<div style="font-size:11px;color:#92400e;text-transform:uppercase;'
|
||||
'letter-spacing:1.2px;margin-bottom:4px;font-weight:600">'
|
||||
'Banner-Konsistenz-Pruefung</div>'
|
||||
f'<h3 style="margin:0 0 6px;font-size:14px;color:#1e293b">'
|
||||
f'{len(findings)} Konsistenz-Finding{"s" if len(findings) != 1 else ""} '
|
||||
'zwischen Banner-UI und Cookie-Richtlinie</h3>'
|
||||
'<ul style="margin:8px 0 0 18px;padding:0">'
|
||||
+ "".join(items) +
|
||||
'</ul></div>'
|
||||
)
|
||||
@@ -0,0 +1,221 @@
|
||||
"""
|
||||
P80 — Replay-Pipeline (Mini-Version v1).
|
||||
|
||||
Lädt einen persistierten Snapshot und rendert die Audit-Mail mit dem
|
||||
AKTUELLEN Mail-Render-Code neu. Nutzbar fuer:
|
||||
* Mail-Layout-Aenderungen (P63-P67, P82 1-Pager, P84 Diff-Mode) testen
|
||||
* Action-Recipes anpassen
|
||||
* Disclaimer-Text iterieren
|
||||
* Pattern-Notice-Logik tunen
|
||||
|
||||
NICHT enthalten (kommt in v2):
|
||||
* MC-Scorecard re-run mit aktuellem scope_doc_type-Filter (P72) —
|
||||
erfordert MC-Pipeline-Refactoring aus _run_compliance_check
|
||||
* Vendor-Redundancy-Analyse re-run
|
||||
|
||||
Effekt v1: 7min Re-Scan -> 2-5 Sek fuer Mail-Layout-Iterationen.
|
||||
Effekt v2 (spaeter): auch fuer MC-Filter-Tests.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from compliance.services.check_snapshot import load_snapshot
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def replay_from_snapshot(
|
||||
db: Session,
|
||||
snapshot_id: str,
|
||||
recipient: str | None = None,
|
||||
dry_run: bool = False,
|
||||
) -> dict:
|
||||
"""Replay audit mail render from snapshot.
|
||||
|
||||
Args:
|
||||
db: SQLAlchemy session
|
||||
snapshot_id: UUID of snapshot to replay
|
||||
recipient: Override email recipient. None = skip send.
|
||||
dry_run: If True, render HTML but do not send mail.
|
||||
|
||||
Returns:
|
||||
{"snapshot_id", "html_size", "sections", "mail_sent", "preview"}
|
||||
"""
|
||||
snap = load_snapshot(db, snapshot_id)
|
||||
if not snap:
|
||||
return {"error": "snapshot not found", "snapshot_id": snapshot_id}
|
||||
|
||||
doc_entries = snap.get("doc_entries") or []
|
||||
banner_result = snap.get("banner_result") or {}
|
||||
profile_dict = snap.get("profile") or {}
|
||||
cmp_vendors = snap.get("cmp_vendors") or []
|
||||
site_label = snap.get("site_label") or snap.get("site_domain")
|
||||
|
||||
# Reconstruct doc_texts mapping (was the input to mail-render).
|
||||
# Snapshot-Schema speichert text unter "text" (nicht full_text).
|
||||
doc_texts: dict[str, str] = {}
|
||||
for e in doc_entries:
|
||||
dt = e.get("doc_type", "")
|
||||
txt = (e.get("text") or e.get("full_text") or e.get("text_preview") or "").strip()
|
||||
if dt and txt:
|
||||
doc_texts[dt] = txt
|
||||
|
||||
# Build results list mock (just enough for mail-render)
|
||||
def _dict_to_result(d: dict) -> Any:
|
||||
"""Best-effort reconstruction. Snapshot didn't persist DocCheckResult
|
||||
so we fake minimal fields. For real MC-replay (v2) we'd re-run the
|
||||
check_document_completeness function against the snapshot text."""
|
||||
return type("R", (), {
|
||||
"doc_type": d.get("doc_type", "other"),
|
||||
"label": d.get("doc_type", "Dokument"),
|
||||
"completeness_pct": d.get("completeness_pct", 0),
|
||||
"correctness_pct": d.get("correctness_pct"),
|
||||
"checks": [],
|
||||
"error": d.get("error", ""),
|
||||
})()
|
||||
|
||||
results = [_dict_to_result(e) for e in doc_entries]
|
||||
|
||||
# Render mail sections
|
||||
section_sizes: dict[str, int] = {}
|
||||
parts: list[str] = []
|
||||
|
||||
# P82: GF-1-Pager zuerst (5-Bullet-Summary)
|
||||
try:
|
||||
from compliance.services.gf_one_pager import build_gf_one_pager_html
|
||||
gf_html = build_gf_one_pager_html(
|
||||
site_name=site_label or "",
|
||||
scorecard=None, # Snapshot enthaelt keine MC-Scorecard
|
||||
banner_result=banner_result,
|
||||
library_mismatch_findings=None, # wird unten gefuellt
|
||||
scan_context=snap.get("scan_context"),
|
||||
)
|
||||
parts.append(gf_html)
|
||||
section_sizes["gf_one_pager"] = len(gf_html)
|
||||
except Exception as e:
|
||||
logger.warning("Replay: GF-1-pager failed: %s", e)
|
||||
|
||||
try:
|
||||
from compliance.api.agent_doc_check_critical import build_critical_findings_html
|
||||
critical_html = build_critical_findings_html(banner_result, None, results) or ""
|
||||
parts.append(critical_html)
|
||||
section_sizes["critical"] = len(critical_html)
|
||||
except Exception as e:
|
||||
logger.warning("Replay: critical-block failed: %s", e)
|
||||
|
||||
try:
|
||||
from compliance.api.scope_disclaimer import build_scope_disclaimer_html
|
||||
disclaimer = build_scope_disclaimer_html()
|
||||
parts.append(disclaimer)
|
||||
section_sizes["disclaimer"] = len(disclaimer)
|
||||
except Exception as e:
|
||||
logger.warning("Replay: disclaimer failed: %s", e)
|
||||
|
||||
try:
|
||||
from compliance.api.agent_doc_check_banner import build_banner_deep_html
|
||||
banner_html = build_banner_deep_html(banner_result) or ""
|
||||
parts.append(banner_html)
|
||||
section_sizes["banner"] = len(banner_html)
|
||||
except Exception as e:
|
||||
logger.warning("Replay: banner-block failed: %s", e)
|
||||
|
||||
try:
|
||||
from compliance.api.vvt_table_renderer import build_vvt_table_html
|
||||
vvt_html = build_vvt_table_html(cmp_vendors) or ""
|
||||
parts.append(vvt_html)
|
||||
section_sizes["vvt"] = len(vvt_html)
|
||||
except Exception as e:
|
||||
logger.warning("Replay: vvt failed: %s", e)
|
||||
|
||||
# P35 + P77 + P78 + P36: Textsignale (Save-Label, Cookies-in-DSE,
|
||||
# JC-Klausel, Social-Embeds)
|
||||
try:
|
||||
from compliance.services.doc_text_signals import (
|
||||
run_all as run_signal_checks,
|
||||
build_signals_block_html,
|
||||
)
|
||||
cookie_doc_missing = not bool(doc_texts.get("cookie"))
|
||||
sig_findings = run_signal_checks(
|
||||
banner_result, doc_texts, cookie_doc_missing,
|
||||
)
|
||||
if sig_findings:
|
||||
sig_html = build_signals_block_html(sig_findings)
|
||||
parts.append(sig_html)
|
||||
section_sizes["signals"] = len(sig_html)
|
||||
except Exception as e:
|
||||
logger.warning("Replay: signals block failed: %s", e)
|
||||
|
||||
# P92 + P94: Banner-Konsistenz
|
||||
try:
|
||||
from compliance.services.banner_consistency_checks import (
|
||||
run_all as run_consistency_checks,
|
||||
build_consistency_block_html,
|
||||
)
|
||||
cookie_doc_for_check = doc_texts.get("cookie") or doc_texts.get("dse") or ""
|
||||
cons = run_consistency_checks(
|
||||
banner_result or {}, cookie_doc_for_check, cmp_vendors,
|
||||
doc_texts=doc_texts,
|
||||
)
|
||||
if cons:
|
||||
cons_html = build_consistency_block_html(cons)
|
||||
parts.append(cons_html)
|
||||
section_sizes["consistency"] = len(cons_html)
|
||||
except Exception as e:
|
||||
logger.warning("Replay: consistency block failed: %s", e)
|
||||
|
||||
# P102: Cookie-Klassifikations-Pruefung
|
||||
try:
|
||||
from compliance.services.cookie_library_mismatch import (
|
||||
detect_mismatches, build_mismatch_block_html,
|
||||
)
|
||||
cookies_seen: list[str] = []
|
||||
for ph in (banner_result.get("phases") or {}).values():
|
||||
if isinstance(ph, dict):
|
||||
for ck in (ph.get("cookies") or []):
|
||||
if isinstance(ck, str):
|
||||
cookies_seen.append(ck)
|
||||
elif isinstance(ck, dict) and ck.get("name"):
|
||||
cookies_seen.append(ck["name"])
|
||||
doc_for_check = doc_texts.get("cookie") or doc_texts.get("dse") or ""
|
||||
if cookies_seen and doc_for_check:
|
||||
mm = detect_mismatches(db, cookies_seen, doc_for_check)
|
||||
if mm:
|
||||
mm_html = build_mismatch_block_html(mm)
|
||||
parts.append(mm_html)
|
||||
section_sizes["library_mismatch"] = len(mm_html)
|
||||
except Exception as e:
|
||||
logger.warning("Replay: mismatch block failed: %s", e)
|
||||
|
||||
full_html = "".join(parts)
|
||||
|
||||
result = {
|
||||
"snapshot_id": snapshot_id,
|
||||
"check_id": snap.get("check_id"),
|
||||
"site_domain": snap.get("site_domain"),
|
||||
"html_size": len(full_html),
|
||||
"sections": section_sizes,
|
||||
"mail_sent": False,
|
||||
"preview": full_html[:500] + "..." if len(full_html) > 500 else full_html,
|
||||
"full_html": full_html, # P88 PDF-Export braucht das volle HTML.
|
||||
}
|
||||
|
||||
if recipient and not dry_run:
|
||||
try:
|
||||
from compliance.services.smtp_sender import send_email
|
||||
email_res = send_email(
|
||||
recipient=recipient,
|
||||
subject=f"[REPLAY] {site_label} (Snapshot {snapshot_id[:8]})",
|
||||
body_html=full_html,
|
||||
)
|
||||
result["mail_sent"] = (email_res.get("status") == "sent")
|
||||
result["mail_status"] = email_res.get("status")
|
||||
except Exception as e:
|
||||
logger.warning("Replay: mail send failed: %s", e)
|
||||
result["mail_send_error"] = str(e)[:200]
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,179 @@
|
||||
"""
|
||||
P80 — Snapshot + Replay-Helper.
|
||||
|
||||
Persistiert die Roh-Daten eines Compliance-Check-Laufs (DSE-Text,
|
||||
Banner-HTML, Cookies, CMP-Vendors, Profile), damit die Audit-Pipeline
|
||||
spaeter ohne erneuten Browser-Crawl die Mail-Render-/MC-Scoring-Logik
|
||||
neu laufen kann.
|
||||
|
||||
Use Cases:
|
||||
* Logik-Iteration (MC-Filter P72, Mail-Layout, Action-Recipes) ohne
|
||||
7min Re-Crawl.
|
||||
* Regression-Test: Golden-Truth-Library (P81).
|
||||
* Diff-Mode: "was hat sich seit letztem Snapshot geaendert" (P84).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _to_jsonb(obj: Any) -> str:
|
||||
"""Serialize to JSON-string for psycopg2 JSONB insertion."""
|
||||
return json.dumps(obj, default=str, ensure_ascii=False)
|
||||
|
||||
|
||||
def _derive_site_domain(doc_entries: list[dict]) -> str:
|
||||
for e in doc_entries or []:
|
||||
url = (e.get("url") or "").strip()
|
||||
if url:
|
||||
try:
|
||||
netloc = urlparse(url).netloc.lower().replace("www.", "")
|
||||
if netloc:
|
||||
return netloc
|
||||
except Exception:
|
||||
continue
|
||||
return "unknown"
|
||||
|
||||
|
||||
def save_snapshot(
|
||||
db: Session,
|
||||
check_id: str,
|
||||
doc_entries: list[dict],
|
||||
banner_result: dict | None,
|
||||
profile: Any,
|
||||
cmp_vendors: list[dict] | None = None,
|
||||
scan_context: dict | None = None,
|
||||
site_label: str | None = None,
|
||||
notes: str | None = None,
|
||||
) -> str | None:
|
||||
"""Persist scan raw data. Returns snapshot UUID on success."""
|
||||
try:
|
||||
profile_dict: dict = {}
|
||||
if profile is not None:
|
||||
if hasattr(profile, "__dict__"):
|
||||
profile_dict = {k: v for k, v in profile.__dict__.items()
|
||||
if not k.startswith("_")}
|
||||
elif isinstance(profile, dict):
|
||||
profile_dict = profile
|
||||
|
||||
domain = _derive_site_domain(doc_entries or [])
|
||||
result = db.execute(
|
||||
text("""
|
||||
INSERT INTO compliance.compliance_check_snapshots
|
||||
(check_id, site_domain, site_label,
|
||||
doc_entries, banner_result, profile,
|
||||
scan_context, cmp_vendors, notes)
|
||||
VALUES (:cid, :dom, :lbl,
|
||||
CAST(:de AS JSONB), CAST(:br AS JSONB), CAST(:pr AS JSONB),
|
||||
CAST(:sc AS JSONB), CAST(:cv AS JSONB), :nt)
|
||||
RETURNING id
|
||||
"""),
|
||||
{
|
||||
"cid": check_id,
|
||||
"dom": domain,
|
||||
"lbl": site_label,
|
||||
"de": _to_jsonb(doc_entries or []),
|
||||
"br": _to_jsonb(banner_result) if banner_result else None,
|
||||
"pr": _to_jsonb(profile_dict) if profile_dict else None,
|
||||
"sc": _to_jsonb(scan_context) if scan_context else None,
|
||||
"cv": _to_jsonb(cmp_vendors) if cmp_vendors else None,
|
||||
"nt": notes,
|
||||
},
|
||||
)
|
||||
snapshot_id = str(result.fetchone()[0])
|
||||
db.commit()
|
||||
logger.info(
|
||||
"P80: snapshot saved id=%s check=%s domain=%s docs=%d",
|
||||
snapshot_id, check_id, domain, len(doc_entries or []),
|
||||
)
|
||||
return snapshot_id
|
||||
except Exception as e:
|
||||
logger.warning("P80 snapshot save failed for %s: %s", check_id, e)
|
||||
try:
|
||||
db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def load_snapshot(db: Session, snapshot_id: str) -> dict | None:
|
||||
"""Load a snapshot by UUID. Returns dict with all fields or None."""
|
||||
try:
|
||||
row = db.execute(
|
||||
text("""
|
||||
SELECT id, check_id, site_domain, site_label,
|
||||
doc_entries, banner_result, profile,
|
||||
scan_context, cmp_vendors, created_at,
|
||||
replay_count, notes
|
||||
FROM compliance.compliance_check_snapshots
|
||||
WHERE id = CAST(:sid AS uuid)
|
||||
"""),
|
||||
{"sid": snapshot_id},
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
db.execute(
|
||||
text("""
|
||||
UPDATE compliance.compliance_check_snapshots
|
||||
SET replay_count = replay_count + 1,
|
||||
last_replay_at = now()
|
||||
WHERE id = CAST(:sid AS uuid)
|
||||
"""),
|
||||
{"sid": snapshot_id},
|
||||
)
|
||||
db.commit()
|
||||
return {
|
||||
"id": str(row[0]),
|
||||
"check_id": row[1],
|
||||
"site_domain": row[2],
|
||||
"site_label": row[3],
|
||||
"doc_entries": row[4] or [],
|
||||
"banner_result": row[5],
|
||||
"profile": row[6] or {},
|
||||
"scan_context": row[7] or {},
|
||||
"cmp_vendors": row[8] or [],
|
||||
"created_at": str(row[9]),
|
||||
"replay_count": row[10],
|
||||
"notes": row[11],
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning("P80 snapshot load failed for %s: %s", snapshot_id, e)
|
||||
return None
|
||||
|
||||
|
||||
def list_snapshots_for_domain(db: Session, domain: str, limit: int = 20) -> list[dict]:
|
||||
"""List recent snapshots for a domain (for diff-mode P84)."""
|
||||
try:
|
||||
rows = db.execute(
|
||||
text("""
|
||||
SELECT id, check_id, site_domain, created_at, replay_count, notes
|
||||
FROM compliance.compliance_check_snapshots
|
||||
WHERE site_domain = :dom
|
||||
ORDER BY created_at DESC
|
||||
LIMIT :lim
|
||||
"""),
|
||||
{"dom": domain.lower().replace("www.", ""), "lim": limit},
|
||||
).fetchall()
|
||||
return [
|
||||
{
|
||||
"id": str(r[0]),
|
||||
"check_id": r[1],
|
||||
"site_domain": r[2],
|
||||
"created_at": str(r[3]),
|
||||
"replay_count": r[4],
|
||||
"notes": r[5],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
except Exception as e:
|
||||
logger.warning("P80 list_snapshots failed for %s: %s", domain, e)
|
||||
return []
|
||||
@@ -66,27 +66,35 @@ def _ensure_db() -> None:
|
||||
CREATE TABLE IF NOT EXISTS check_payloads (
|
||||
check_id TEXT PRIMARY KEY,
|
||||
vendors TEXT, -- JSON list[dict]
|
||||
profile TEXT -- JSON dict
|
||||
profile TEXT, -- JSON dict
|
||||
banner TEXT -- P20: JSON dict — full banner_result
|
||||
);
|
||||
""")
|
||||
# P20 migration: spalte 'banner' nachtraeglich anlegen wenn alt
|
||||
try:
|
||||
conn.execute("ALTER TABLE check_payloads ADD COLUMN banner TEXT")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
|
||||
|
||||
def record_check_payload(
|
||||
check_id: str,
|
||||
vendors: list[dict] | None,
|
||||
profile: dict | None,
|
||||
banner: dict | None = None,
|
||||
) -> None:
|
||||
"""Persist cmp_vendors + extracted_profile for later migration use."""
|
||||
"""Persist cmp_vendors + extracted_profile + banner_result (P20)."""
|
||||
try:
|
||||
_ensure_db()
|
||||
with sqlite3.connect(DB_PATH) as conn:
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO check_payloads "
|
||||
"(check_id, vendors, profile) VALUES (?, ?, ?)",
|
||||
"(check_id, vendors, profile, banner) VALUES (?, ?, ?, ?)",
|
||||
(
|
||||
check_id,
|
||||
json.dumps(vendors or [], ensure_ascii=False),
|
||||
json.dumps(profile or {}, ensure_ascii=False),
|
||||
json.dumps(banner or {}, ensure_ascii=False) if banner else None,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
@@ -95,13 +103,13 @@ def record_check_payload(
|
||||
|
||||
|
||||
def get_check_payload(check_id: str) -> dict | None:
|
||||
"""Load cmp_vendors + extracted_profile for a previous check."""
|
||||
"""Load cmp_vendors + extracted_profile + banner_result for a previous check."""
|
||||
try:
|
||||
_ensure_db()
|
||||
with sqlite3.connect(DB_PATH) as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
row = conn.execute(
|
||||
"SELECT vendors, profile FROM check_payloads WHERE check_id=?",
|
||||
"SELECT vendors, profile, banner FROM check_payloads WHERE check_id=?",
|
||||
(check_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
@@ -109,6 +117,7 @@ def get_check_payload(check_id: str) -> dict | None:
|
||||
return {
|
||||
"vendors": json.loads(row["vendors"] or "[]"),
|
||||
"profile": json.loads(row["profile"] or "{}"),
|
||||
"banner": json.loads(row["banner"]) if row["banner"] else None,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning("get_check_payload failed: %s", e)
|
||||
|
||||
@@ -82,6 +82,8 @@ class CompliancePDFGenerator:
|
||||
self._add_consent_section(story, ss, tenant_id)
|
||||
# Org Roles
|
||||
self._add_role_section(story, ss, tenant_id, project_id)
|
||||
# Stufe 2 — Quellen- und Lizenz-Footer (Attribution-Renderer Task #23)
|
||||
self._add_attribution_footer(story, ss)
|
||||
# Footer
|
||||
story.append(Spacer(1, 15 * mm))
|
||||
story.append(Paragraph("Erstellt mit BreakPilot Compliance SDK", ss["Small"]))
|
||||
@@ -214,3 +216,64 @@ class CompliancePDFGenerator:
|
||||
story.append(Paragraph("Keine Rollen zugewiesen.", ss["Body2"]))
|
||||
except Exception:
|
||||
story.append(Paragraph("Rollen-Tabelle nicht vorhanden.", ss["Small"]))
|
||||
|
||||
def _add_attribution_footer(self, story, ss) -> None:
|
||||
"""Stufe 2 of the attribution renderer (Task #23).
|
||||
|
||||
Adds a "Quellen und Lizenzen" section listing the platform's
|
||||
license-rule distribution and, crucially, the mandatory
|
||||
attribution lines for Rule-2 sources (CC-BY-SA, OECD, Apache).
|
||||
For Rule 1 sources the attribution is optional but rendered as
|
||||
a brief reference list for auditability.
|
||||
|
||||
The section is added to every generated compliance PDF so each
|
||||
export carries its own provenance footer — pauschale Hinweise
|
||||
in AGB/Impressum reichen rechtlich nicht (siehe
|
||||
project_attribution_strategy.md).
|
||||
"""
|
||||
try:
|
||||
rows = self.db.execute(text("""
|
||||
SELECT cc.license_rule, COUNT(*) AS n,
|
||||
array_agg(DISTINCT cpl.source_regulation ORDER BY cpl.source_regulation)
|
||||
FILTER (WHERE cpl.source_regulation IS NOT NULL) AS sources
|
||||
FROM compliance.canonical_controls cc
|
||||
LEFT JOIN compliance.control_parent_links cpl ON cpl.control_uuid = cc.id
|
||||
WHERE cc.license_rule IS NOT NULL
|
||||
GROUP BY cc.license_rule
|
||||
ORDER BY cc.license_rule
|
||||
""")).fetchall()
|
||||
except Exception as e:
|
||||
logger.warning("attribution footer skipped: %s", e)
|
||||
return
|
||||
if not rows:
|
||||
return
|
||||
|
||||
rule_labels = {1: "Hoheitsrecht/Public Domain (woertlich)",
|
||||
2: "Mit Attribution (CC-BY u.ae.)",
|
||||
3: "Nur Identifier-Verweis"}
|
||||
|
||||
story.append(Spacer(1, 8 * mm))
|
||||
story.append(Paragraph("Quellen & Lizenzen", ss["Section"]))
|
||||
story.append(Paragraph(
|
||||
"Dieser Bericht stuetzt sich auf klassifizierte Compliance-Controls "
|
||||
"aus den folgenden Quellen. Jede Quelle ist deterministisch in eine "
|
||||
"der drei Lizenzregeln (R1-R3) eingeordnet.", ss["Body2"]))
|
||||
|
||||
for r in rows:
|
||||
rule = int(r.license_rule)
|
||||
sources = (r.sources or [])[:8]
|
||||
label = rule_labels.get(rule, f"Regel {rule}")
|
||||
head = f"<b>R{rule} — {label}</b> ({r.n} Controls)"
|
||||
story.append(Paragraph(head, ss["Body2"]))
|
||||
if sources:
|
||||
src_text = "; ".join(sources)
|
||||
if len(r.sources or []) > 8:
|
||||
src_text += f" und {len(r.sources) - 8} weitere"
|
||||
story.append(Paragraph(src_text, ss["Small"]))
|
||||
if rule == 2:
|
||||
story.append(Paragraph(
|
||||
"Pflicht-Attribution: Inhalte aus den oben genannten Quellen sind "
|
||||
"unter den jeweiligen freien Lizenzen (z.B. CC-BY-SA, OECD-Public, "
|
||||
"Apache-2.0) wiedergegeben. Original-Urheber bleibt in jeder "
|
||||
"Weiterverwendung zu nennen.", ss["Small"]))
|
||||
story.append(Spacer(1, 2 * mm))
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
"""
|
||||
P59 — Cookie-Behavior-Validator.
|
||||
|
||||
4 Layer:
|
||||
A) Open Cookie Database lookup (declared category vs library category)
|
||||
B) Network-Traffic-Analyse (cookie value sent to third-party domains)
|
||||
C) Value-Pattern (Hash/UUID/PII heuristics on "essential"-declared cookies)
|
||||
D) Cross-Site frequency (from library metadata, when available)
|
||||
|
||||
Returns list of findings with severity + Art. 5(1)(b) DSGVO reference.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Iterable
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# --- Patterns für Layer C ---
|
||||
_HASH_PATTERN = re.compile(r"^[a-f0-9]{32,64}$", re.IGNORECASE)
|
||||
_UUID_PATTERN = re.compile(
|
||||
r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_BASE64_LONG = re.compile(r"^[A-Za-z0-9+/=]{40,}$")
|
||||
_PII_KEYS = ("email", "@", "user_id", "userid", "username", "phone")
|
||||
|
||||
# --- Purpose-Keyword-Bags für Layer A2 (Zweck-Match) ---
|
||||
_PURPOSE_KEYWORDS = {
|
||||
"marketing": {
|
||||
"tracking", "tracker", "targeting", "profiling", "profile",
|
||||
"advertis", "marketing", "remarket", "retargeting", "conversion",
|
||||
"audience", "behavioral", "behaviour", "personali", "interest",
|
||||
"campaign", "promotion", "pixel", "fingerprint",
|
||||
},
|
||||
"statistics": {
|
||||
"analytic", "analyse", "analyz", "measure", "measurement", "metric",
|
||||
"statistic", "performance", "telemetr", "monitoring", "usage",
|
||||
"reichweite", "auswert",
|
||||
},
|
||||
"essential": {
|
||||
"session", "sitzung", "authentic", "anmeld", "login", "logout",
|
||||
"security", "sicherheit", "csrf", "xsrf", "cookie consent",
|
||||
"cookie-einwilligung", "technisch notwendig", "load balanc",
|
||||
"lastverteil",
|
||||
},
|
||||
"functional": {
|
||||
"preference", "praeferen", "language", "sprache", "layout", "design",
|
||||
"cart", "warenkorb", "wishlist", "merkliste", "favorit", "theme",
|
||||
"darkmode", "darstellung",
|
||||
},
|
||||
"social_media": {
|
||||
"social", "facebook", "twitter", "linkedin", "instagram", "youtube",
|
||||
"embed", "share", "teilen",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _classify_purpose_text(text_value: str) -> set[str]:
|
||||
"""Return set of categories whose keywords appear in the purpose-text."""
|
||||
if not text_value:
|
||||
return set()
|
||||
t = text_value.lower()
|
||||
matches = set()
|
||||
for cat, kws in _PURPOSE_KEYWORDS.items():
|
||||
if any(k in t for k in kws):
|
||||
matches.add(cat)
|
||||
return matches
|
||||
|
||||
|
||||
def _lookup_library(db: Session, cookie_name: str,
|
||||
cookie_domain: str) -> dict | None:
|
||||
"""Layer A: find best library match."""
|
||||
# Exact domain match first, then wildcard
|
||||
cur = db.execute(text("""
|
||||
SELECT actual_category, purpose_en, purpose_de, vendor_name,
|
||||
data_receivers, source_name, source_url, confidence
|
||||
FROM compliance.cookie_library
|
||||
WHERE cookie_name = :name
|
||||
ORDER BY
|
||||
CASE WHEN domain_pattern = :domain THEN 0
|
||||
WHEN :domain ILIKE replace(domain_pattern, '*', '%') THEN 1
|
||||
ELSE 2 END,
|
||||
confidence DESC
|
||||
LIMIT 1
|
||||
"""), {"name": cookie_name, "domain": cookie_domain or ""})
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return {
|
||||
"actual_category": row[0], "purpose_en": row[1],
|
||||
"purpose_de": row[2], "vendor_name": row[3],
|
||||
"data_receivers": row[4] or [],
|
||||
"source_name": row[5], "source_url": row[6],
|
||||
"confidence": float(row[7] or 0),
|
||||
}
|
||||
|
||||
|
||||
def _value_pattern_flag(value: str | None, declared_category: str) -> str | None:
|
||||
"""Layer C: detect tracking-typical patterns in essential-declared cookies."""
|
||||
if not value or declared_category not in ("essential", "functional"):
|
||||
return None
|
||||
v = value.strip()
|
||||
if not v or len(v) < 16:
|
||||
return None
|
||||
if _UUID_PATTERN.match(v):
|
||||
return "UUID (Persistent Identifier)"
|
||||
if _HASH_PATTERN.match(v):
|
||||
return f"Hash-Wert ({len(v)} Hex-Zeichen — typisch User-ID)"
|
||||
if _BASE64_LONG.match(v):
|
||||
return f"Base64-Long ({len(v)} Zeichen — typisch Tracking-Payload)"
|
||||
vlow = v.lower()
|
||||
for kw in _PII_KEYS:
|
||||
if kw in vlow:
|
||||
return f"PII-Marker '{kw}' im Wert"
|
||||
return None
|
||||
|
||||
|
||||
def _category_label(cat: str) -> str:
|
||||
return {
|
||||
"essential": "technisch notwendig",
|
||||
"functional": "funktional",
|
||||
"statistics": "Analyse/Statistik",
|
||||
"marketing": "Marketing/Werbung",
|
||||
"social_media": "Social Media",
|
||||
"unknown": "unbekannt",
|
||||
}.get(cat, cat)
|
||||
|
||||
|
||||
def validate_cookie_behavior(
|
||||
db: Session,
|
||||
cookies_set: Iterable[dict],
|
||||
network_requests: list[dict] | None = None,
|
||||
first_party_domain: str = "",
|
||||
) -> list[dict]:
|
||||
"""Run all 4 layers, return list of finding dicts.
|
||||
|
||||
Each cookie dict should have: name, domain (optional), value (optional),
|
||||
declared_category (e.g. 'essential'), max_age_seconds (optional)."""
|
||||
findings: list[dict] = []
|
||||
network_requests = network_requests or []
|
||||
fp_domain = (first_party_domain or "").lower().lstrip(".")
|
||||
|
||||
# Pre-index network: which receivers got which cookie?
|
||||
receivers_by_cookie: dict[str, set[str]] = {}
|
||||
for req in network_requests:
|
||||
try:
|
||||
host = (req.get("host") or req.get("url", "")).lower()
|
||||
for cname in (req.get("cookies_sent") or []):
|
||||
receivers_by_cookie.setdefault(cname, set()).add(host)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
for c in cookies_set or []:
|
||||
name = (c.get("name") or "").strip()
|
||||
if not name:
|
||||
continue
|
||||
declared = (c.get("declared_category") or "").lower()
|
||||
domain = (c.get("domain") or "").lstrip(".").lower()
|
||||
value = c.get("value")
|
||||
|
||||
# Layer A: library lookup + 3-Tier-Severity (Kategorie / Zweck / Kombi)
|
||||
lib = _lookup_library(db, name, domain)
|
||||
declared_purpose = (c.get("declared_purpose") or "").strip()
|
||||
if lib and lib["actual_category"] != "unknown":
|
||||
# Layer A1: Kategorie-Mismatch (NUR wenn relevant — declared ist
|
||||
# essential/functional aber library sagt marketing/statistics)
|
||||
category_mismatch = (
|
||||
declared
|
||||
and lib["actual_category"] != declared
|
||||
and declared in ("essential", "functional")
|
||||
and lib["actual_category"] in ("marketing", "statistics",
|
||||
"social_media")
|
||||
)
|
||||
# Layer A2: Zweck-Text-Mismatch
|
||||
purpose_mismatch = False
|
||||
purpose_explain = ""
|
||||
if declared_purpose:
|
||||
declared_cats = _classify_purpose_text(declared_purpose)
|
||||
actual_cat = lib["actual_category"]
|
||||
# Mismatch wenn deklarierter Zweck-Text auf andere Kategorie
|
||||
# zeigt als die Library-Realität (z.B. declared "Sitzung" aber
|
||||
# tatsaechlich Marketing-Cookie)
|
||||
if actual_cat in ("marketing", "statistics", "social_media"):
|
||||
# Verdacht wenn deklarierter Zweck NUR essential/functional
|
||||
# Patterns hat (nichts zu Marketing/Analytics)
|
||||
if declared_cats and actual_cat not in declared_cats:
|
||||
# ausserdem: irgendein "harmloser" Keyword da
|
||||
if declared_cats & {"essential", "functional"}:
|
||||
purpose_mismatch = True
|
||||
purpose_explain = (
|
||||
f"Beschriebener Zweck deutet auf "
|
||||
f"{', '.join(_category_label(c) for c in declared_cats)}, "
|
||||
f"das Cookie wird aber tatsaechlich fuer "
|
||||
f"{_category_label(actual_cat)} eingesetzt"
|
||||
)
|
||||
|
||||
# 3-Tier-Severity
|
||||
if category_mismatch and purpose_mismatch:
|
||||
# CRITICAL — Vorsatz / Boeswilligkeit-Indiz
|
||||
findings.append({
|
||||
"layer": "A1+A2",
|
||||
"cookie_name": name,
|
||||
"severity": "CRITICAL",
|
||||
"type": "DUAL_MISMATCH_INTENT",
|
||||
"text": (
|
||||
f"Cookie '{name}' weist DOPPELTE Diskrepanz auf: "
|
||||
f"deklarierte Kategorie '{_category_label(declared)}' UND "
|
||||
f"deklarierter Zweck stimmen NICHT mit dem realen Verhalten "
|
||||
f"('{_category_label(lib['actual_category'])}') ueberein. "
|
||||
f"{purpose_explain}. {lib['source_name']}-Quelle: "
|
||||
f"{lib['purpose_en'][:120] if lib['purpose_en'] else ''}. "
|
||||
f"Doppel-Mismatch indiziert Vorsatz nach DSK Beschluss 2024-02 "
|
||||
f"(Cookie gezielt verschleiert) — siehe Bussgeld-Risiko Art. 83 "
|
||||
f"DSGVO bei wissentlicher Taeuschung. Konstruktive Annahme: "
|
||||
f"haeufig Marketing-/Agentur-Versehen ohne DSB-Kontrolle."
|
||||
),
|
||||
"legal_ref": "Art. 5(1)(a)+(b) DSGVO + DSK Beschluss 2024-02",
|
||||
"source": lib["source_url"] or lib["source_name"],
|
||||
})
|
||||
elif purpose_mismatch:
|
||||
# HIGH — Zweck stimmt nicht (Ahnungslosigkeit oder Vorsatz)
|
||||
findings.append({
|
||||
"layer": "A2",
|
||||
"cookie_name": name,
|
||||
"severity": "HIGH",
|
||||
"type": "PURPOSE_TEXT_MISMATCH",
|
||||
"text": (
|
||||
f"Cookie '{name}': {purpose_explain}. {lib['source_name']}: "
|
||||
f"{(lib['purpose_en'] or '')[:140]}. Deutet auf fehlende "
|
||||
f"Detail-Pruefung des Cookie-Verhaltens — Beschreibung sollte "
|
||||
f"das tatsaechliche Verhalten reflektieren (Art. 13 DSGVO + "
|
||||
f"Transparenz)."
|
||||
),
|
||||
"legal_ref": "Art. 13(1)(c) DSGVO (Zweck-Angabe muss korrekt sein)",
|
||||
"source": lib["source_url"] or lib["source_name"],
|
||||
})
|
||||
elif category_mismatch:
|
||||
# MEDIUM — Kategorie-Tag falsch, kann Fluechtigkeitsfehler sein
|
||||
findings.append({
|
||||
"layer": "A1",
|
||||
"cookie_name": name,
|
||||
"severity": "MEDIUM",
|
||||
"type": "CATEGORY_MISMATCH",
|
||||
"text": (
|
||||
f"Cookie '{name}' ist als '{_category_label(declared)}' "
|
||||
f"kategorisiert. {lib['source_name']} klassifiziert ihn als "
|
||||
f"'{_category_label(lib['actual_category'])}'"
|
||||
+ (f" — {lib['purpose_en'][:120]}" if lib['purpose_en'] else "")
|
||||
+ f". Vermutlich Konfigurations-Versehen im Consent-Tool "
|
||||
f"(haeufig bei Migrations zwischen CMP-Anbietern). "
|
||||
f"Korrektur: Cookie auf '{_category_label(lib['actual_category'])}'"
|
||||
f" umstellen, Consent neu einholen."
|
||||
),
|
||||
"legal_ref": "Art. 5(1)(b) DSGVO (Zweckbindung)",
|
||||
"source": lib["source_url"] or lib["source_name"],
|
||||
})
|
||||
|
||||
# Layer B: network traffic
|
||||
receivers = receivers_by_cookie.get(name, set())
|
||||
third_party = [r for r in receivers
|
||||
if r and fp_domain and not r.endswith(fp_domain)]
|
||||
if third_party and declared in ("essential", "functional"):
|
||||
findings.append({
|
||||
"layer": "B",
|
||||
"cookie_name": name,
|
||||
"severity": "HIGH",
|
||||
"type": "THIRD_PARTY_DESPITE_ESSENTIAL",
|
||||
"text": (
|
||||
f"Cookie '{name}' ist als '{_category_label(declared)}' "
|
||||
f"deklariert, der Wert wird aber an {len(third_party)} "
|
||||
f"externe(n) Empfaenger uebertragen: "
|
||||
f"{', '.join(sorted(third_party))[:200]}. "
|
||||
f"Damit liegt eine Drittlandstransfer-/Drittanbieter-Verarbeitung "
|
||||
f"vor, die nicht durch die deklarierte Zweckbestimmung gedeckt ist."
|
||||
),
|
||||
"legal_ref": "Art. 5(1)(b) Zweckbindung + Art. 13(1)(f) DSGVO",
|
||||
})
|
||||
|
||||
# Layer C: value pattern
|
||||
flag = _value_pattern_flag(value, declared)
|
||||
if flag:
|
||||
findings.append({
|
||||
"layer": "C",
|
||||
"cookie_name": name,
|
||||
"severity": "MEDIUM",
|
||||
"type": "TRACKING_PATTERN_DESPITE_ESSENTIAL",
|
||||
"text": (
|
||||
f"Cookie '{name}' ist als '{_category_label(declared)}' "
|
||||
f"deklariert, enthaelt aber: {flag}. Werte mit Tracking-Charakter "
|
||||
f"sind in nicht einwilligungsbeduerftigen Kategorien fragwuerdig."
|
||||
),
|
||||
"legal_ref": "Art. 5(1)(b) DSGVO + DSK-OH Telemedien 2024",
|
||||
})
|
||||
|
||||
# Layer D: cross-site frequency (later — needs metadata import)
|
||||
|
||||
return findings
|
||||
@@ -0,0 +1,221 @@
|
||||
"""
|
||||
Cookie-Compliance-Audit — 3-Quellen-Vergleich.
|
||||
|
||||
DAS ist der eigentliche Mehrwert des Tools:
|
||||
* A. Was in der Cookie-Richtlinie DEKLARIERT ist (Text-Parse)
|
||||
* B. Was im Browser TATSAECHLICH GELADEN wurde (after_accept)
|
||||
* C. Was unsere LIBRARY ueber den Cookie weiss (Vendor, Kategorie)
|
||||
|
||||
Daraus 3 Listen:
|
||||
1. ✓ deklariert + geladen + library-bekannt → compliant
|
||||
2. ❌ geladen aber NICHT deklariert → HIGH-Verstoss (Art. 13(1)(c) DSGVO)
|
||||
3. ⚠️ deklariert aber NICHT geladen → Tabelle veraltet (LOW)
|
||||
4. 🔍 deklariert + Library-Kategorie weicht ab → Pruefanlass
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Iterable
|
||||
|
||||
from sqlalchemy import text as sa_text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _normalize_cookie_name(name: str) -> str:
|
||||
"""Wildcard-Cookies wie 'AMCV_*', 'pm_sess_NNN' werden auf Prefix
|
||||
reduziert damit '_ga' und '_ga_GTM-XXX' als ein Cookie zaehlen."""
|
||||
if not name:
|
||||
return ""
|
||||
s = name.strip()
|
||||
# AMCV_*, sc_v44, etc.
|
||||
s = re.sub(r"[<\[].*?[>\]]", "", s) # entferne <ID>, [...]
|
||||
s = s.rstrip("*").rstrip("_")
|
||||
s = re.sub(r"_NNN$|_\d+$", "", s)
|
||||
return s.lower()
|
||||
|
||||
|
||||
def _extract_declared_cookies(cookie_doc_text: str | None) -> set[str]:
|
||||
"""Liest Cookie-Namen aus dem Cookie-Richtlinien-Text.
|
||||
Nutzt zuerst parse_cookie_table (Block/Tab-Format), dann
|
||||
parse_flat_cookie_text (Anchor-Pattern).
|
||||
"""
|
||||
if not cookie_doc_text:
|
||||
return set()
|
||||
declared: set[str] = set()
|
||||
try:
|
||||
from compliance.services.cookies_table_parser import (
|
||||
parse_cookie_table, parse_flat_cookie_text,
|
||||
)
|
||||
for v in parse_cookie_table(cookie_doc_text):
|
||||
for c in (v.get("cookies") or []):
|
||||
if isinstance(c, dict) and c.get("name"):
|
||||
declared.add(_normalize_cookie_name(c["name"]))
|
||||
for v in parse_flat_cookie_text(cookie_doc_text):
|
||||
for c in (v.get("cookies") or []):
|
||||
if isinstance(c, dict) and c.get("name"):
|
||||
declared.add(_normalize_cookie_name(c["name"]))
|
||||
except Exception as e:
|
||||
logger.warning("declared-cookie-extract failed: %s", e)
|
||||
return {n for n in declared if n}
|
||||
|
||||
|
||||
def _extract_browser_cookies(banner_result: dict | None) -> set[str]:
|
||||
"""Liest Cookie-Namen aus banner_result.phases.after_accept.cookies."""
|
||||
out: set[str] = set()
|
||||
if not isinstance(banner_result, dict):
|
||||
return out
|
||||
phases = banner_result.get("phases") or {}
|
||||
for ph_name in ("after_accept", "before_consent", "after_reject"):
|
||||
ph = phases.get(ph_name) or {}
|
||||
if not isinstance(ph, dict):
|
||||
continue
|
||||
for c in (ph.get("cookies") or []):
|
||||
if isinstance(c, str):
|
||||
out.add(_normalize_cookie_name(c))
|
||||
elif isinstance(c, dict) and c.get("name"):
|
||||
out.add(_normalize_cookie_name(c["name"]))
|
||||
return {n for n in out if n}
|
||||
|
||||
|
||||
def _lookup_library(db: Session, names: Iterable[str]) -> dict[str, dict]:
|
||||
"""Liefert {normalized_name: {category, vendor}} aus cookie_library."""
|
||||
nl = [n for n in names if n]
|
||||
if not nl:
|
||||
return {}
|
||||
try:
|
||||
rows = db.execute(sa_text(
|
||||
"SELECT cookie_name, actual_category, vendor_name "
|
||||
"FROM compliance.cookie_library "
|
||||
"WHERE LOWER(cookie_name) = ANY(:lc)"
|
||||
), {"lc": nl}).fetchall()
|
||||
return {r[0].lower(): {"category": r[1], "vendor": r[2]} for r in rows}
|
||||
except Exception as e:
|
||||
logger.warning("library lookup failed: %s", e)
|
||||
return {}
|
||||
|
||||
|
||||
def audit_cookie_compliance(
|
||||
db: Session | None,
|
||||
cookie_doc_text: str | None,
|
||||
banner_result: dict | None,
|
||||
) -> dict:
|
||||
"""Hauptfunktion: liefert dict mit 4 Listen + counts."""
|
||||
declared = _extract_declared_cookies(cookie_doc_text)
|
||||
browser = _extract_browser_cookies(banner_result)
|
||||
|
||||
all_names = declared | browser
|
||||
library = _lookup_library(db, all_names) if db else {}
|
||||
|
||||
declared_only = declared - browser
|
||||
browser_only = browser - declared
|
||||
both = declared & browser
|
||||
|
||||
return {
|
||||
"declared_count": len(declared),
|
||||
"browser_count": len(browser),
|
||||
"library_count": len(library),
|
||||
"compliant": sorted(both),
|
||||
"undeclared_in_browser": sorted(browser_only),
|
||||
"declared_not_loaded": sorted(declared_only),
|
||||
"library_metadata": library,
|
||||
"high_findings": len(browser_only),
|
||||
"low_findings": len(declared_only),
|
||||
}
|
||||
|
||||
|
||||
def build_cookie_audit_block_html(audit: dict) -> str:
|
||||
"""Rendert den 3-Spalten-Vergleichs-Block in die Mail."""
|
||||
if not audit:
|
||||
return ""
|
||||
n_dec = audit.get("declared_count", 0)
|
||||
n_brw = audit.get("browser_count", 0)
|
||||
n_undecl = len(audit.get("undeclared_in_browser") or [])
|
||||
n_dec_only = len(audit.get("declared_not_loaded") or [])
|
||||
n_both = len(audit.get("compliant") or [])
|
||||
|
||||
sev_color = "#dc2626" if n_undecl else "#16a34a"
|
||||
|
||||
undecl_html = ""
|
||||
if audit.get("undeclared_in_browser"):
|
||||
undecl_html = (
|
||||
'<div style="margin-top:10px;padding:10px 12px;background:#fee2e2;'
|
||||
'border:1px solid #fecaca;border-radius:6px">'
|
||||
f'<strong style="color:#991b1b">❌ {n_undecl} Cookie'
|
||||
f'{"s" if n_undecl != 1 else ""} im Browser geladen, '
|
||||
'aber NICHT in der Cookie-Richtlinie deklariert:</strong>'
|
||||
'<div style="font-family:monospace;font-size:10px;color:#7f1d1d;'
|
||||
'margin-top:6px;max-height:200px;overflow:auto">'
|
||||
+ ", ".join(audit["undeclared_in_browser"][:50])
|
||||
+ (f' ... +{n_undecl - 50} weitere'
|
||||
if n_undecl > 50 else '') +
|
||||
'</div>'
|
||||
'<div style="font-size:10px;color:#7f1d1d;margin-top:4px;'
|
||||
'font-style:italic">Art. 13(1)(c) DSGVO + § 25 TDDDG — '
|
||||
'die Empfaengerliste muss vollstaendig sein. Diese Cookies '
|
||||
'sind potenziell ungenannte Verarbeitungen.</div>'
|
||||
'</div>'
|
||||
)
|
||||
|
||||
dec_only_html = ""
|
||||
if audit.get("declared_not_loaded"):
|
||||
dec_only_html = (
|
||||
'<div style="margin-top:10px;padding:10px 12px;background:#fef3c7;'
|
||||
'border:1px solid #fde68a;border-radius:6px">'
|
||||
f'<strong style="color:#92400e">⚠️ {n_dec_only} Cookie'
|
||||
f'{"s" if n_dec_only != 1 else ""} in der Richtlinie '
|
||||
'deklariert, aber bei diesem Audit NICHT im Browser gesehen:</strong>'
|
||||
'<div style="font-family:monospace;font-size:10px;color:#78350f;'
|
||||
'margin-top:6px;max-height:200px;overflow:auto">'
|
||||
+ ", ".join(audit["declared_not_loaded"][:50])
|
||||
+ (f' ... +{n_dec_only - 50} weitere'
|
||||
if n_dec_only > 50 else '') +
|
||||
'</div>'
|
||||
'<div style="font-size:10px;color:#78350f;margin-top:4px;'
|
||||
'font-style:italic">Kein direkter Verstoss — die Cookies '
|
||||
'koennen nur in bestimmten User-Journeys / Geo-Regionen / '
|
||||
'eingeloggten Zustaenden geladen werden. Empfehlung: '
|
||||
'pruefen ob die Cookie-Richtlinie veraltet ist.</div>'
|
||||
'</div>'
|
||||
)
|
||||
|
||||
compliant_html = ""
|
||||
if audit.get("compliant"):
|
||||
compliant_html = (
|
||||
'<div style="margin-top:10px;padding:10px 12px;background:#dcfce7;'
|
||||
'border:1px solid #bbf7d0;border-radius:6px">'
|
||||
f'<strong style="color:#166534">✓ {n_both} Cookie'
|
||||
f'{"s" if n_both != 1 else ""} sowohl deklariert als auch geladen '
|
||||
'(compliant):</strong>'
|
||||
'<div style="font-family:monospace;font-size:10px;color:#14532d;'
|
||||
'margin-top:6px;max-height:150px;overflow:auto">'
|
||||
+ ", ".join(audit["compliant"][:50])
|
||||
+ (f' ... +{n_both - 50} weitere'
|
||||
if n_both > 50 else '') +
|
||||
'</div>'
|
||||
'</div>'
|
||||
)
|
||||
|
||||
return (
|
||||
'<div style="font-family:-apple-system,BlinkMacSystemFont,sans-serif;'
|
||||
'max-width:760px;margin:0 auto 16px;padding:14px 18px;'
|
||||
'background:#fff;border:1px solid #cbd5e1;border-radius:8px">'
|
||||
f'<div style="font-size:11px;color:{sev_color};text-transform:uppercase;'
|
||||
f'letter-spacing:1.2px;margin-bottom:4px;font-weight:600">'
|
||||
'Cookie-Compliance-Audit — 3-Quellen-Vergleich</div>'
|
||||
'<h3 style="margin:0 0 6px;font-size:14px;color:#1e293b">'
|
||||
f'{n_dec} in Richtlinie · {n_brw} im Browser · '
|
||||
f'{n_both} compliant · {n_undecl} undokumentiert · '
|
||||
f'{n_dec_only} nicht geladen</h3>'
|
||||
'<p style="margin:0 0 8px;font-size:11px;color:#475569;line-height:1.5">'
|
||||
'Wir vergleichen die in der Cookie-Richtlinie genannten Cookies '
|
||||
'mit dem was der Browser nach Akzeptieren tatsaechlich laed. '
|
||||
'Undokumentierte Cookies im Browser sind ein direkter Verstoss '
|
||||
'gegen die DSGVO-Informationspflicht.'
|
||||
'</p>'
|
||||
+ undecl_html + dec_only_html + compliant_html +
|
||||
'</div>'
|
||||
)
|
||||
@@ -0,0 +1,157 @@
|
||||
"""
|
||||
P102 — Cookie-Library-Mismatch-Detection pro Site.
|
||||
|
||||
Vergleicht die in einem Lauf erfassten Cookies (mit deklarierter
|
||||
Kategorie aus dem Cookie-Doc-Text) gegen die Library
|
||||
(compliance.cookie_library). Liefert Mismatches: deklariert ≠ Library.
|
||||
|
||||
Genutzt im Mail-Render als neuer Block "Cookie-Klassifikations-Pruefung".
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_CATEGORY_PATTERNS = [
|
||||
(re.compile(r"\b(?:strictly[-\s]?)?(?:notwendig|essential|funktional|"
|
||||
r"funktionscookie|technisch[- ]?notwendig)\b", re.I),
|
||||
"essential"),
|
||||
(re.compile(r"\b(?:tracking|analytics|analyse|statistik|"
|
||||
r"measurement|performance)\b", re.I),
|
||||
"statistics"),
|
||||
(re.compile(r"\b(?:marketing|werbung|advertising|targeting|"
|
||||
r"drittanbieter[- ]?cookie)\b", re.I),
|
||||
"marketing"),
|
||||
(re.compile(r"\b(?:social[-\s]?media|share|like)\b", re.I),
|
||||
"social_media"),
|
||||
]
|
||||
|
||||
|
||||
def _category_for(name: str, doc_text: str) -> str | None:
|
||||
if not doc_text or not name:
|
||||
return None
|
||||
idx = doc_text.find(name)
|
||||
if idx < 0:
|
||||
return None
|
||||
window = doc_text[max(0, idx - 50):idx + 400]
|
||||
for pat, cat in _CATEGORY_PATTERNS:
|
||||
if pat.search(window):
|
||||
return cat
|
||||
return None
|
||||
|
||||
|
||||
def _load_library(db: Session) -> dict[str, dict]:
|
||||
rows = db.execute(text(
|
||||
"SELECT cookie_name, actual_category, vendor_name "
|
||||
"FROM compliance.cookie_library"
|
||||
)).fetchall()
|
||||
return {r[0].lower(): {"category": r[1], "vendor": r[2]} for r in rows}
|
||||
|
||||
|
||||
def detect_mismatches(
|
||||
db: Session,
|
||||
cookie_names_seen: list[str],
|
||||
doc_text: str,
|
||||
) -> list[dict]:
|
||||
"""Returns list of finding dicts."""
|
||||
if not cookie_names_seen or not doc_text:
|
||||
return []
|
||||
|
||||
lib = _load_library(db)
|
||||
findings: list[dict] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
for cname in cookie_names_seen:
|
||||
cname = (cname or "").strip()
|
||||
if not cname or cname.lower() in seen:
|
||||
continue
|
||||
seen.add(cname.lower())
|
||||
declared = _category_for(cname, doc_text)
|
||||
if not declared:
|
||||
continue
|
||||
lib_entry = lib.get(cname.lower())
|
||||
if not lib_entry:
|
||||
continue
|
||||
lib_cat = lib_entry["category"]
|
||||
if lib_cat in (None, "unknown") or lib_cat == declared:
|
||||
continue
|
||||
|
||||
# HIGH wenn Library sagt Marketing aber Site als essential/statistics
|
||||
# deklariert (faktische Drittland-/Werbe-Verarbeitung versteckt
|
||||
# als technische/statistische Notwendigkeit). MEDIUM sonst.
|
||||
severity = "HIGH" if (
|
||||
lib_cat == "marketing" and declared in ("essential", "statistics")
|
||||
) else "MEDIUM"
|
||||
|
||||
findings.append({
|
||||
"cookie": cname,
|
||||
"declared_category": declared,
|
||||
"library_category": lib_cat,
|
||||
"library_vendor": lib_entry["vendor"],
|
||||
"severity": severity,
|
||||
})
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
def build_mismatch_block_html(findings: list[dict]) -> str:
|
||||
"""Render the mismatch findings as a Mail-Block."""
|
||||
if not findings:
|
||||
return ""
|
||||
|
||||
n_high = sum(1 for f in findings if f["severity"] == "HIGH")
|
||||
items: list[str] = []
|
||||
for f in findings[:25]:
|
||||
sev_color = "#dc2626" if f["severity"] == "HIGH" else "#d97706"
|
||||
items.append(
|
||||
f'<li style="margin-bottom:6px;font-size:11px">'
|
||||
f'<code style="background:#f1f5f9;padding:1px 4px;border-radius:2px">'
|
||||
f'{f["cookie"]}</code> '
|
||||
f'<span style="color:#64748b">— deklariert als</span> '
|
||||
f'<strong>{f["declared_category"]}</strong>, '
|
||||
f'<span style="color:#64748b">unsere Bibliothek + verbreitete '
|
||||
f'Vendor-Doku sagen</span> <strong style="color:{sev_color}">'
|
||||
f'{f["library_category"]}</strong> '
|
||||
f'(Vendor: {f["library_vendor"]})'
|
||||
f'</li>'
|
||||
)
|
||||
|
||||
return (
|
||||
'<div style="font-family:-apple-system,BlinkMacSystemFont,sans-serif;'
|
||||
'max-width:760px;margin:0 auto 16px;padding:14px 18px;'
|
||||
'background:#fffbeb;border:1px solid #fde68a;border-radius:8px">'
|
||||
'<div style="font-size:11px;color:#92400e;text-transform:uppercase;'
|
||||
'letter-spacing:1.2px;margin-bottom:4px;font-weight:600">'
|
||||
'Cookie-Klassifikations-Pruefung</div>'
|
||||
f'<h3 style="margin:0 0 8px;font-size:14px;color:#1e293b">'
|
||||
f'{len(findings)} Cookie{"s" if len(findings) != 1 else ""}'
|
||||
f' mit abweichender Klassifikation gefunden'
|
||||
f'{f" ({n_high} davon mit erhoehter Bedeutung)" if n_high else ""}'
|
||||
f'</h3>'
|
||||
'<p style="margin:0 0 10px;font-size:11px;color:#475569;line-height:1.5">'
|
||||
'Wir haben die in Ihrer Cookie-Richtlinie deklarierte Kategorie der '
|
||||
'Cookies mit unserer globalen Bibliothek (~2.300 Cookies aus Open-'
|
||||
'Cookie-Database + DACH-spezifischen Quellen) und der verbreiteten '
|
||||
'Vendor-Doku abgeglichen. Bei den folgenden Cookies stimmt die '
|
||||
'deklarierte Kategorie nicht mit dem typischerweise erwarteten '
|
||||
'Zweck ueberein. Das ist kein automatischer Verstoss — aber ein '
|
||||
'Pruefanlass: bei Marketing-Cookies braucht es Einwilligung, bei '
|
||||
'als "essential" deklarierten nicht. Empfehlung: mit DSB / '
|
||||
'Marketing-Agentur klaeren ob die Klassifikation korrigiert '
|
||||
'oder die Einwilligung anders eingeholt werden muss.</p>'
|
||||
'<ul style="margin:0 0 0 18px;padding:0">'
|
||||
+ "".join(items) +
|
||||
'</ul>'
|
||||
'<p style="margin:8px 0 0;font-size:10px;color:#94a3b8;'
|
||||
'font-style:italic">Hintergrund: Art. 13(1)(c) DSGVO + EDPB 5/2020 '
|
||||
'— der angegebene Verarbeitungszweck muss dem tatsaechlichen '
|
||||
'entsprechen.</p>'
|
||||
'</div>'
|
||||
)
|
||||
@@ -0,0 +1,147 @@
|
||||
"""
|
||||
Cookie-zu-Vendor-Fallback (P52 Lite).
|
||||
|
||||
Wenn weder cmp_payloads noch vendor_llm_extract Vendors lieferten,
|
||||
matchen wir die im after_accept gesehenen Cookies gegen die
|
||||
compliance.cookie_library und bauen Vendor-Records aus den Library-
|
||||
Eintraegen (cookie_name → vendor_name, actual_category).
|
||||
|
||||
Typisches Szenario: VW nutzt ein Custom-CMP (cookiemgmt-Wrapper),
|
||||
kein bekanntes IAB-Tool. cmp_payloads = leer, aber after_accept.cookies
|
||||
hat 28 Eintraege. Diese 28 Cookies sind in der Library = ~15-20 Vendors.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Iterable
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _collect_cookie_names(banner_result: dict | None) -> set[str]:
|
||||
names: set[str] = set()
|
||||
if not isinstance(banner_result, dict):
|
||||
return names
|
||||
for ph in (banner_result.get("phases") or {}).values():
|
||||
if not isinstance(ph, dict):
|
||||
continue
|
||||
for ck in (ph.get("cookies") or []):
|
||||
if isinstance(ck, str):
|
||||
names.add(ck.strip())
|
||||
elif isinstance(ck, dict):
|
||||
n = (ck.get("name") or "").strip()
|
||||
if n:
|
||||
names.add(n)
|
||||
return {n for n in names if n and len(n) <= 120}
|
||||
|
||||
|
||||
def lookup_vendors_from_library(
|
||||
db: Session,
|
||||
cookie_names: Iterable[str],
|
||||
) -> list[dict]:
|
||||
"""Resolves cookie names to vendor records via cookie_library."""
|
||||
names = [n for n in cookie_names if n]
|
||||
if not names:
|
||||
return []
|
||||
rows = db.execute(text(
|
||||
"""
|
||||
SELECT cookie_name, actual_category, vendor_name
|
||||
FROM compliance.cookie_library
|
||||
WHERE LOWER(cookie_name) = ANY(:lc)
|
||||
"""
|
||||
), {"lc": [n.lower() for n in names]}).fetchall()
|
||||
by_vendor: dict[str, dict] = {}
|
||||
for cname, cat, vendor in rows:
|
||||
if not vendor:
|
||||
continue
|
||||
entry = by_vendor.setdefault(vendor, {
|
||||
"name": vendor,
|
||||
"country": "",
|
||||
"purpose": "",
|
||||
"category": cat or "",
|
||||
"opt_out_url": "",
|
||||
"privacy_policy_url": "",
|
||||
"persistence": "",
|
||||
"cookies": [],
|
||||
"source": "library_fallback",
|
||||
})
|
||||
entry["cookies"].append({
|
||||
"name": cname, "purpose": "", "expiry": "",
|
||||
"is_third_party": True,
|
||||
})
|
||||
return list(by_vendor.values())
|
||||
|
||||
|
||||
def fallback_vendors_for_run(
|
||||
db: Session,
|
||||
banner_result: dict | None,
|
||||
existing_vendor_count: int,
|
||||
cookie_doc_text: str | None = None,
|
||||
) -> list[dict]:
|
||||
"""Returns extra vendor records to merge with the run's cmp_vendors.
|
||||
|
||||
VW-Lehre: cmp_vendors=6 (alle LLM-grob) reicht NICHT — die echte
|
||||
Cookie-Tabelle hat 30+ Eintraege. Wir fuehren den Lookup jetzt auch
|
||||
bei mid-tier-Counts aus, solange after_accept >= 15 Cookies hat
|
||||
ODER der Cookie-Doc-Text Cookie-Tabellen-Signale enthaelt.
|
||||
"""
|
||||
names = _collect_cookie_names(banner_result)
|
||||
|
||||
# Erweitere names um Cookie-Namen die im Cookie-Doc-Text als
|
||||
# Tabellen-Eintraege auftauchen (Pattern: NAME gefolgt von
|
||||
# "Tracking Cookies"/"Session Cookies"/"Funktional"/...).
|
||||
if cookie_doc_text:
|
||||
names |= _extract_cookie_names_from_doc(cookie_doc_text)
|
||||
|
||||
# Skip-Bedingungen ueberarbeitet:
|
||||
# - sehr wenige Cookies UND >= 5 Vendors schon vorhanden → skip
|
||||
# - sonst IMMER versuchen
|
||||
if len(names) < 5 and existing_vendor_count >= 5:
|
||||
return []
|
||||
if not names:
|
||||
return []
|
||||
|
||||
vendors = lookup_vendors_from_library(db, names)
|
||||
if vendors:
|
||||
logger.info(
|
||||
"Cookie-Library-Fallback: %d Vendors aus %d Cookies "
|
||||
"(existing cmp_vendors=%d)",
|
||||
len(vendors), len(names), existing_vendor_count,
|
||||
)
|
||||
return vendors
|
||||
|
||||
|
||||
_TABLE_ROW_RE = re.compile(
|
||||
r"\b([A-Za-z_][A-Za-z0-9_\-\.]{2,40})\s+"
|
||||
r"(?:Tracking Cookies|Session Cookies|Funktional|Marketing|"
|
||||
r"Analytics|Performance|Notwendig|Strictly\s+Necessary|"
|
||||
r"Statistik|Werbung|Targeting|Personalisierung)",
|
||||
re.I,
|
||||
)
|
||||
|
||||
|
||||
def _extract_cookie_names_from_doc(text: str) -> set[str]:
|
||||
"""Pattern-basiertes Erkennen von Cookie-Tabellen-Zeilen.
|
||||
|
||||
VW-Cookie-Tabelle hat Form:
|
||||
'IDE Tracking Cookies (Marketing) Dieser Cookie ... 13 Monate'
|
||||
Das fangen wir mit einem Cookie-Name-vor-Category-Pattern.
|
||||
"""
|
||||
out: set[str] = set()
|
||||
for m in _TABLE_ROW_RE.finditer(text):
|
||||
name = m.group(1).strip()
|
||||
# Filter offensichtliche Noise (Pronomen, Verben)
|
||||
nl = name.lower()
|
||||
if nl in ("dieser", "diese", "ein", "der", "die", "das",
|
||||
"session", "permanent", "funktional", "notwendig",
|
||||
"marketing", "analytics", "werbung", "anbieter",
|
||||
"google", "facebook", "tracking", "cookie", "cookies"):
|
||||
continue
|
||||
if len(name) >= 3:
|
||||
out.add(name)
|
||||
return out
|
||||
@@ -0,0 +1,439 @@
|
||||
"""
|
||||
Parst Cookie-Tabellen die der User direkt ins Frontend kopiert.
|
||||
|
||||
Typische Quellen:
|
||||
* Browser-Copy aus VW/BMW/Mercedes Cookie-Richtlinie (Tab-getrennt)
|
||||
* Excel-Export aus Borlabs / OneTrust / Cookiebot Admin (CSV / Pipe)
|
||||
* Markdown-Tabelle aus interner Doku
|
||||
|
||||
Erkennt 4 Spalten-Layouts (heuristisch):
|
||||
1. [Name, Kategorie, Beschreibung, Speicherdauer, Provider]
|
||||
2. [Name, Provider, Zweck, Speicherdauer]
|
||||
3. [Name, Beschreibung, Speicherdauer]
|
||||
4. nur [Name, Speicherdauer]
|
||||
|
||||
Output: gleiche Vendor-Record-Struktur wie vendor_extractor / LLM —
|
||||
damit der Rest der Pipeline (VVT-Tabelle, Library-Mismatch-Check) ohne
|
||||
Aenderung weiterlaeuft.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_CATEGORY_LABELS = (
|
||||
"notwendig", "essential", "funktional", "tracking", "marketing",
|
||||
"statistik", "analyse", "analytics", "performance", "werbung",
|
||||
"advertising", "targeting", "preferences", "social_media",
|
||||
"strictly necessary", "personalisierung",
|
||||
)
|
||||
|
||||
|
||||
def _looks_like_separator(line: str) -> str | None:
|
||||
"""Detect the column-separator of a tabular line."""
|
||||
if "\t" in line and line.count("\t") >= 2:
|
||||
return "\t"
|
||||
if " | " in line and line.count(" | ") >= 2:
|
||||
return " | "
|
||||
if ";" in line and line.count(";") >= 2 and "," not in line[:20]:
|
||||
return ";"
|
||||
if "," in line and line.count(",") >= 3:
|
||||
return ","
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_category(s: str) -> str:
|
||||
sl = s.lower().strip()
|
||||
for cat in _CATEGORY_LABELS:
|
||||
if cat in sl:
|
||||
if cat in ("notwendig", "essential", "strictly necessary"):
|
||||
return "essential"
|
||||
if cat in ("tracking", "marketing", "werbung",
|
||||
"advertising", "targeting"):
|
||||
return "marketing"
|
||||
if cat in ("statistik", "analyse", "analytics", "performance"):
|
||||
return "statistics"
|
||||
if cat == "funktional":
|
||||
return "functional"
|
||||
if cat == "social_media":
|
||||
return "social_media"
|
||||
return sl[:30]
|
||||
|
||||
|
||||
def _parse_persistence(s: str) -> str:
|
||||
"""Extracts 'Speicherdauer' notation."""
|
||||
m = re.search(
|
||||
r"(\d+\s*(sekunde|minute|stunde|tag|woche|monat|jahr|day|month|year)[^\s,;|]{0,5})",
|
||||
s, re.I,
|
||||
)
|
||||
if m:
|
||||
return m.group(1).strip()[:80]
|
||||
if re.search(r"\bsession\b", s, re.I):
|
||||
return "Session"
|
||||
if re.search(r"permanent", s, re.I):
|
||||
return "Permanent"
|
||||
return ""
|
||||
|
||||
|
||||
_CATEGORY_INDICATORS = (
|
||||
"funktionscookie", "tracking cookie", "trackingcookie",
|
||||
"marketing", "analytics", "necessary", "notwendig",
|
||||
"performance", "session cookie", "persistent cookie",
|
||||
"permanent cookie", "permanent/protokoll", "sitzungs-cookie",
|
||||
)
|
||||
|
||||
|
||||
def parse_block_format(text: str) -> list[dict]:
|
||||
"""Block-Format (Browser-Copy aus VW/BMW/Mercedes ohne Tab-Trenner):
|
||||
Pro Cookie 5 Zeilen: Name / Kategorie / Zweck / Speicherdauer / Art.
|
||||
|
||||
Heuristik: gehe ueber alle Zeilen. Wenn eine Zeile NICHT eine
|
||||
Kategorie/Dauer/Art ist und die naechste eine Kategorie enthaelt
|
||||
→ das ist ein Cookie-Name. Sammle die naechsten 4 Zeilen als
|
||||
Kategorie/Zweck/Dauer/Art.
|
||||
"""
|
||||
if not text or len(text) < 100:
|
||||
return []
|
||||
raw_lines = [ln.strip() for ln in text.splitlines()]
|
||||
# Aggressive newline-collapse: leere Zeilen entfernen, aber Zeilen
|
||||
# die Teil eines mehrzeiligen Zwecks sind moegen separat bleiben.
|
||||
lines = [ln for ln in raw_lines if ln]
|
||||
if len(lines) < 10:
|
||||
return []
|
||||
|
||||
# Drop the header row(s) if present
|
||||
start = 0
|
||||
if lines[0].lower() in ("name des cookies", "cookie name", "name"):
|
||||
start = 5 if len(lines) > 5 else 1
|
||||
|
||||
by_vendor: dict[str, dict] = {}
|
||||
seen_names: set[str] = set()
|
||||
i = start
|
||||
while i < len(lines) - 2:
|
||||
name_line = lines[i]
|
||||
cat_line = lines[i + 1] if i + 1 < len(lines) else ""
|
||||
# Verify cat_line is a category indicator (otherwise the
|
||||
# block is malformed — skip 1 line and try again).
|
||||
if not any(c in cat_line.lower() for c in _CATEGORY_INDICATORS):
|
||||
i += 1
|
||||
continue
|
||||
# Cookie-Name validation
|
||||
nl = name_line.lower().strip()
|
||||
if (not name_line or len(name_line) > 80
|
||||
or len(name_line) < 2
|
||||
or any(c in nl for c in _CATEGORY_INDICATORS)
|
||||
or nl in seen_names
|
||||
or nl in ("name des cookies", "kategorie",
|
||||
"verwendungszweck", "speicherdauer",
|
||||
"art des cookies")):
|
||||
i += 1
|
||||
continue
|
||||
# Look ahead for the Art-Cookie line (max 8 lines forward)
|
||||
purpose_parts: list[str] = []
|
||||
persistence = ""
|
||||
art = ""
|
||||
j = i + 2
|
||||
while j < min(i + 12, len(lines)):
|
||||
ln = lines[j]
|
||||
ll = ln.lower()
|
||||
if any(t in ll for t in (
|
||||
"permanent/protokoll", "session cookie",
|
||||
"persistent cookie", "permanent cookie",
|
||||
"sitzungs-cookie", "permanent/ protokoll",
|
||||
)):
|
||||
art = ln
|
||||
if not persistence and j > i + 2:
|
||||
persistence = lines[j - 1]
|
||||
break
|
||||
purpose_parts.append(ln)
|
||||
j += 1
|
||||
purpose = " ".join(purpose_parts[:-1]) if len(purpose_parts) > 1 else " ".join(purpose_parts)
|
||||
purpose = purpose[:500].strip()
|
||||
|
||||
seen_names.add(nl)
|
||||
provider = _guess_vendor(name_line) or "Unbekannter Anbieter (VW-intern)"
|
||||
# Marketing-Cookies = Drittanbieter
|
||||
if "marketing" in cat_line.lower() or "tracking" in cat_line.lower():
|
||||
if provider == "Unbekannter Anbieter (VW-intern)":
|
||||
provider = "Unbekannter Drittanbieter (Marketing)"
|
||||
entry = by_vendor.setdefault(provider, {
|
||||
"name": provider, "country": "",
|
||||
"purpose": "", "category": _normalize_category(cat_line),
|
||||
"opt_out_url": "", "privacy_policy_url": "",
|
||||
"persistence": "",
|
||||
"cookies": [],
|
||||
"source": "block_paste",
|
||||
})
|
||||
entry["cookies"].append({
|
||||
"name": name_line,
|
||||
"purpose": purpose[:300],
|
||||
"expiry": persistence,
|
||||
"is_third_party": "tracking" in cat_line.lower() or "marketing" in cat_line.lower(),
|
||||
})
|
||||
i = j + 1 if art else i + 5
|
||||
|
||||
out = list(by_vendor.values())
|
||||
logger.info("parse_block_format: %d vendors / %d cookies",
|
||||
len(out), sum(len(v["cookies"]) for v in out))
|
||||
return out
|
||||
|
||||
|
||||
def parse_cookie_table(text: str) -> list[dict]:
|
||||
"""Returns vendor-records aus einer copy-pasted Cookie-Tabelle.
|
||||
|
||||
Probiert in dieser Reihenfolge:
|
||||
1. Tab/Pipe/Komma-getrennt (klassisches Tabellen-Layout)
|
||||
2. 5-Zeilen-Block-Format (VW Browser-Copy)
|
||||
3. return []
|
||||
"""
|
||||
if not text or len(text) < 100:
|
||||
return []
|
||||
lines = [ln.strip() for ln in text.splitlines() if ln.strip()]
|
||||
if not lines:
|
||||
return []
|
||||
|
||||
# Sample 30 lines to detect separator
|
||||
sample = lines[:60]
|
||||
sep_counts: dict[str, int] = {}
|
||||
for ln in sample:
|
||||
sep = _looks_like_separator(ln)
|
||||
if sep:
|
||||
sep_counts[sep] = sep_counts.get(sep, 0) + 1
|
||||
if not sep_counts or max(sep_counts.values()) < 3:
|
||||
# Kein Separator-Format → versuche Block-Format
|
||||
block_vendors = parse_block_format(text)
|
||||
if block_vendors:
|
||||
return block_vendors
|
||||
return []
|
||||
|
||||
sep = max(sep_counts, key=sep_counts.get)
|
||||
logger.info("cookies_table_parser: detected separator '%s' (%d hits)",
|
||||
sep, sep_counts[sep])
|
||||
|
||||
# Parse rows
|
||||
rows: list[list[str]] = []
|
||||
for ln in lines:
|
||||
if sep in ln:
|
||||
parts = [p.strip().strip('"') for p in ln.split(sep)]
|
||||
if len(parts) >= 2 and parts[0]:
|
||||
rows.append(parts)
|
||||
|
||||
if len(rows) < 3:
|
||||
return []
|
||||
|
||||
# Detect column layout from header (first row) or by content
|
||||
header_row = [c.lower() for c in rows[0]]
|
||||
has_header = any(h in " ".join(header_row) for h in
|
||||
("cookie", "name", "anbieter", "provider", "zweck",
|
||||
"kategorie", "speicherdauer", "dauer"))
|
||||
data_rows = rows[1:] if has_header else rows
|
||||
|
||||
# Map columns by header keyword or by position
|
||||
col_idx = {"name": 0, "provider": -1, "category": -1,
|
||||
"purpose": -1, "persistence": -1}
|
||||
if has_header:
|
||||
for i, h in enumerate(header_row):
|
||||
if "name" in h or "cookie" in h:
|
||||
col_idx["name"] = i
|
||||
elif "anbieter" in h or "provider" in h or "domain" in h:
|
||||
col_idx["provider"] = i
|
||||
elif "kategorie" in h or "type" in h or "art" in h:
|
||||
col_idx["category"] = i
|
||||
elif "zweck" in h or "purpose" in h or "beschreib" in h:
|
||||
col_idx["purpose"] = i
|
||||
elif "speicher" in h or "dauer" in h or "lebens" in h or "expir" in h:
|
||||
col_idx["persistence"] = i
|
||||
|
||||
# Aggregate by vendor (or by name if no vendor column)
|
||||
by_vendor: dict[str, dict] = {}
|
||||
for r in data_rows:
|
||||
if len(r) < 2:
|
||||
continue
|
||||
name = r[col_idx["name"]] if col_idx["name"] < len(r) else r[0]
|
||||
name = (name or "").strip()
|
||||
if not name or len(name) > 120 or len(name) < 2:
|
||||
continue
|
||||
provider = ""
|
||||
if col_idx["provider"] >= 0 and col_idx["provider"] < len(r):
|
||||
provider = r[col_idx["provider"]].strip()
|
||||
if not provider:
|
||||
# Heuristik: wenn Spalte 'Anbieter' fehlt, raten aus Cookie-Name
|
||||
provider = _guess_vendor(name)
|
||||
if not provider:
|
||||
provider = "Unbekannter Anbieter"
|
||||
|
||||
category = ""
|
||||
purpose = ""
|
||||
persistence = ""
|
||||
if col_idx["category"] >= 0 and col_idx["category"] < len(r):
|
||||
category = _normalize_category(r[col_idx["category"]])
|
||||
if col_idx["purpose"] >= 0 and col_idx["purpose"] < len(r):
|
||||
purpose = r[col_idx["purpose"]][:500]
|
||||
if col_idx["persistence"] >= 0 and col_idx["persistence"] < len(r):
|
||||
persistence = _parse_persistence(r[col_idx["persistence"]])
|
||||
if not category:
|
||||
# Inferieren aus purpose-Text
|
||||
category = _normalize_category(purpose)
|
||||
|
||||
entry = by_vendor.setdefault(provider, {
|
||||
"name": provider, "country": "",
|
||||
"purpose": purpose[:300] if purpose else "",
|
||||
"category": category,
|
||||
"opt_out_url": "", "privacy_policy_url": "",
|
||||
"persistence": persistence,
|
||||
"cookies": [],
|
||||
"source": "table_paste",
|
||||
})
|
||||
entry["cookies"].append({
|
||||
"name": name, "purpose": purpose[:200],
|
||||
"expiry": persistence, "is_third_party": True,
|
||||
})
|
||||
|
||||
out = list(by_vendor.values())
|
||||
logger.info("cookies_table_parser: %d vendors / %d cookies parsed",
|
||||
len(out), sum(len(v["cookies"]) for v in out))
|
||||
return out
|
||||
|
||||
|
||||
# textContent-Output von HTML-Tabellen verkettet Zellen ohne Whitespace
|
||||
# (z.B. VW: "Permanent/Protokoll_fbcTracking Cookies (Marketing)..."). Wir
|
||||
# erkennen Cookie-Eintraege ueber 2 Anker:
|
||||
# - Davor: typisches End-Token einer vorherigen Tabellen-Zelle
|
||||
# (Speicherdauer-Suffix wie Permanent/Protokoll, Session Cookie, ...)
|
||||
# - Danach: Kategorie-Token (Tracking Cookies, Funktionscookie, ...)
|
||||
# Dazwischen: der Cookie-Name (3-50 Zeichen, alphanum/underscore/dash).
|
||||
_FLAT_ROW_RE = re.compile(
|
||||
r"(?:Permanent/Protokoll|Session Cookie|Persistent Cookie|"
|
||||
r"TagePersistent|TageSitzungs-Cookie|TageSession Cookie|"
|
||||
r"MinutenPersistent|MinutenSession Cookie|StundenPersistent|"
|
||||
r"MonatePersistent|JahrePersistent)"
|
||||
r"([A-Za-z_][A-Za-z0-9_\-\.]{1,40}?)"
|
||||
r"(?=Tracking Cookies|Session Cookies|Funktionscookie|Funktional|"
|
||||
r"Marketing|Analytics|Necessary)",
|
||||
re.I,
|
||||
)
|
||||
|
||||
|
||||
def parse_flat_cookie_text(text: str) -> list[dict]:
|
||||
"""Variante fuer Sites wie VW die ihre Cookie-Tabelle als flachen
|
||||
Text liefern (textContent-Output ohne Whitespace zwischen Zellen).
|
||||
|
||||
Regex anchored auf vorherige Speicherdauer-Suffixe + folgende
|
||||
Kategorie-Token → extrahiert den Cookie-Namen dazwischen.
|
||||
"""
|
||||
if not text or len(text) < 500:
|
||||
return []
|
||||
names = _FLAT_ROW_RE.findall(text)
|
||||
if len(names) < 3:
|
||||
return []
|
||||
by_vendor: dict[str, dict] = {}
|
||||
seen_names: set[str] = set()
|
||||
for raw in names:
|
||||
name = raw.strip()
|
||||
nl = name.lower()
|
||||
if nl in seen_names:
|
||||
continue
|
||||
if nl in ("dieser", "diese", "ein", "der", "die", "das",
|
||||
"session", "permanent", "funktional", "notwendig",
|
||||
"marketing", "analytics", "werbung", "anbieter",
|
||||
"tracking", "cookie", "cookies", "und", "von",
|
||||
"einer", "ist", "alle", "noch", "auch", "name",
|
||||
"art", "zweck", "dauer", "test"):
|
||||
continue
|
||||
if len(name) < 3 or len(name) > 60:
|
||||
continue
|
||||
seen_names.add(nl)
|
||||
vendor = _guess_vendor(name) or "Unbekannter Anbieter"
|
||||
entry = by_vendor.setdefault(vendor, {
|
||||
"name": vendor, "country": "",
|
||||
"purpose": "", "category": "",
|
||||
"opt_out_url": "", "privacy_policy_url": "",
|
||||
"persistence": "",
|
||||
"cookies": [],
|
||||
"source": "flat_pattern",
|
||||
})
|
||||
entry["cookies"].append({
|
||||
"name": name, "purpose": "",
|
||||
"expiry": "", "is_third_party": True,
|
||||
})
|
||||
out = list(by_vendor.values())
|
||||
logger.info("parse_flat_cookie_text: %d vendors / %d cookies",
|
||||
len(out), sum(len(v["cookies"]) for v in out))
|
||||
return out
|
||||
|
||||
|
||||
_VENDOR_GUESS = (
|
||||
# Google-Familie (alles unter "Google" zusammenfassen — Dedup kuemmert sich)
|
||||
("_ga", "Google"), ("_gid", "Google"), ("_gcl_", "Google"),
|
||||
("ANID", "Google"), ("AID", "Google"), ("FPGCLDC", "Google"),
|
||||
("FPAU", "Google"), ("FLC", "Google"), ("APC", "Google"),
|
||||
("IDE", "Google"), ("DSID", "Google"), ("TAID", "Google"),
|
||||
("NID", "Google"), ("1P_JAR", "Google"),
|
||||
# Meta / Facebook
|
||||
("_fbp", "Meta / Facebook"), ("_fbc", "Meta / Facebook"),
|
||||
# fr ist Meta-Cookie, nur wenn keine andere Site-eigene Verwendung
|
||||
# Microsoft / Bing
|
||||
("_pin_unauth", "Pinterest"), ("_uetsid", "Microsoft Bing"),
|
||||
("_uetvid", "Microsoft Bing"), ("MUID", "Microsoft"),
|
||||
# Soziale Netzwerke
|
||||
("tt_", "TikTok"), ("li_at", "LinkedIn"),
|
||||
# CMP
|
||||
("OptanonConsent", "OneTrust"), ("cookieconsent", "Borlabs / Cookie-CMP"),
|
||||
("CookieConsentPolicy", "Borlabs / Cookie-CMP"),
|
||||
# Analytics
|
||||
("eta_", "etracker"), ("matomo", "Matomo"),
|
||||
("_hjid", "Hotjar"), ("_hj", "Hotjar"),
|
||||
("ajs_", "Segment"), ("amp_", "Amplitude"),
|
||||
# Adobe-Familie
|
||||
("sat_track", "Adobe Experience Cloud"),
|
||||
("AMCV", "Adobe Experience Cloud"),
|
||||
("AMCVS", "Adobe Experience Cloud"),
|
||||
("demdex", "Adobe Experience Cloud"),
|
||||
("dextp", "Adobe Experience Cloud"),
|
||||
("dpm", "Adobe Experience Cloud"),
|
||||
("mbox", "Adobe Target"),
|
||||
("smartSignals", "Adobe Experience Cloud"),
|
||||
("adbCDP", "Adobe Experience Cloud"),
|
||||
("s_cc", "Adobe Analytics"), ("s_sq", "Adobe Analytics"),
|
||||
("s_ecid", "Adobe Analytics"), ("s_vi", "Adobe Analytics"),
|
||||
("s_fid", "Adobe Analytics"), ("s_plt", "Adobe Analytics"),
|
||||
("s_pltp", "Adobe Analytics"), ("s_invisit", "Adobe Analytics"),
|
||||
("s_vnc365", "Adobe Analytics"), ("s_ivc", "Adobe Analytics"),
|
||||
("sc_appvn", "Adobe Analytics"), ("sc_pCmp", "Adobe Analytics"),
|
||||
("sc_prevpage", "Adobe Analytics"), ("sc_prop", "Adobe Analytics"),
|
||||
("sc_v17", "Adobe Analytics"), ("sc_v44", "Adobe Analytics"),
|
||||
("sc_v49", "Adobe Analytics"),
|
||||
# The Trade Desk
|
||||
("TDID", "The Trade Desk"), ("TDCPM", "The Trade Desk"),
|
||||
("TTDOptOut", "The Trade Desk"),
|
||||
# AdForm
|
||||
("uid", "AdForm"), ("cid", "AdForm"), ("otsid", "AdForm"),
|
||||
# everest
|
||||
("everest", "Adobe Advertising Cloud (everest)"),
|
||||
# Infra/CDN
|
||||
("__cf", "Cloudflare"), ("datadome", "DataDome"),
|
||||
("incap_", "Imperva Incapsula"), ("awsalb", "AWS Load Balancer"),
|
||||
# Salesforce
|
||||
("sfdc-", "Salesforce"), ("X-Salesforce", "Salesforce"),
|
||||
("liveagent_", "Salesforce LiveAgent"),
|
||||
# Inbenta
|
||||
("inbenta", "Inbenta"),
|
||||
# Sonstige Tracker
|
||||
("_pk_", "Matomo / Piwik"),
|
||||
("hmt_", "Akamai mPulse"),
|
||||
# EDAA / Industry Self-regulation
|
||||
("EDAAT", "EDAA / Online Choices"),
|
||||
("Eboptout", "EDAA / Online Choices"),
|
||||
)
|
||||
|
||||
|
||||
def _guess_vendor(cookie_name: str) -> str:
|
||||
nl = cookie_name.lower()
|
||||
for prefix, vendor in _VENDOR_GUESS:
|
||||
if nl.startswith(prefix.lower()) or prefix.lower() in nl:
|
||||
return vendor
|
||||
return ""
|
||||
@@ -39,6 +39,12 @@ AGB_CHECKLIST = [
|
||||
"patterns": [
|
||||
r"vertragsschluss", r"zustandekommen",
|
||||
r"contract\s+formation", r"angebot\s+und\s+annahme",
|
||||
# P41: English synonyms
|
||||
r"conclusion\s+of\s+(?:the\s+)?contract",
|
||||
r"contract\s+(?:is\s+)?(?:concluded|formed)",
|
||||
r"offer\s+and\s+acceptance",
|
||||
r"how\s+the\s+contract\s+is\s+formed",
|
||||
r"contracts?\s+(?:apply|between\s+the\s+provider)",
|
||||
],
|
||||
"severity": "HIGH",
|
||||
"hint": "Haeufiger Fehler: Die Bestellung wird als Angebot des Kunden dargestellt, aber die Auftragsbestaetigung als Annahme — das ist nur wirksam, wenn klar zwischen Eingangsbestaetigung (§312i BGB) und Auftragsbestaetigung/Annahme unterschieden wird.",
|
||||
@@ -140,6 +146,15 @@ AGB_CHECKLIST = [
|
||||
r"lieferung", r"leistungserbringung", r"delivery",
|
||||
r"lieferfrist", r"bereitstellung",
|
||||
r"(?:zugang|zugriff).*(?:dienst|leistung)",
|
||||
# P41: English synonyms (SaaS-style)
|
||||
r"provision\s+of\s+(?:the\s+)?(?:service|services)",
|
||||
r"(?:performance|rendering)\s+of\s+(?:the\s+)?(?:service|services)",
|
||||
r"availability\s+of\s+(?:the\s+)?service",
|
||||
r"service\s+level\s+(?:agreement|description)",
|
||||
r"access\s+to\s+(?:the\s+)?(?:service|platform)",
|
||||
r"description\s+of\s+(?:the\s+)?services?",
|
||||
r"(?:^|\n)\s*#+\s*[§\d\.\s]*availability\b",
|
||||
r"(?:^|\n)\s*#+\s*[§\d\.\s]*description\s+of\s+services?",
|
||||
],
|
||||
"severity": "MEDIUM",
|
||||
"hint": "Bei Fernabsatzvertraegen muss der Unternehmer spaetestens 30 Tage nach Vertragsschluss liefern (§475 Abs. 1 BGB). Formulierungen wie 'Lieferung in der Regel in...' oder 'voraussichtlich' sind nur als Richtwert zulaessig, nicht als verbindliche Frist.",
|
||||
@@ -230,6 +245,12 @@ AGB_CHECKLIST = [
|
||||
r"(?:agb|bedingung).*datenschutz",
|
||||
r"personenbezogen.*daten.*(?:agb|vertrag)",
|
||||
r"dsgvo.*(?:agb|vertrag)",
|
||||
# P41: English synonyms
|
||||
r"data\s+protection.*(?:terms|contract)",
|
||||
r"(?:terms|contract).*data\s+protection",
|
||||
r"personal\s+data.*(?:terms|contract|agreement)",
|
||||
r"gdpr.*(?:terms|contract|agreement)",
|
||||
r"privacy\s+(?:policy|notice).*(?:see|refer)",
|
||||
],
|
||||
"severity": "LOW",
|
||||
"hint": "AGB und Datenschutzerklaerung sind rechtlich getrennte Dokumente. Mischen Sie KEINE Datenschutzhinweise in die AGB ein — stattdessen genuegt ein Verweis: 'Details zur Datenverarbeitung finden Sie in unserer Datenschutzerklaerung [Link].'",
|
||||
@@ -245,6 +266,11 @@ AGB_CHECKLIST = [
|
||||
r"(?:unwirksamkeit|nichtigkeit)\s+(?:einer|einzelner)\s+(?:bestimmung|klausel|regelung)",
|
||||
r"(?:sollte|sofern).*(?:bestimmung|klausel).*(?:unwirksam|nichtig)",
|
||||
r"(?:uebrigen|übrigen)\s+bestimmungen.*(?:unberuehrt|unberührt|wirksam|bestehen)",
|
||||
# P41: English equivalents
|
||||
r"severability",
|
||||
r"(?:invalid|unenforceable).*(?:provision|clause)",
|
||||
r"remaining\s+provisions\s+(?:shall\s+)?(?:remain|continue)",
|
||||
r"(?:provision|clause)\s+(?:is\s+)?(?:invalid|unenforceable|void)",
|
||||
],
|
||||
"severity": "LOW",
|
||||
"hint": "Die klassische salvatorische Klausel ('unwirksame Bestimmungen werden durch wirksame ersetzt') ist nach BGH-Rechtsprechung in AGB selbst unwirksam. Besser: Nur die Erhaltungsklausel verwenden ('Die uebrigen Bestimmungen bleiben wirksam').",
|
||||
@@ -260,6 +286,12 @@ AGB_CHECKLIST = [
|
||||
r"(?:agb|bedingung).*(?:ae|ä)nder",
|
||||
r"(?:anpassung|aktualisierung).*(?:agb|bedingung|geschaeftsbedingung|geschäftsbedingung)",
|
||||
r"(?:neue\s+fassung|neufassung).*(?:agb|bedingung)",
|
||||
# P41: English
|
||||
r"amendments?.*(?:terms|conditions|agreement)",
|
||||
r"(?:terms|conditions|agreement).*(?:may\s+be\s+)?amend",
|
||||
r"changes?\s+to\s+(?:these\s+)?(?:terms|conditions)",
|
||||
r"modification\s+of\s+(?:the\s+)?(?:terms|agreement)",
|
||||
r"(?:revised|updated)\s+(?:terms|conditions|version)",
|
||||
],
|
||||
"severity": "LOW",
|
||||
"hint": "AGB-Aenderungsklauseln bei B2C sind nur unter engen Voraussetzungen wirksam (BGH Az. XI ZR 388/10): Aenderungsgrund muss konkret benannt sein, Kunde muss angemessene Frist zur Kuendigung erhalten. Pauschale 'Wir koennen jederzeit aendern'-Klauseln sind unwirksam.",
|
||||
@@ -275,6 +307,12 @@ AGB_CHECKLIST = [
|
||||
r"verbraucherrecht",
|
||||
r"(?:gesetzlich|zwingende)\w*\s+recht\w*.*(?:unberuehrt|unberührt|bestehen\s+bleiben)",
|
||||
r"(?:verbrauch|konsument).*(?:recht|anspruch|schutz)",
|
||||
# P41: English equivalents — UCTA / Consumer Rights Act
|
||||
r"consumer\s+(?:rights?|protection|laws?)",
|
||||
r"statutory\s+rights?\s+(?:are|shall\s+be|remain)\s+unaffected",
|
||||
r"mandatory\s+(?:law|rights?)\s+(?:remain|shall\s+remain)",
|
||||
r"(?:nothing|no\s+provision)\s+(?:in\s+these\s+)?(?:terms|conditions)\s+(?:shall|limits?|excludes?)",
|
||||
r"contracts?\s+with\s+consumers?\s+(?:are\s+not\s+concluded|excluded)",
|
||||
],
|
||||
"severity": "LOW",
|
||||
"hint": "Haeufigste §309 BGB-Verstoesse: Pauschalierter Schadensersatz ohne Gegenbeweismoeglichkeit (Nr. 5), Haftungsausschluss bei Koerperschaeden (Nr. 7a), Schriftformerfordernis fuer Kuendigung (Nr. 13). Jede dieser Klauseln ist einzeln abmahnfaehig.",
|
||||
|
||||
@@ -259,6 +259,8 @@ AVV_CHECKLIST = [
|
||||
r"(?:l(?:oe|ö)schung|rueckgabe|r(?:ue|ü)ckgabe)\s+(?:nach|bei|zum)\s+(?:vertragsende|beendigung|ablauf)",
|
||||
r"(?:nach|bei)\s+(?:beendigung|ablauf|ende)\s+(?:des\s+)?(?:vertrag|auftrag)[\s\S]{0,100}(?:l(?:oe|ö)sch|rueckgabe|r(?:ue|ü)ckgabe|vernicht)",
|
||||
r"(?:alle|saemtliche)\s+(?:personenbezogenen?\s+)?daten\s+(?:l(?:oe|ö)sch|vernicht|zurueckgeb|zur(?:ue|ü)ckgeb)",
|
||||
# P39: reverse order — "loescht/gibt ... nach Beendigung/Ablauf"
|
||||
r"(?:l(?:oe|ö)sch|gibt|gibt\s+zur(?:ue|ü)ck|vernicht)\w*[\s\S]{0,150}(?:nach|bei|zum)\s+(?:beendigung|ablauf|ende|vertragsende)",
|
||||
],
|
||||
"severity": "CRITICAL",
|
||||
"hint": "Art. 28(3)(g) DSGVO: Nach Ende der Verarbeitung muessen alle personenbezogenen Daten geloescht oder zurueckgegeben werden — nach Wahl des Verantwortlichen. Ausnahme nur bei gesetzlicher Aufbewahrungspflicht.",
|
||||
@@ -336,6 +338,10 @@ AVV_CHECKLIST = [
|
||||
r"data\s+breach",
|
||||
r"(?:meld|benachrichtig|informier|unterricht)\w*[\s\S]{0,50}(?:verletzung|vorfall|sicherheit)",
|
||||
r"art(?:ikel)?\s*\.?\s*33\s+(?:dsgvo|ds-?gvo)",
|
||||
# P39: "Datenpanne" als gleichwertiges Synonym (sehr verbreitet)
|
||||
r"datenpanne",
|
||||
r"meldung\s+von\s+datenpannen",
|
||||
r"art\.?\s*33\s+abs\.?\s*\d",
|
||||
],
|
||||
"severity": "CRITICAL",
|
||||
"hint": "Art. 33(2) DSGVO: Der Auftragsverarbeiter muss den Verantwortlichen UNVERZUEGLICH ueber jede Datenschutzverletzung informieren. Die 72-Stunden-Frist des Verantwortlichen gegenueber der Aufsichtsbehoerde laeuft ab Kenntnis — daher sollte die Meldefrist im AVV enger sein (z.B. 24h).",
|
||||
|
||||
@@ -66,6 +66,10 @@ COOKIE_CHECKLIST = [
|
||||
r"(?:setzen|verwenden|nutzen)\s+.*cookies?\s+.*(?:um|fuer|für)",
|
||||
r"(?:analyse|marketing|tracking|funktional)\w*\s*cookies?\s*\.?\s*(?:um|damit|diese|sie)",
|
||||
r"cookies?\s+(?:dienen|helfen|erm(?:oe|ö)glichen)",
|
||||
# P39: cookie purpose table column "| Zweck |" + "Kategorie"
|
||||
r"kategorie\s*\|\s*zweck",
|
||||
r"\|\s*zweck\s*\|",
|
||||
r"welche\s+technologie\s+welchen\s+zweck",
|
||||
],
|
||||
"severity": "HIGH",
|
||||
"hint": "Art. 13 Abs. 1 lit. c DSGVO verlangt die Zweckangabe je Verarbeitung. Jede Cookie-Kategorie braucht einen konkreten Zweck (z.B. 'Reichweitenmessung', 'Conversion-Tracking'), nicht nur 'zur Verbesserung unserer Website'.",
|
||||
@@ -207,6 +211,10 @@ COOKIE_CHECKLIST = [
|
||||
r"(?:datenschutz[\-]?rechtlich(?:er)?\s+)?verantwortlich\w*\s*[:\|]",
|
||||
r"daten(?:schutz)?[\-]?(?:rechtlich(?:er)?\s+)?(?:verantwortl|controller)",
|
||||
r"\bcontroller\b.*\b(?:art\.?\s*13|art\.?\s*14|gdpr|dsgvo)",
|
||||
# P39: heading variant — common in cookie policies
|
||||
r"(?:^|\n)\s*#+\s*\d*\.?\s*verantwortlich\w*",
|
||||
r"(?:^|\n)\s*\d+\.\s+verantwortlich\w*",
|
||||
r"verantwortlich\w*\s+(?:fuer|für|ist|im\s+sinne)",
|
||||
],
|
||||
"severity": "MEDIUM",
|
||||
"hint": "Art. 13(1)(a) DSGVO verlangt die Nennung des Verantwortlichen in der Cookie-Richtlinie. Pflicht: Firmenname + Anschrift + Kontaktdaten (E-Mail/Telefon). Akzeptabel: knapper Verweis 'Details zum Verantwortlichen siehe Datenschutzerklaerung [Link]' wenn die DSI verlinkt ist.",
|
||||
@@ -268,19 +276,40 @@ COOKIE_CHECKLIST = [
|
||||
},
|
||||
|
||||
# ── Neue L1: Cookie-Tabelle ───────────────────────────────────────
|
||||
# P95: Lockerer Match — Vendor-zentrische Detailseiten (BMW-Stil mit
|
||||
# Adform-Block etc.) werden als gleichwertig akzeptiert. DSK-OH 2024
|
||||
# §3.2 verlangt die Informationen pro Cookie, schreibt aber keine
|
||||
# Tabellenform vor. Ein Vendor-Block der Name+Anbieter+Zweck+Dauer+
|
||||
# Cookie-Namen aggregiert nennt erfuellt das.
|
||||
{
|
||||
"id": "cookie_table",
|
||||
"label": "Strukturierte Cookie-Tabelle/Liste",
|
||||
"label": "Strukturierte Cookie-Informationen (Tabelle oder Vendor-Blöcke)",
|
||||
"level": 1, "parent": None,
|
||||
"patterns": [
|
||||
# Klassische Tabelle
|
||||
r"(?:cookie[\-\s])?(?:tabelle|uebersicht|übersicht|liste|aufstellung)",
|
||||
r"(?:name|bezeichnung)\s*[\|\t]\s*(?:anbieter|zweck|dauer|laufzeit|funktion)",
|
||||
r"(?:first[\-\s]?party|third[\-\s]?party)\s*[\|\t]",
|
||||
r"(?:typ(?:en)?|name|funktion|speicherdauer)\s+(?:typ(?:en)?|name|funktion|speicherdauer)",
|
||||
r"folgende\s+cookies",
|
||||
r"(?:funktionale|session|analyse|tracking)\s+cookies?\s+\w+",
|
||||
# P95: Vendor-zentrische Detail-Bloecke (BMW-Stil) — wenn
|
||||
# mehrere typische Vendor-Block-Marker vorhanden, gilt als
|
||||
# strukturiert. "Gesetzt von:" + "Opt-Out Link:" + "Privacy"
|
||||
# ist ein klares Indiz fuer Vendor-Detailseite.
|
||||
r"gesetzt\s+von\s*[:\|]",
|
||||
r"opt[\-\s]?out[\s\-]?link\s*[:\|]",
|
||||
r"speicherdauer\s*[:\|]\s*\d+\s+(?:tag|monat|jahr|day|month|year)",
|
||||
r"(?:rechtsgrundlage|legal\s+basis)\s*[:\|]",
|
||||
r"(?:diese\s+datenverarbeitung\s+verwendet\s+die\s+folgenden\s+cookies)",
|
||||
],
|
||||
"severity": "LOW",
|
||||
"hint": "Die DSK-Orientierungshilfe empfiehlt eine Tabelle mit 5 Spalten: Name, Anbieter, Zweck, Speicherdauer, Typ (First-/Third-Party). Viele Consent-Tools (Cookiebot, Usercentrics) generieren diese Tabelle automatisch — binden Sie sie ein.",
|
||||
"hint": "DSK-OH Telemedien 2024 §3.2 verlangt Cookie-Informationen pro "
|
||||
"Vendor/Cookie (Name, Anbieter, Zweck, Speicherdauer, Drittlandtransfer). "
|
||||
"Akzeptable Formate: (a) Tabelle mit 5 Spalten oder (b) Vendor-Detailseite "
|
||||
"mit Block pro Anbieter (Anbieter+Anschrift, Zweck, Speicherdauer aggregiert, "
|
||||
"Cookie-Namen-Liste, Opt-Out-Link, Drittlandstatus). BMW-Stil mit Adform-"
|
||||
"Block ist konform. Auch automatisierte CMP-Generierung (Cookiebot, Usercentrics) "
|
||||
"ist OK.",
|
||||
},
|
||||
]
|
||||
|
||||
@@ -17,6 +17,11 @@ ART13_CHECKLIST = [
|
||||
r"name\s+(?:und|&)\s+kontaktdaten\s+des",
|
||||
r"controller", r"verantwortliche\s+stelle",
|
||||
r"responsible\s+(?:party|for)",
|
||||
# P39: Heading-style "## 1. Verantwortlicher", "## Verantwortlicher",
|
||||
# "1. Verantwortlicher" — common template structure that wasn't matched.
|
||||
r"(?:^|\n)\s*#+\s*\d*\.?\s*verantwortlich\w*",
|
||||
r"(?:^|\n)\s*\d+\.\s+verantwortlich\w*",
|
||||
r"\bverantwortlich\w*\s*[:\n]",
|
||||
],
|
||||
"severity": "HIGH",
|
||||
"hint": "Art. 13(1)(a) DSGVO verlangt vollstaendige Identifizierung: Firmenname mit Rechtsform (z.B. 'Muster GmbH'), ladungsfaehige Anschrift, E-Mail und Telefon. Haeufiger Fehler: Nur Markenname ohne Rechtsform — das genuegt nicht zur Zustellung.",
|
||||
@@ -93,6 +98,11 @@ ART13_CHECKLIST = [
|
||||
r"zu\s+welch\w+\s+zweck",
|
||||
r"welche\s+daten\s+werden.*verarbeitet",
|
||||
r"daten\s+werden\s+(?:zu|fuer|für)\s+(?:folgende|diese)",
|
||||
# P39: heading variants
|
||||
r"(?:^|\n)\s*#+\s*\d*\.?\s*zwecke?\b",
|
||||
r"\*\*zwecke?:?\*\*",
|
||||
r"purposes?\s+and\s+(?:legal|legal\s+bases?)",
|
||||
r"purposes?\s*[:\n]",
|
||||
],
|
||||
"severity": "HIGH",
|
||||
"hint": "Art. 13(1)(c) verlangt konkrete Zweckangaben — nicht nur 'Wir verarbeiten Ihre Daten'. Jeder Dienst braucht einen eigenen Zweck: z.B. 'Webanalyse via Matomo', 'Newsletter-Versand', 'Kontaktanfragen'. Pauschalformulierungen verstiessen laut DSK gegen den Transparenzgrundsatz (Art. 5(1)(a)).",
|
||||
@@ -223,6 +233,13 @@ ART13_CHECKLIST = [
|
||||
r"(?:ueber|über)mittlung.*(?:ausserhalb|außerhalb)",
|
||||
r"(?:europ(?:ae|ä)ischen\s+wirtschaftsraum|ewr|eea)",
|
||||
r"privacy\s+shield", r"data\s+privacy\s+framework",
|
||||
# P39: Art. 13(1)(f) verlangt nur Erwaehnung — "keine
|
||||
# Uebermittlung in Drittlaender" / "kein Drittlandtransfer"
|
||||
# / "alle Verarbeitung innerhalb der EU" sind explizite,
|
||||
# konforme Negations-Aussagen.
|
||||
r"(?:kein|keine)\s+(?:uebermittlung|übermittlung|transfer|drittland)",
|
||||
r"verarbeitung\s+(?:erfolgt\s+)?(?:ausschliesslich|ausschließlich|nur)\s+(?:in|innerhalb)\s+(?:der\s+)?(?:eu|europ(?:ae|ä)ischen\s+union|ewr)",
|
||||
r"alle\s+daten\s+(?:bleiben|verbleiben)\s+(?:in|innerhalb)\s+(?:der\s+)?(?:eu|deutschland)",
|
||||
],
|
||||
"severity": "MEDIUM",
|
||||
"hint": "Art. 13(1)(f) DSGVO: Bei jedem Drittlandtransfer muessen Empfaengerland und Schutzgarantien genannt werden. Pruefen Sie: Google Fonts, reCAPTCHA, YouTube-Embeds, CDNs — all das sind USA-Transfers. Fehlende Angabe war Grundlage zahlreicher DSGVO-Bussgelder.",
|
||||
|
||||
@@ -192,6 +192,11 @@ DSFA_CHECKLIST = [
|
||||
r"landes.?datenschutz",
|
||||
r"richtlinie.*(?:land|lfdi|landes)",
|
||||
r"(?:aufsichtsbeh(?:oe|ö)rde|beh(?:oe|ö)rde).*(?:richtlinie|empfehlung|vorgabe)",
|
||||
# P39: DSK Liste/Blacklist + spezifische Landesbehoerden
|
||||
r"(?:dsk|datenschutzkonferenz)\s+(?:positiv|black)?liste",
|
||||
r"art\.?\s*35\s*\(?\s*4\s*\)?\s*dsgvo",
|
||||
r"(?:berliner|hamburgische|saechsisch|bayerisch|nordrhein|baden)\w*\s+beauftragt",
|
||||
r"(?:bfdi|bvfd|ldsbw|ldsh)",
|
||||
],
|
||||
"severity": "MEDIUM",
|
||||
"hint": "Die DSK hat eine Positivliste (Blacklist) nach Art. 35(4) DSGVO veroeffentlicht, die DSFA-pflichtige Verarbeitungen auflistet. Zusaetzlich hat jedes Bundesland eigene LfDI-Empfehlungen — z.B. der LfDI BaWue zu Social-Media-Fanpages. Pruefen und zitieren Sie die fuer Sie zustaendige Behoerde.",
|
||||
|
||||
@@ -16,6 +16,11 @@ LOESCHKONZEPT_CHECKLIST = [
|
||||
r"(?:geltungsbereich|anwendungsbereich)",
|
||||
r"verantwortlich\w*\s+(?:fuer|für)\s+(?:das\s+)?l(?:oe|ö)schkonzept",
|
||||
r"(?:datenschutzbeauftragt\w*|dpo|dsb)\s+(?:verantwort|zustaendig|zuständig)",
|
||||
# P39: heading variants + Verantwortlichkeiten table
|
||||
r"(?:^|\n)\s*#+\s*\d*\.?\s*verantwortlichkeit",
|
||||
r"(?:^|\n)\s*#+\s*\d*\.?\s*geltungsbereich",
|
||||
r"verantwortlichkeiten\s*\|",
|
||||
r"\|\s*verantwortlich\s*\|",
|
||||
],
|
||||
"severity": "HIGH",
|
||||
"hint": "DIN 66398 verlangt einen klaren Geltungsbereich (welche Systeme, Datenarten, Standorte) und die Benennung des Verantwortlichen fuer Erstellung + Wartung des Loeschkonzepts.",
|
||||
@@ -98,6 +103,10 @@ LOESCHKONZEPT_CHECKLIST = [
|
||||
r"l(?:oe|ö)sch(?:prozess|vorgang|verfahren|workflow|routine)",
|
||||
r"(?:wie|wann)\s+(?:wird|werden)\s+(?:die\s+daten\s+)?gel(?:oe|ö)scht",
|
||||
r"automatisierte?\s+l(?:oe|ö)schung",
|
||||
# P39: more generic — "Verfahren fuer die Loeschung", "Loeschmethode"
|
||||
r"verfahren\s+(?:fuer|für|zur?)\s+(?:die\s+)?l(?:oe|ö)sch",
|
||||
r"l(?:oe|ö)sch(?:methode|frist|regel)",
|
||||
r"systematische?\s+(?:regeln?|verfahren)[\s\S]{0,80}l(?:oe|ö)sch",
|
||||
],
|
||||
"severity": "HIGH",
|
||||
"hint": "Beschreiben wie Loeschung erfolgt: automatisch per Cron-Job, manuell durch Admin, Loeschungs-Workflow im CRM, Backup-Loeschung etc.",
|
||||
@@ -154,6 +163,10 @@ LOESCHKONZEPT_CHECKLIST = [
|
||||
r"sperr\w+\s+(?:statt|anstelle)\s+l(?:oe|ö)sch",
|
||||
r"l(?:oe|ö)sch(?:beschr|sperr|ausnahme|hindernis)",
|
||||
r"(?:rechtsstreit|gerichtsverfahren|prozessrelevant)",
|
||||
# P39: gesetzliche Aufbewahrungspflichten als legitime Loeschausnahme
|
||||
r"(?:gesetzliche|handelsrechtlich|steuerrechtlich)\w*\s+aufbewahrungs?(?:pflicht|frist)",
|
||||
r"aufbewahrungspflicht[\s\S]{0,80}(?:setzt|bleib|gilt)",
|
||||
r"(?:hgb|ao|abgabenordnung)\s*§?\s*\d",
|
||||
],
|
||||
"severity": "MEDIUM",
|
||||
"hint": "Wenn Loeschung nicht moeglich ist (laufender Prozess, gesetzliche Aufbewahrung, Streitfall) muss stattdessen Sperrung/Einschraenkung (Art. 18 DSGVO) erfolgen. Sperrkonzept dokumentieren.",
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
"""
|
||||
Rendert die Doc-Type-Mismatch-Hinweise als Mail-Block.
|
||||
|
||||
Wenn der User Text in das falsche Feld kopiert (z.B. Impressum-Text
|
||||
ins DSE-Feld), zeigt der Block:
|
||||
- was er deklariert hat
|
||||
- was der Classifier erkannt hat
|
||||
- Empfehlung (re-paste oder als unbekannt einreichen)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Iterable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DOC_LABELS = {
|
||||
"dse": "Datenschutzerklaerung",
|
||||
"cookie": "Cookie-Richtlinie",
|
||||
"impressum": "Impressum",
|
||||
"agb": "AGB",
|
||||
"widerruf": "Widerrufsbelehrung",
|
||||
"nutzungsbedingungen": "Nutzungsbedingungen",
|
||||
"social_media": "Social Media DSE",
|
||||
"dsfa": "DSFA",
|
||||
"dsa": "DSA-Pflichtangaben",
|
||||
"legal_notice": "Rechtliche Hinweise",
|
||||
"lizenzhinweise": "Lizenzhinweise",
|
||||
}
|
||||
|
||||
|
||||
def _label(dt: str) -> str:
|
||||
return _DOC_LABELS.get(dt, dt)
|
||||
|
||||
|
||||
def collect_warnings(doc_entries: Iterable[dict]) -> list[dict]:
|
||||
"""Returns list of {declared, detected, action, scores} fuer alle
|
||||
doc_entries mit einem reclassify_hint."""
|
||||
out: list[dict] = []
|
||||
for e in (doc_entries or []):
|
||||
hint = e.get("reclassify_hint")
|
||||
if not hint:
|
||||
continue
|
||||
out.append({
|
||||
"input_source": e.get("input_source"),
|
||||
"declared": hint.get("declared"),
|
||||
"detected": hint.get("detected"),
|
||||
"action": hint.get("action"),
|
||||
"declared_score": hint.get("declared_score", 0),
|
||||
"detected_score": hint.get("detected_score", 0),
|
||||
"all_scores": hint.get("all_scores") or {},
|
||||
"word_count": e.get("word_count", 0),
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def build_warnings_block_html(warnings: list[dict]) -> str:
|
||||
if not warnings:
|
||||
return ""
|
||||
items: list[str] = []
|
||||
for w in warnings:
|
||||
action = w.get("action")
|
||||
if action == "reclassify":
|
||||
color = "#0e7490"
|
||||
badge = "AUTO-RECLASSIFIZIERT"
|
||||
body = (
|
||||
f'Sie haben den Text als <strong>{_label(w["declared"])}</strong> '
|
||||
f'eingereicht, das System hat ihn aber automatisch als '
|
||||
f'<strong>{_label(w["detected"])}</strong> erkannt und entsprechend '
|
||||
f'gepruft (Konfidenz-Score: {w["detected_score"]} vs '
|
||||
f'{w["declared_score"]} für die deklarierte Kategorie).'
|
||||
)
|
||||
else:
|
||||
color = "#d97706"
|
||||
badge = "MOEGLICHER MISMATCH"
|
||||
body = (
|
||||
f'Sie haben den Text als <strong>{_label(w["declared"])}</strong> '
|
||||
f'eingereicht. Der Inhalt enthaelt aber Patterns die eher zu '
|
||||
f'<strong>{_label(w["detected"])}</strong> passen '
|
||||
f'({w["detected_score"]} vs {w["declared_score"]}). '
|
||||
'Bitte pruefen Sie ob Sie den richtigen Doc-Typ ausgewaehlt haben.'
|
||||
)
|
||||
items.append(
|
||||
f'<li style="margin-bottom:8px;font-size:11px;line-height:1.5">'
|
||||
f'<strong style="color:{color}">[{badge}]</strong> {body}'
|
||||
f'</li>'
|
||||
)
|
||||
return (
|
||||
'<div style="font-family:-apple-system,BlinkMacSystemFont,sans-serif;'
|
||||
'max-width:760px;margin:0 auto 12px;padding:10px 14px;'
|
||||
'background:#ecfeff;border:1px solid #67e8f9;border-radius:6px">'
|
||||
'<div style="font-size:11px;color:#0e7490;text-transform:uppercase;'
|
||||
'letter-spacing:1.2px;margin-bottom:4px;font-weight:600">'
|
||||
'Hinweise zum eingefügten Text</div>'
|
||||
'<ul style="margin:4px 0 0 18px;padding:0">'
|
||||
+ "".join(items) +
|
||||
'</ul></div>'
|
||||
)
|
||||
@@ -0,0 +1,297 @@
|
||||
"""
|
||||
P35 + P77 + P78 — Post-hoc Textsignal-Checks auf den geladenen
|
||||
Dokumenten-Texten (DSE / Cookie-Richtlinie / Banner-Text).
|
||||
|
||||
P35 — "Speichern" als mehrdeutiges Reject-Label im Banner. Wenn das
|
||||
einzige Schliess-Element nur "Speichern" heisst (statt
|
||||
"Alle ablehnen" / "Nur notwendige"), ist das ein MEDIUM-Finding,
|
||||
weil der Nutzer nicht versteht ob er gerade akzeptiert oder
|
||||
abgelehnt hat.
|
||||
|
||||
P77 — Cookie-Doc-Architecture: wenn keine eigene Cookie-Richtlinie
|
||||
ausgeliefert wurde, aber die DSE einen prominent benannten
|
||||
Cookie-Abschnitt enthaelt (mit Vendor-Liste + Speicherdauer),
|
||||
ist das ein gleichwertiger OEM-Pattern. Liefert positives Signal
|
||||
statt MEDIUM-Finding "Cookie-Richtlinie fehlt".
|
||||
|
||||
P78 — JC-Detection in DSE-Text: erkennt 'gemeinsam Verantwortliche'-
|
||||
Klauseln (Art. 26 DSGVO) im DSE-Text. Liefert positives Signal
|
||||
"JC-Konstrukt dokumentiert" — verhindert False-Positive
|
||||
"JC nicht erwaehnt obwohl Kooperation mit Konzern-Schwester".
|
||||
|
||||
Alle drei liefern dict shape {"severity": ...} oder positive-signal-dict.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_REJECT_LABEL_KEYS = (
|
||||
"alle ablehnen", "ablehnen", "reject all", "deny all",
|
||||
"nur notwendige", "nur essenzielle", "nur erforderliche",
|
||||
"essentials only", "verweigern", "block all",
|
||||
)
|
||||
|
||||
_SAVE_ONLY_KEYS = (
|
||||
"speichern", "auswahl speichern", "save selection",
|
||||
"auswahl bestaetigen",
|
||||
)
|
||||
|
||||
_COOKIE_SECTION_HEADINGS = (
|
||||
"cookies und tracking", "cookies und vergleichbare technologien",
|
||||
"cookies und aehnliche technologien", "verwendung von cookies",
|
||||
"informationen zu cookies", "uebersicht der cookies",
|
||||
"eingesetzte cookies", "cookies im einsatz",
|
||||
)
|
||||
|
||||
_VENDOR_HINTS = (
|
||||
"speicherdauer", "lebensdauer", "anbieter", "drittanbieter",
|
||||
"datenempfaenger", "datenkategorie", "rechtsgrundlage",
|
||||
)
|
||||
|
||||
_JC_PATTERNS = (
|
||||
"gemeinsam verantwortlich", "joint controller",
|
||||
"gemeinsame verantwortung", "art. 26 dsgvo", "art 26 dsgvo",
|
||||
"vereinbarung gemaess art. 26", "joint-controller-vereinbarung",
|
||||
"gemeinsame verarbeitung",
|
||||
)
|
||||
|
||||
# P36 — Social-Media-Einbindung:
|
||||
# "direct" = direkte FB/Insta/Twitter-Embeds laden bei Page-Load
|
||||
# (HIGH-Risiko, Cookies vor Consent).
|
||||
# "shariff" = Heise-Shariff-Buttons (clientseitig, kein 3rd-party-Call).
|
||||
# "two_click" = zweistufige Loesung (Klick auf Platzhalter laed Tracker).
|
||||
_SOCIAL_DIRECT_PATTERNS = (
|
||||
"connect.facebook.net", "platform.twitter.com",
|
||||
"platform.instagram.com", "platform.linkedin.com",
|
||||
"youtube.com/embed", "syndication.twitter.com",
|
||||
"//www.facebook.com/", "fb-pixel", "facebook-pixel",
|
||||
)
|
||||
_SOCIAL_SHARIFF_PATTERNS = (
|
||||
"shariff", "ct_shariff", "data-shariff",
|
||||
)
|
||||
_SOCIAL_TWOCLICK_PATTERNS = (
|
||||
"2-klick", "2klick", "zwei klick", "two-click",
|
||||
"klick-zu-laden", "klick um zu laden", "platzhalter laed",
|
||||
"embetty",
|
||||
)
|
||||
|
||||
|
||||
def check_save_only_reject(banner_result: dict) -> dict | None:
|
||||
"""P35 — Banner hat keinen klaren Reject, nur "Speichern"."""
|
||||
initial = ((banner_result or {}).get("phases") or {}).get("initial") or {}
|
||||
if not isinstance(initial, dict):
|
||||
return None
|
||||
btext = (initial.get("banner_text") or "").lower()
|
||||
if not btext or len(btext) < 30:
|
||||
return None
|
||||
has_clear_reject = any(k in btext for k in _REJECT_LABEL_KEYS)
|
||||
has_save_only = any(k in btext for k in _SAVE_ONLY_KEYS)
|
||||
if has_clear_reject or not has_save_only:
|
||||
return None
|
||||
return {
|
||||
"severity": "MEDIUM",
|
||||
"code": "save_label_ambiguous",
|
||||
"label": (
|
||||
'Banner verwendet "Speichern" ohne erkennbares "Ablehnen" '
|
||||
'— mehrdeutig fuer den Nutzer'
|
||||
),
|
||||
"detail": (
|
||||
'Der Button "Speichern" laesst offen, ob die aktuelle '
|
||||
'Vorauswahl (oft alles aktiv) bestaetigt oder nur die '
|
||||
'getroffene Auswahl uebernommen wird. EDPB 03/2022 empfiehlt '
|
||||
'eindeutige Labels: "Alle akzeptieren" + "Alle ablehnen".'
|
||||
),
|
||||
"legal_basis": "Art. 7 (1) DSGVO + EDPB 03/2022 Guidelines on "
|
||||
"deceptive design patterns.",
|
||||
}
|
||||
|
||||
|
||||
def check_cookies_in_dse(
|
||||
doc_texts: dict[str, str],
|
||||
cookie_doc_missing: bool,
|
||||
) -> dict | None:
|
||||
"""P77 — DSE hat eigenen Cookie-Abschnitt mit Vendor-Hints."""
|
||||
if not cookie_doc_missing:
|
||||
return None
|
||||
dse = (doc_texts or {}).get("dse") or ""
|
||||
if len(dse) < 1000:
|
||||
return None
|
||||
dse_lower = dse.lower()
|
||||
has_heading = any(h in dse_lower for h in _COOKIE_SECTION_HEADINGS)
|
||||
if not has_heading:
|
||||
return None
|
||||
vendor_hint_count = sum(1 for h in _VENDOR_HINTS if h in dse_lower)
|
||||
if vendor_hint_count < 3:
|
||||
return None # zu wenig substanziell
|
||||
return {
|
||||
"severity": "INFO", # Positives Signal, kein Finding
|
||||
"code": "cookies_in_dse_accepted",
|
||||
"label": (
|
||||
"Cookie-Informationen sind im Datenschutz-Dokument enthalten "
|
||||
"(eigener Abschnitt mit Vendor-Hinweisen)"
|
||||
),
|
||||
"detail": (
|
||||
"Die Praxis vieler OEM-Sites, Cookies als eigenen Abschnitt "
|
||||
'in der DSE zu fuehren (statt als separate Datei), wird als '
|
||||
"gleichwertig akzeptiert. Empfehlung trotzdem: separate "
|
||||
"Cookie-Richtlinie erleichtert kuenftige Aenderungen und "
|
||||
"Versionierung."
|
||||
),
|
||||
"legal_basis": "Art. 13(1)(c) DSGVO — Form ist nicht vorgegeben, "
|
||||
"Inhalt muss vollstaendig sein.",
|
||||
}
|
||||
|
||||
|
||||
def check_jc_clause_in_dse(doc_texts: dict[str, str]) -> dict | None:
|
||||
"""P78 — DSE enthaelt Art. 26 JC-Klausel."""
|
||||
dse = (doc_texts or {}).get("dse") or ""
|
||||
if not dse:
|
||||
return None
|
||||
dse_lower = dse.lower()
|
||||
matches = [p for p in _JC_PATTERNS if p in dse_lower]
|
||||
if not matches:
|
||||
return None
|
||||
return {
|
||||
"severity": "INFO",
|
||||
"code": "jc_clause_documented",
|
||||
"label": "Gemeinsame Verantwortlichkeit (Art. 26 DSGVO) im "
|
||||
"DSE-Text dokumentiert",
|
||||
"detail": (
|
||||
f'Erkannte Signale: {", ".join(sorted(set(matches))[:3])}. '
|
||||
'Das verhindert das False-Positive "JC-Konstrukt nicht '
|
||||
'erwaehnt" bei Sites mit Konzern-Schwesterunternehmen.'
|
||||
),
|
||||
"legal_basis": "Art. 26 DSGVO + EDPB 7/2020 Guidelines on the "
|
||||
"concepts of controller and processor.",
|
||||
}
|
||||
|
||||
|
||||
def check_social_embedding(
|
||||
doc_texts: dict[str, str],
|
||||
homepage_html: str | None = None,
|
||||
) -> dict | None:
|
||||
"""P36 — direkte Social-Embeds vs Shariff vs 2-Klick."""
|
||||
sources: list[str] = []
|
||||
for key in ("dse", "cookie", "impressum"):
|
||||
v = (doc_texts or {}).get(key) or ""
|
||||
if v:
|
||||
sources.append(v[:50000])
|
||||
if homepage_html:
|
||||
sources.append(homepage_html[:50000])
|
||||
if not sources:
|
||||
return None
|
||||
blob = " ".join(sources).lower()
|
||||
direct_hits = [p for p in _SOCIAL_DIRECT_PATTERNS if p in blob]
|
||||
has_shariff = any(p in blob for p in _SOCIAL_SHARIFF_PATTERNS)
|
||||
has_twoclick = any(p in blob for p in _SOCIAL_TWOCLICK_PATTERNS)
|
||||
|
||||
if not direct_hits and not has_shariff and not has_twoclick:
|
||||
return None
|
||||
if direct_hits and not (has_shariff or has_twoclick):
|
||||
return {
|
||||
"severity": "HIGH",
|
||||
"code": "social_direct_embed",
|
||||
"label": "Direkte Social-Media-Embeds ohne 2-Klick-Schutz "
|
||||
"oder Shariff erkannt",
|
||||
"detail": (
|
||||
f'Gefundene Drittanbieter-Skripte: '
|
||||
f'{", ".join(sorted(set(direct_hits))[:4])}. '
|
||||
"Diese laden i.d.R. Cookies/Pixel ohne Einwilligung. "
|
||||
"Empfehlung: Heise-Shariff (clientseitig) oder "
|
||||
"2-Klick-Loesung (Embetty, eigener Platzhalter)."
|
||||
),
|
||||
"legal_basis": "EuGH C-40/17 (Fashion-ID) — Einbinden eines "
|
||||
"Facebook-Like-Buttons macht den Site-Betreiber "
|
||||
"zum gemeinsam Verantwortlichen + benoetigt "
|
||||
"Einwilligung VOR dem Drittanbieter-Call.",
|
||||
}
|
||||
if has_shariff or has_twoclick:
|
||||
return {
|
||||
"severity": "INFO",
|
||||
"code": "social_protected_embed",
|
||||
"label": (
|
||||
"Datenschutzfreundliche Social-Media-Einbindung erkannt "
|
||||
f"({'Shariff' if has_shariff else '2-Klick-Loesung'})"
|
||||
),
|
||||
"detail": (
|
||||
"Drittanbieter-Skripte werden erst nach aktivem Klick "
|
||||
"geladen — kein Tracking ohne Einwilligung."
|
||||
),
|
||||
"legal_basis": "EuGH C-40/17 + EDPB Guidelines 8/2020.",
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def run_all(
|
||||
banner_result: dict | None,
|
||||
doc_texts: dict[str, str] | None,
|
||||
cookie_doc_missing: bool = False,
|
||||
homepage_html: str | None = None,
|
||||
) -> list[dict]:
|
||||
findings: list[dict] = []
|
||||
try:
|
||||
f = check_save_only_reject(banner_result or {})
|
||||
if f:
|
||||
findings.append(f)
|
||||
except Exception as e:
|
||||
logger.warning("P35 save_only_reject failed: %s", e)
|
||||
try:
|
||||
f = check_cookies_in_dse(doc_texts or {}, cookie_doc_missing)
|
||||
if f:
|
||||
findings.append(f)
|
||||
except Exception as e:
|
||||
logger.warning("P77 cookies_in_dse failed: %s", e)
|
||||
try:
|
||||
f = check_jc_clause_in_dse(doc_texts or {})
|
||||
if f:
|
||||
findings.append(f)
|
||||
except Exception as e:
|
||||
logger.warning("P78 jc_clause failed: %s", e)
|
||||
try:
|
||||
f = check_social_embedding(doc_texts or {}, homepage_html)
|
||||
if f:
|
||||
findings.append(f)
|
||||
except Exception as e:
|
||||
logger.warning("P36 social_embedding failed: %s", e)
|
||||
return findings
|
||||
|
||||
|
||||
def build_signals_block_html(findings: list[dict]) -> str:
|
||||
if not findings:
|
||||
return ""
|
||||
pos = [f for f in findings if f.get("severity") == "INFO"]
|
||||
neg = [f for f in findings if f.get("severity") != "INFO"]
|
||||
items: list[str] = []
|
||||
for f in neg + pos:
|
||||
sev = f.get("severity", "MEDIUM")
|
||||
if sev == "INFO":
|
||||
color = "#16a34a"
|
||||
tag = "✓ POSITIV"
|
||||
elif sev == "HIGH":
|
||||
color = "#dc2626"
|
||||
tag = "HOCH"
|
||||
else:
|
||||
color = "#d97706"
|
||||
tag = "MITTEL"
|
||||
items.append(
|
||||
f'<li style="margin-bottom:8px;font-size:11px;line-height:1.5">'
|
||||
f'<strong style="color:{color}">[{tag}] {f.get("label","")}</strong>'
|
||||
f'<div style="color:#475569;margin-top:2px">{f.get("detail","")}</div>'
|
||||
f'<div style="color:#94a3b8;margin-top:2px;font-style:italic">'
|
||||
f'{f.get("legal_basis","")}</div></li>'
|
||||
)
|
||||
return (
|
||||
'<div style="font-family:-apple-system,BlinkMacSystemFont,sans-serif;'
|
||||
'max-width:760px;margin:0 auto 16px;padding:12px 16px;'
|
||||
'background:#f8fafc;border:1px solid #e2e8f0;border-radius:6px">'
|
||||
'<div style="font-size:11px;color:#475569;text-transform:uppercase;'
|
||||
'letter-spacing:1.2px;margin-bottom:4px;font-weight:600">'
|
||||
'Weitere Textsignale</div>'
|
||||
'<ul style="margin:6px 0 0 18px;padding:0">'
|
||||
+ "".join(items) +
|
||||
'</ul></div>'
|
||||
)
|
||||
@@ -0,0 +1,162 @@
|
||||
"""
|
||||
Erkennt den wahrscheinlichen Doc-Type eines eingefuegten Textes.
|
||||
|
||||
Wird genutzt wenn der User Text direkt ins Frontend kopiert. Wenn der
|
||||
erkannte Typ vom user-deklarierten Typ abweicht, gibt das System einen
|
||||
Hinweis (oder reklassifiziert automatisch wenn Confidence hoch genug).
|
||||
|
||||
Heuristik basiert auf Pflichtangaben-Patterns:
|
||||
* Impressum: §5 TMG-Bestandteile (Anschrift + Telefon + Email + UStID)
|
||||
* DSE: Art. 13 DSGVO-Bestandteile (Verantwortlicher + Zweck + Rechtsgrund)
|
||||
* AGB: Vertragsschluss + Lieferung + Zahlung + Gerichtsstand
|
||||
* Widerruf: 14-Tage-Frist + Widerrufsformular + Wertersatz
|
||||
* Cookie-Richtlinie: Cookie-Tabelle / Speicherdauer / Drittanbieter
|
||||
* Nutzungsbedingungen: Lizenz + Verbot der Vervielfaeltigung + Account
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_PATTERNS: dict[str, list[tuple[re.Pattern, int]]] = {
|
||||
"impressum": [
|
||||
(re.compile(r"§\s*5\s+TMG", re.I), 4),
|
||||
(re.compile(r"angaben\s+gem(ä|ae)ß", re.I), 3),
|
||||
(re.compile(r"\bUSt[\-\s]?ID[\-\s]?Nr\b", re.I), 4),
|
||||
(re.compile(r"vertretungsberechtigt(e|er)", re.I), 3),
|
||||
(re.compile(r"registergericht", re.I), 3),
|
||||
(re.compile(r"handelsregister(nummer)?", re.I), 3),
|
||||
(re.compile(r"\bHRB\s+\d+", re.I), 3),
|
||||
(re.compile(r"verantwortlich\s+f(ü|ue)r\s+den\s+inhalt", re.I), 3),
|
||||
(re.compile(r"\bRStV\b|Rundfunkstaatsvertrag", re.I), 3),
|
||||
(re.compile(r"streitschlichtung", re.I), 2),
|
||||
(re.compile(r"OS[\-\s]?plattform", re.I), 2),
|
||||
],
|
||||
"dse": [
|
||||
(re.compile(r"art(ikel)?\.?\s*13\s+DSGVO", re.I), 5),
|
||||
(re.compile(r"art(ikel)?\.?\s*15\s+DSGVO", re.I), 4),
|
||||
(re.compile(r"rechtsgrundlage", re.I), 3),
|
||||
(re.compile(r"datenschutzbeauftragt", re.I), 4),
|
||||
(re.compile(r"berechtigtes\s+interesse", re.I), 3),
|
||||
(re.compile(r"betroffenenrechte", re.I), 3),
|
||||
(re.compile(r"aufsichtsbeh(ö|oe)rde", re.I), 3),
|
||||
(re.compile(r"speicherdauer|aufbewahrungsfrist", re.I), 2),
|
||||
(re.compile(r"datenkategorie", re.I), 2),
|
||||
(re.compile(r"verantwortliche(r|n)\s+im\s+sinne", re.I), 4),
|
||||
],
|
||||
"agb": [
|
||||
(re.compile(r"allgemeine\s+gesch(ä|ae)ftsbedingungen", re.I), 5),
|
||||
(re.compile(r"\bAGB\b", re.I), 3),
|
||||
(re.compile(r"vertragsschluss|vertragsabschluss", re.I), 3),
|
||||
(re.compile(r"liefer(bedingungen|zeit|kosten)", re.I), 2),
|
||||
(re.compile(r"gew(ä|ae)hrleistung", re.I), 2),
|
||||
(re.compile(r"haftungsausschluss", re.I), 2),
|
||||
(re.compile(r"gerichtsstand", re.I), 3),
|
||||
(re.compile(r"anwendbares\s+recht", re.I), 2),
|
||||
(re.compile(r"salvatorische\s+klausel", re.I), 2),
|
||||
],
|
||||
"widerruf": [
|
||||
(re.compile(r"widerrufsbelehrung", re.I), 5),
|
||||
(re.compile(r"14\s+tage", re.I), 3),
|
||||
(re.compile(r"widerrufsrecht", re.I), 4),
|
||||
(re.compile(r"widerrufsformular", re.I), 3),
|
||||
(re.compile(r"wertersatz", re.I), 3),
|
||||
(re.compile(r"r(ü|ue)cksende(kosten|gebuehr)", re.I), 3),
|
||||
(re.compile(r"muster[\-\s]?widerrufsformular", re.I), 4),
|
||||
],
|
||||
"cookie": [
|
||||
(re.compile(r"cookie[\-\s]?richtlinie", re.I), 4),
|
||||
(re.compile(r"cookie[\-\s]?policy", re.I), 4),
|
||||
(re.compile(r"tracking[\-\s]?cookies?", re.I), 3),
|
||||
(re.compile(r"funktionale\s+cookies?", re.I), 3),
|
||||
(re.compile(r"marketing[\-\s]?cookies?", re.I), 3),
|
||||
(re.compile(r"speicherdauer\s*\d+\s*(tag|monat|jahr)", re.I), 3),
|
||||
(re.compile(r"drittanbieter[\-\s]?cookies?", re.I), 3),
|
||||
(re.compile(r"\b(IDE|_ga|_gid|_fbp|_gcl_au|OptanonConsent)\b"), 3),
|
||||
(re.compile(r"opt[\-\s]?out", re.I), 2),
|
||||
],
|
||||
"nutzungsbedingungen": [
|
||||
(re.compile(r"nutzungsbedingungen", re.I), 5),
|
||||
(re.compile(r"terms\s+of\s+(use|service)", re.I), 4),
|
||||
(re.compile(r"benutzerkonto|nutzerkonto", re.I), 3),
|
||||
(re.compile(r"untersagt|unzul(ä|ae)ssig.{0,30}nutzung", re.I), 2),
|
||||
(re.compile(r"sperrung\s+des\s+kontos", re.I), 2),
|
||||
],
|
||||
"social_media": [
|
||||
(re.compile(r"social[\-\s]?media[\-\s]?(plug[\-\s]?ins?|kanale|kanaele|pr(ä|ae)senz)", re.I), 4),
|
||||
(re.compile(r"gemeinsam\s+verantwortlich.{0,100}(facebook|meta|instagram)", re.I), 4),
|
||||
(re.compile(r"fanpage|fan[\-\s]?page", re.I), 3),
|
||||
(re.compile(r"like[\-\s]?button|share[\-\s]?button", re.I), 2),
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def classify(text: str, top_n: int = 3) -> list[tuple[str, int]]:
|
||||
"""Returns list of (doc_type, score) sorted by score desc.
|
||||
|
||||
Score >= 6 = high confidence, 3-5 = medium, < 3 = low.
|
||||
"""
|
||||
if not text or len(text) < 200:
|
||||
return []
|
||||
scores: dict[str, int] = {}
|
||||
for dt, pats in _PATTERNS.items():
|
||||
s = 0
|
||||
for pat, weight in pats:
|
||||
if pat.search(text):
|
||||
s += weight
|
||||
if s > 0:
|
||||
scores[dt] = s
|
||||
ranked = sorted(scores.items(), key=lambda x: x[1], reverse=True)
|
||||
return ranked[:top_n]
|
||||
|
||||
|
||||
def best_match(text: str) -> tuple[str, int] | None:
|
||||
"""Returns (doc_type, score) of best match or None."""
|
||||
ranked = classify(text, top_n=1)
|
||||
return ranked[0] if ranked else None
|
||||
|
||||
|
||||
def detect_mismatch(
|
||||
declared_doc_type: str,
|
||||
text: str,
|
||||
min_confidence: int = 6,
|
||||
) -> dict | None:
|
||||
"""If the text scores higher for a different doc_type than declared,
|
||||
return a hint dict {detected, declared, scores, action}.
|
||||
|
||||
action='reclassify' if confidence is very high (>= min_confidence * 1.5)
|
||||
action='warn' if medium (>= min_confidence)
|
||||
action=None / no return otherwise.
|
||||
"""
|
||||
ranked = classify(text, top_n=3)
|
||||
if not ranked:
|
||||
return None
|
||||
detected, detected_score = ranked[0]
|
||||
declared_canon = (declared_doc_type or "").lower().strip()
|
||||
# Aliase normalisieren
|
||||
alias = {"datenschutz": "dse", "privacy": "dse",
|
||||
"terms": "nutzungsbedingungen",
|
||||
"terms_of_use": "nutzungsbedingungen"}
|
||||
declared_canon = alias.get(declared_canon, declared_canon)
|
||||
|
||||
if detected == declared_canon:
|
||||
return None
|
||||
if detected_score < min_confidence:
|
||||
return None
|
||||
declared_score = next((s for dt, s in ranked if dt == declared_canon), 0)
|
||||
# Nur wenn detected DEUTLICH besser ist (Faktor >= 2 oder declared = 0)
|
||||
if declared_score and detected_score < declared_score * 2:
|
||||
return None
|
||||
|
||||
action = "reclassify" if detected_score >= min_confidence * 1.5 else "warn"
|
||||
return {
|
||||
"detected": detected,
|
||||
"declared": declared_doc_type,
|
||||
"detected_score": detected_score,
|
||||
"declared_score": declared_score,
|
||||
"action": action,
|
||||
"all_scores": dict(ranked),
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
"""
|
||||
P87 — Konfidenz-Score pro Finding.
|
||||
|
||||
Nicht jedes HIGH-Finding ist gleich sicher. "Kein Reject-Button im Banner"
|
||||
ist faktisch direkt beobachtbar (Confidence ~98%). "DSE enthaelt keinen
|
||||
DSB-Kontakt" ist ein Textmuster-Match und kann False-Positive sein
|
||||
(Confidence ~70%). "Cookie X als essential deklariert, Library sagt
|
||||
marketing" haengt von Library-Qualitaet ab (Confidence ~80%).
|
||||
|
||||
Liefert pro Finding-Label ein (confidence_pct, reason) Paar. Wird im
|
||||
Mail-Render als kleine graue Klammer hinter dem Severity-Pill angezeigt:
|
||||
"HOCH (95% Konfidenz: Direkt im DOM beobachtet)".
|
||||
|
||||
Keine ML — nur regelbasiert. Eine zentrale Stelle damit alle Render-
|
||||
Stellen einheitlich klassifizieren.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
# (regex, confidence_pct, reason)
|
||||
# Reihenfolge wichtig: spezifischere Patterns zuerst.
|
||||
_RULES: list[tuple[re.Pattern, int, str]] = [
|
||||
# 1) Direkt im DOM / im Cookie-Jar beobachtet — sehr hohe Sicherheit
|
||||
(re.compile(r"reject[- ]?button.*(fehlt|nicht.*vorhanden)", re.I), 98,
|
||||
"Direkt im Banner-DOM ueberprueft"),
|
||||
(re.compile(r"(anpassen|einstellungen|customize).*button.*fehlt", re.I), 95,
|
||||
"Initial-Banner-DOM ueberprueft"),
|
||||
(re.compile(r"cookie.*vor.*einwilligung.*gesetzt", re.I), 96,
|
||||
"Cookie-Jar vor Akzeptieren beobachtet"),
|
||||
(re.compile(r"(tracking|marketing).*ohne.*einwilligung", re.I), 92,
|
||||
"Network-Calls vor Akzeptieren beobachtet"),
|
||||
|
||||
# 2) Library-Mismatches — abhaengig von Library-Qualitaet
|
||||
(re.compile(r"deklariert als.*library.*sagt", re.I), 82,
|
||||
"Vergleich mit ~2.300-Cookie-Library + Open-Cookie-DB"),
|
||||
(re.compile(r"library.*marketing", re.I), 82,
|
||||
"Cookie-Library-Klassifikation"),
|
||||
|
||||
# 3) Pflichtangaben-Checks (Impressum/AGB/DSE) — Textmuster, MEDIUM-Sicherheit
|
||||
(re.compile(r"impressum.*(fehlt|unvollstaendig)", re.I), 88,
|
||||
"Pattern-Match auf Impressums-Pflichtfelder (§ 5 TMG)"),
|
||||
(re.compile(r"dsb.*(fehlt|nicht.*genannt)", re.I), 75,
|
||||
"Textmuster-Suche; DSB kann ueber Impressum referenziert sein"),
|
||||
(re.compile(r"drittland.*(fehlt|nicht.*genannt|ohne.*hinweis)", re.I), 80,
|
||||
"Pattern-Match auf typische Drittland-Klauseln"),
|
||||
(re.compile(r"widerruf.*(fehlt|unvollstaendig)", re.I), 85,
|
||||
"Pattern-Match auf Widerrufsbelehrungs-Pflichtfelder"),
|
||||
|
||||
# 4) Anti-Auditing-Detection — heuristisch
|
||||
(re.compile(r"anti[- ]?audit", re.I), 70,
|
||||
"Skript-Domain-Heuristik; manuelle Pruefung empfohlen"),
|
||||
|
||||
# 5) Generische Konsistenz-Findings (DSE vs. Banner vs. Cookie-Liste)
|
||||
(re.compile(r"banner.*nennt.*\d+.*cmp.*\d+", re.I), 90,
|
||||
"Quantitativer Vergleich zwischen Banner-Text und CMP-Payload"),
|
||||
|
||||
# 6) Klassifikations- / Kontext-Findings (Wizard-getrieben)
|
||||
(re.compile(r"(branchen|scope).*passt.*nicht", re.I), 88,
|
||||
"Wizard-Klassifikation + MC-scope_doc_type"),
|
||||
]
|
||||
|
||||
_DEFAULT_CONFIDENCE = 78
|
||||
_DEFAULT_REASON = (
|
||||
"Standard-Regelpruefung; Bestaetigung mit DSB / interner Doku empfohlen"
|
||||
)
|
||||
|
||||
|
||||
def score_finding(label: str) -> tuple[int, str]:
|
||||
"""Returns (confidence_pct, reason) for a finding label."""
|
||||
if not label:
|
||||
return _DEFAULT_CONFIDENCE, _DEFAULT_REASON
|
||||
for pat, conf, reason in _RULES:
|
||||
if pat.search(label):
|
||||
return conf, reason
|
||||
return _DEFAULT_CONFIDENCE, _DEFAULT_REASON
|
||||
|
||||
|
||||
def confidence_pill_html(label: str) -> str:
|
||||
"""Returns an inline HTML snippet '(NN% Konfidenz: ...)' or empty."""
|
||||
conf, reason = score_finding(label)
|
||||
return (
|
||||
f' <span style="color:#94a3b8;font-size:10px" title="{reason}">'
|
||||
f'({conf}% Konfidenz)</span>'
|
||||
)
|
||||
@@ -0,0 +1,12 @@
|
||||
"""Founding-Wizard Service: rendert Templates + generiert DOCX-Files."""
|
||||
|
||||
from .markdown_to_docx import markdown_to_docx_bytes
|
||||
from .template_renderer import find_undefined_placeholders, render_template
|
||||
from .wizard_to_context import base_context
|
||||
|
||||
__all__ = [
|
||||
"base_context",
|
||||
"find_undefined_placeholders",
|
||||
"markdown_to_docx_bytes",
|
||||
"render_template",
|
||||
]
|
||||
@@ -0,0 +1,181 @@
|
||||
"""
|
||||
Konvertiert gerendertes Markdown in eine .docx-Datei mittels python-docx.
|
||||
|
||||
Unterstuetzte Markdown-Elemente:
|
||||
- # / ## / ### / #### / ##### Headings
|
||||
- **bold** und _italic_ inline
|
||||
- Tabellen (Pipe-Syntax)
|
||||
- Listen mit - oder * oder Ziffer.)
|
||||
- Horizontale Linien ---
|
||||
- Code-Inline `code`
|
||||
|
||||
Bewusst minimal — fuer rechtliche Dokumente brauchen wir keine Bilder/Embeds.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import re
|
||||
from typing import Any, Optional
|
||||
|
||||
from docx import Document
|
||||
from docx.shared import Pt
|
||||
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
||||
|
||||
HEADING_RE = re.compile(r"^(#{1,5})\s+(.+)$")
|
||||
HR_RE = re.compile(r"^[-_*]{3,}\s*$")
|
||||
LIST_BULLET_RE = re.compile(r"^(\s*)([-*+])\s+(.+)$")
|
||||
LIST_NUMBER_RE = re.compile(r"^(\s*)(\d+)[\.\)]\s+(.+)$")
|
||||
TABLE_ROW_RE = re.compile(r"^\|(.+)\|\s*$")
|
||||
TABLE_SEP_RE = re.compile(r"^\|[\s\-:|]+\|\s*$")
|
||||
|
||||
INLINE_BOLD = re.compile(r"\*\*([^*]+)\*\*")
|
||||
# Italic: nur _wort_ wenn von Whitespace/Satzzeichen umgeben — verhindert dass
|
||||
# snake_case-Variablen wie ESKALATION_TAGE_INTERN als Italic interpretiert werden.
|
||||
INLINE_ITALIC = re.compile(
|
||||
r"(?<!\*)\*(?!\*)([^*\n]+)\*(?!\*)"
|
||||
r"|(?<![A-Za-z0-9_])_([^_\n]+)_(?![A-Za-z0-9_])"
|
||||
)
|
||||
INLINE_CODE = re.compile(r"`([^`]+)`")
|
||||
|
||||
|
||||
def _add_runs(paragraph: Any, text: str) -> None:
|
||||
"""Parse inline-Formatierung und fuege Runs hinzu."""
|
||||
pos = 0
|
||||
tokens: list[tuple[str, str]] = []
|
||||
while pos < len(text):
|
||||
m_bold = INLINE_BOLD.search(text, pos)
|
||||
m_code = INLINE_CODE.search(text, pos)
|
||||
m_italic = INLINE_ITALIC.search(text, pos)
|
||||
|
||||
candidates = [m for m in (m_bold, m_code, m_italic) if m]
|
||||
if not candidates:
|
||||
tokens.append(("plain", text[pos:]))
|
||||
break
|
||||
first = min(candidates, key=lambda m: m.start())
|
||||
if first.start() > pos:
|
||||
tokens.append(("plain", text[pos:first.start()]))
|
||||
if first is m_bold:
|
||||
tokens.append(("bold", first.group(1)))
|
||||
elif first is m_code:
|
||||
tokens.append(("code", first.group(1)))
|
||||
elif m_italic is not None:
|
||||
content = m_italic.group(1) or m_italic.group(2)
|
||||
tokens.append(("italic", content))
|
||||
pos = first.end()
|
||||
|
||||
for kind, content in tokens:
|
||||
run = paragraph.add_run(content)
|
||||
if kind == "bold":
|
||||
run.bold = True
|
||||
elif kind == "italic":
|
||||
run.italic = True
|
||||
elif kind == "code":
|
||||
run.font.name = "Courier New"
|
||||
run.font.size = Pt(10)
|
||||
|
||||
|
||||
def _parse_table(lines: list[str], start: int) -> tuple[list[list[str]], int]:
|
||||
"""Parst Markdown-Tabelle. Returns (rows, next_line_index)."""
|
||||
rows: list[list[str]] = []
|
||||
i = start
|
||||
while i < len(lines):
|
||||
line = lines[i].rstrip()
|
||||
if not TABLE_ROW_RE.match(line) and not TABLE_SEP_RE.match(line):
|
||||
break
|
||||
if TABLE_SEP_RE.match(line):
|
||||
i += 1
|
||||
continue
|
||||
cells = [c.strip() for c in line.strip("|").split("|")]
|
||||
rows.append(cells)
|
||||
i += 1
|
||||
return rows, i
|
||||
|
||||
|
||||
def _add_table(doc: Any, rows: list[list[str]]) -> None:
|
||||
if not rows:
|
||||
return
|
||||
ncols = max(len(r) for r in rows)
|
||||
table = doc.add_table(rows=len(rows), cols=ncols)
|
||||
table.style = "Light Grid"
|
||||
for r_idx, row in enumerate(rows):
|
||||
for c_idx, cell_text in enumerate(row):
|
||||
if c_idx < ncols:
|
||||
cell = table.rows[r_idx].cells[c_idx]
|
||||
cell.text = ""
|
||||
p = cell.paragraphs[0]
|
||||
_add_runs(p, cell_text)
|
||||
if r_idx == 0:
|
||||
for run in p.runs:
|
||||
run.bold = True
|
||||
|
||||
|
||||
def markdown_to_docx_bytes(markdown_text: str, title: Optional[str] = None) -> bytes:
|
||||
"""Konvertiert Markdown nach DOCX und returns die Bytes."""
|
||||
doc = Document()
|
||||
|
||||
# Basis-Style
|
||||
style = doc.styles["Normal"]
|
||||
style.font.name = "Calibri"
|
||||
style.font.size = Pt(11)
|
||||
|
||||
if title:
|
||||
h = doc.add_heading(title, level=0)
|
||||
h.alignment = WD_ALIGN_PARAGRAPH.LEFT
|
||||
|
||||
lines = markdown_text.split("\n")
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
line = lines[i].rstrip()
|
||||
|
||||
if not line.strip():
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Heading
|
||||
h_match = HEADING_RE.match(line)
|
||||
if h_match:
|
||||
level = len(h_match.group(1))
|
||||
text = h_match.group(2)
|
||||
heading = doc.add_heading(level=min(level, 4))
|
||||
_add_runs(heading, text)
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Horizontal Rule
|
||||
if HR_RE.match(line):
|
||||
doc.add_paragraph("─" * 60)
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Tabelle
|
||||
if TABLE_ROW_RE.match(line):
|
||||
rows, i = _parse_table(lines, i)
|
||||
_add_table(doc, rows)
|
||||
doc.add_paragraph()
|
||||
continue
|
||||
|
||||
# List Bullet
|
||||
b_match = LIST_BULLET_RE.match(line)
|
||||
if b_match:
|
||||
p = doc.add_paragraph(style="List Bullet")
|
||||
_add_runs(p, b_match.group(3))
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# List Number
|
||||
n_match = LIST_NUMBER_RE.match(line)
|
||||
if n_match:
|
||||
p = doc.add_paragraph(style="List Number")
|
||||
_add_runs(p, n_match.group(3))
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Sonst: normaler Paragraph
|
||||
p = doc.add_paragraph()
|
||||
_add_runs(p, line)
|
||||
i += 1
|
||||
|
||||
buf = io.BytesIO()
|
||||
doc.save(buf)
|
||||
return buf.getvalue()
|
||||
@@ -0,0 +1,107 @@
|
||||
"""
|
||||
Handlebars-light Template-Renderer fuer die compliance_legal_templates.
|
||||
|
||||
Unterstuetzte Syntax:
|
||||
- {{VARIABLE_NAME}} - einfache String-Substitution
|
||||
- {{#IF FLAG}}...{{/IF}} - bedingter Block (truthy)
|
||||
- {{#IF NOT FLAG}}...{{/IF}} - negierter bedingter Block
|
||||
|
||||
Bewusst minimal gehalten — keine Loops oder Verschachtelung tiefer Logik.
|
||||
Komplexere Sachen werden im Context vorberechnet.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
# Innerste {{#IF FLAG}}...{{/IF}}-Bloecke (Content enthaelt KEIN weiteres {{#IF).
|
||||
# Iteratives Anwenden loest Verschachtelung von innen nach aussen sauber auf.
|
||||
IF_INNERMOST = re.compile(
|
||||
r"\{\{#IF\s+(NOT\s+)?([A-Z_][A-Z0-9_]*)\}\}"
|
||||
r"((?:(?!\{\{#IF).)*?)" # Content: kein weiteres {{#IF
|
||||
r"\{\{/IF\}\}",
|
||||
re.DOTALL,
|
||||
)
|
||||
VAR_PATTERN = re.compile(r"\{\{\s*([A-Z_][A-Z0-9_]*)\s*\}\}")
|
||||
# Fallback: orphan IF-Tags die nach Iteration uebrig sind (z.B. unbalanced template) raus.
|
||||
ORPHAN_IF_TAG = re.compile(
|
||||
r"\{\{/IF\}\}|\{\{#IF\s+(?:NOT\s+)?[A-Z_][A-Z0-9_]*\}\}"
|
||||
)
|
||||
|
||||
|
||||
def _is_truthy(val: Any) -> bool:
|
||||
"""Pythonische Truthiness, mit Special-Case: leeres dict/list/str = False."""
|
||||
if val is None:
|
||||
return False
|
||||
if isinstance(val, bool):
|
||||
return val
|
||||
if isinstance(val, (int, float)):
|
||||
return val != 0
|
||||
if isinstance(val, str):
|
||||
return val.strip() != "" and val.lower() not in ("false", "0", "no", "nein")
|
||||
if isinstance(val, (list, dict, tuple, set)):
|
||||
return len(val) > 0
|
||||
return True
|
||||
|
||||
|
||||
def render_template(template: str, context: dict[str, Any]) -> str:
|
||||
"""Rendert ein Template mit dem gegebenen Kontext.
|
||||
|
||||
Algorithmus:
|
||||
1. IF-Bloecke iterativ aufloesen (max 10 Durchlaeufe, damit Nesting funktioniert)
|
||||
2. Variablen substituieren
|
||||
|
||||
Args:
|
||||
template: Markdown-Template mit {{VAR}} und {{#IF FLAG}}...{{/IF}}
|
||||
context: dict mit Variablen — Keys SCREAMING_SNAKE_CASE
|
||||
|
||||
Returns:
|
||||
Gerendetes Markdown
|
||||
"""
|
||||
output = template
|
||||
|
||||
# IF-Bloecke iterativ aufloesen — innerste zuerst, dann eine Ebene hoeher, usw.
|
||||
# Bis zu 20 Iterationen reichen fuer realistisches Nesting.
|
||||
for _ in range(20):
|
||||
def replace_if(match: re.Match[str]) -> str:
|
||||
negated = bool(match.group(1))
|
||||
flag_name = match.group(2)
|
||||
content = match.group(3)
|
||||
flag_val = context.get(flag_name)
|
||||
condition = _is_truthy(flag_val)
|
||||
if negated:
|
||||
condition = not condition
|
||||
return content if condition else ""
|
||||
|
||||
new_output = IF_INNERMOST.sub(replace_if, output)
|
||||
if new_output == output:
|
||||
break
|
||||
output = new_output
|
||||
|
||||
# Falls noch orphan IF-Tags uebrig sind (z.B. unbalanced template): entfernen
|
||||
# damit sie nicht im Word-Output landen.
|
||||
output = ORPHAN_IF_TAG.sub("", output)
|
||||
|
||||
def replace_var(match: re.Match[str]) -> str:
|
||||
name = match.group(1)
|
||||
val = context.get(name)
|
||||
if val is None:
|
||||
# Leere Platzhalter sichtbar machen fuer Debugging
|
||||
return f"[{name} fehlt]"
|
||||
if isinstance(val, bool):
|
||||
return "ja" if val else "nein"
|
||||
return str(val)
|
||||
|
||||
output = VAR_PATTERN.sub(replace_var, output)
|
||||
return output
|
||||
|
||||
|
||||
def find_undefined_placeholders(template: str, context: dict[str, Any]) -> list[str]:
|
||||
"""Listet alle Variablen-Platzhalter ohne Wert im Context."""
|
||||
placeholders: set[str] = set()
|
||||
for match in VAR_PATTERN.finditer(template):
|
||||
placeholders.add(match.group(1))
|
||||
for match in IF_INNERMOST.finditer(template):
|
||||
placeholders.add(match.group(2))
|
||||
return sorted([p for p in placeholders if p not in context])
|
||||
@@ -0,0 +1,395 @@
|
||||
"""
|
||||
Mapping vom Wizard-State (frontend) auf den Template-Context (Render-Variablen).
|
||||
|
||||
Frontend liefert ein JSON-Payload mit den Wizard-Schritten. Hier konvertieren
|
||||
wir es in eine flache Dict-Struktur, deren Keys SCREAMING_SNAKE_CASE sind und
|
||||
zu den Platzhaltern in den Templates passen (z.B. {{COMPANY_NAME}}).
|
||||
|
||||
Pro Dokumenttyp (document_type) wird der jeweils benoetigte Subset gebaut.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _gs_table(gesellschafter: list[dict[str, Any]], stammkapital: int) -> str:
|
||||
"""Erzeugt eine Markdown-Tabelle der Gesellschafter."""
|
||||
rows = []
|
||||
for g in gesellschafter:
|
||||
nb = int(g.get("nennbetrag_eur") or 0)
|
||||
pct = (nb / max(stammkapital, 1)) * 100 if stammkapital else 0
|
||||
rows.append(
|
||||
f"| {g.get('anteil_nr', '')} | {g.get('name', '')} | "
|
||||
f"{g.get('geburtsdatum') or g.get('adresse', '')} | "
|
||||
f"{g.get('adresse', '')} | {g.get('anteil_nr', '')} | "
|
||||
f"{nb:,} | {pct:.2f}% |".replace(",", ".")
|
||||
)
|
||||
return "\n".join(rows)
|
||||
|
||||
|
||||
def _parties_list(gesellschafter: list[dict[str, Any]]) -> str:
|
||||
"""Aufzaehlung der Parteien fuer SHA, IP-Assignment etc."""
|
||||
lines = []
|
||||
for idx, g in enumerate(gesellschafter):
|
||||
letter = chr(ord("a") + idx)
|
||||
line = f"{letter}) **{g.get('name', '')}**"
|
||||
if g.get("geburtsdatum"):
|
||||
line += f", geboren am {g['geburtsdatum']}"
|
||||
if g.get("adresse"):
|
||||
line += f", wohnhaft in {g['adresse']}"
|
||||
lines.append(line + ",")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _parties_list_with_shares(gesellschafter: list[dict[str, Any]]) -> str:
|
||||
"""Erzeugt nummerierte Liste der Gesellschafter mit Anteilen fuer § 3 Satzung."""
|
||||
lines = []
|
||||
for g in gesellschafter:
|
||||
nr = g.get("anteil_nr", "?")
|
||||
name = g.get("name", "")
|
||||
nb = int(g.get("nennbetrag_eur") or 0)
|
||||
lines.append(
|
||||
f"{nr}. {name} übernimmt den Geschäftsanteil Nr. {nr} mit einem "
|
||||
f"Nennbetrag von {nb:,} Euro.".replace(",", ".")
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _gf_liste(gf: list[dict[str, Any]]) -> str:
|
||||
"""Liste der Geschaeftsfuehrer fuer Bestellungsbeschluss / HRB-Anmeldung."""
|
||||
lines = []
|
||||
for g in gf:
|
||||
line = f"- **{g.get('name', '')}**"
|
||||
if g.get("geburtsdatum"):
|
||||
line += f", geboren am {g['geburtsdatum']}"
|
||||
if g.get("adresse"):
|
||||
line += f", wohnhaft in {g['adresse']}"
|
||||
if g.get("internal_role"):
|
||||
line += f" — {g['internal_role']}"
|
||||
lines.append(line)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _company_purpose_bullets(bullets: list[str]) -> str:
|
||||
return "\n".join(bullets) if bullets else "a) Allgemeine geschäftliche Tätigkeit"
|
||||
|
||||
|
||||
def _roles_description(gesellschafter: list[dict[str, Any]]) -> str:
|
||||
"""Generiert Anlage-A Rollenbeschreibung pro Gesellschafter."""
|
||||
lines = []
|
||||
for idx, g in enumerate(gesellschafter):
|
||||
name = g.get("name", "")
|
||||
role = g.get("internal_role") or "Gesellschafter"
|
||||
lines.append(f"({idx + 2}) **{role} — {name}**")
|
||||
lines.append(f"Verantwortlich für die operative Leitung im Bereich {role}.\n")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _einzahlungsaufstellung(gesellschafter: list[dict[str, Any]], quote_pct: int) -> str:
|
||||
rows = []
|
||||
for g in gesellschafter:
|
||||
nb = int(g.get("nennbetrag_eur") or 0)
|
||||
paid = int(nb * quote_pct / 100)
|
||||
rows.append(f"- {g.get('name', '')}: {paid:,} EUR von {nb:,} EUR ({quote_pct}%)".replace(",", "."))
|
||||
return "\n".join(rows)
|
||||
|
||||
|
||||
def base_context(state: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Gemeinsamer Context fuer alle Dokumente."""
|
||||
basics = state.get("basics", {})
|
||||
capital = state.get("capital", {})
|
||||
notar = state.get("notar", {})
|
||||
gesellschafter = state.get("gesellschafter", [])
|
||||
gf_list = [g for g in gesellschafter if g.get("is_geschaeftsfuehrer")]
|
||||
sha = state.get("sha", {})
|
||||
|
||||
stammkapital = int(capital.get("stammkapital_eur") or 25000)
|
||||
num_gf = len(gf_list)
|
||||
num_gs = len(gesellschafter)
|
||||
has_academic = any(g.get("has_academic_background") for g in gesellschafter)
|
||||
|
||||
ctx: dict[str, Any] = {
|
||||
# Company
|
||||
"COMPANY_NAME": basics.get("company_name", ""),
|
||||
"COMPANY_LEGAL_FORM": basics.get("legal_form", "GmbH"),
|
||||
"COMPANY_SEAT": basics.get("company_seat", ""),
|
||||
"COMPANY_ADDRESS": basics.get("company_address", ""),
|
||||
"COMPANY_PURPOSE_DESCRIPTION": basics.get("company_purpose_description", ""),
|
||||
"COMPANY_PURPOSE_BULLETS": _company_purpose_bullets(basics.get("company_purpose_bullets", [])),
|
||||
"COMPANY_PURPOSE_SHORT": basics.get("industry", "")[:120],
|
||||
"BUSINESS_YEAR": basics.get("business_year", "Kalenderjahr"),
|
||||
"FIRST_YEAR_END": "31. Dezember des Eintragungsjahres",
|
||||
"PUBLICATION_VENUE": "Bundesanzeiger",
|
||||
# Capital
|
||||
"STAMMKAPITAL_EUR": f"{stammkapital:,}".replace(",", "."),
|
||||
"STAMMKAPITAL_HALF_EUR": f"{stammkapital // 2:,}".replace(",", "."),
|
||||
"EINLAGE_METHOD": capital.get("einlage_method", "Geld"),
|
||||
"EINLAGE_QUOTE_INITIAL_PCT": capital.get("einlage_quote_initial_pct", 50),
|
||||
"EINLAGE_QUOTE_REMAINING_PCT": 100 - int(capital.get("einlage_quote_initial_pct") or 50),
|
||||
"EINLAGE_QUOTE_INITIAL_LESS_THAN_100": (capital.get("einlage_quote_initial_pct") or 50) < 100,
|
||||
"EINZAHLUNGSAUFSTELLUNG": _einzahlungsaufstellung(gesellschafter, capital.get("einlage_quote_initial_pct") or 50),
|
||||
"HAS_SACHEINLAGE": capital.get("has_sacheinlage", False),
|
||||
"VERZUGSFRIST_TAGE": 30,
|
||||
"EINZIEHUNG_MEHRHEIT_PCT": 75,
|
||||
"VORKAUFSRECHT_TAGE": 14,
|
||||
"EINBERUFUNGSFRIST_TAGE": 7,
|
||||
"VOTING_UNIT_EUR": "1,00",
|
||||
"ERBFALL_AUFGRIFFSFRIST_MONATE": 6,
|
||||
"ERBFALL_MEHRHEIT_PCT": 75,
|
||||
"AUFLOESUNG_MEHRHEIT_PCT": 75,
|
||||
"GRUENDUNGSKOSTEN_MAX_EUR": f"{int(stammkapital / 10):,}".replace(",", "."),
|
||||
# Gesellschafter
|
||||
"PARTIES_LIST": _parties_list(gesellschafter),
|
||||
"PARTIES_LIST_WITH_SHARES": _parties_list_with_shares(gesellschafter),
|
||||
"GESELLSCHAFTER_TABELLE": _gs_table(gesellschafter, stammkapital),
|
||||
"GESCHAEFTSFUEHRER_LISTE": _gf_liste(gf_list),
|
||||
"GESELLSCHAFTER_LISTE": _gf_liste(gesellschafter),
|
||||
# GF
|
||||
"NUM_GF": num_gf,
|
||||
"NUM_GF_TEXT": {1: "einen", 2: "zwei", 3: "drei", 4: "vier", 5: "fünf"}.get(num_gf, str(num_gf)),
|
||||
"IS_SINGLE_GF": num_gf == 1,
|
||||
"IS_MULTI_GF": num_gf > 1,
|
||||
"NUM_GF_IS_2": num_gf == 2,
|
||||
"NUM_GF_GT_2": num_gf > 2,
|
||||
"IS_MULTI_GESELLSCHAFTER": num_gs > 1,
|
||||
"IS_FOUNDER_GROUP": num_gs >= 2,
|
||||
"VERTRETUNGSART": "Gesamtvertretung; bei nur einem Geschäftsführer Einzelvertretung",
|
||||
# Notar
|
||||
"NOTARY_NAME": notar.get("notary_name", ""),
|
||||
"NOTARY_PLACE": notar.get("notary_place", ""),
|
||||
"NOTARY_ADDRESS": notar.get("notary_address", ""),
|
||||
"NOTARY_URNR": notar.get("urnr", "[wird beim Termin vergeben]"),
|
||||
"NOTARIAL_DATE": notar.get("notarial_date", "[Notartermin folgt]"),
|
||||
"NOTARY_BEGLAUBIGUNG_URNR": "[wird beim Termin vergeben]",
|
||||
"NOTARIAL_LOCATION": notar.get("notary_place", ""),
|
||||
"ANMELDUNG_TYP": "Ersteintragung gemäß § 7 GmbHG",
|
||||
"ANMELDUNG_DATE": notar.get("notarial_date", "[Notartermin folgt]"),
|
||||
"REGISTRY_COURT_ADDRESS": "[Adresse des zuständigen Registergerichts]",
|
||||
"COMPANY_REGISTRY_COURT": basics.get("register_court") or "[zuständiges Amtsgericht]",
|
||||
"REGISTER_COURT": basics.get("register_court") or "[zuständiges Amtsgericht]",
|
||||
# Common
|
||||
"DOCUMENT_VERSION": "1.0.0",
|
||||
"EFFECTIVE_DATE": notar.get("notarial_date", "[Datum der Beurkundung]"),
|
||||
"RESOLUTION_DATE": notar.get("notarial_date", "[Datum der Beurkundung]"),
|
||||
"NEXT_REVIEW_DATE": "[+ 12 Monate]",
|
||||
"SIGNATURES_BLOCK": "Unterschriften gemäß notarieller Beurkundung",
|
||||
# SHA Flags
|
||||
"HAS_SHA": sha.get("has_sha", True),
|
||||
"HAS_GO_GF": True,
|
||||
"HAS_ACADEMIC_FOUNDER": has_academic,
|
||||
"HAS_RESEARCH_FOCUS": basics.get("has_research_focus", False),
|
||||
"HAS_BEIRAT": sha.get("has_beirat", False),
|
||||
"HAS_TEXAS_SHOOTOUT": sha.get("has_texas_shootout", False),
|
||||
"HAS_CEO_DESIGNATION": sha.get("has_ceo_designation", False),
|
||||
"CEO_NAME": sha.get("ceo_name", ""),
|
||||
"HAS_HRB": bool(basics.get("hrb_number")),
|
||||
"HRB_NUMBER": basics.get("hrb_number") or "[wird vergeben]",
|
||||
"IS_UG": basics.get("legal_form") == "UG",
|
||||
# GO-GF dynamische §-Numerierung
|
||||
"P_INFO": 5,
|
||||
"P_GESELLSCHAFTER": 4 if num_gf == 1 else 4,
|
||||
"P_AUSSER": 5,
|
||||
"P_ENT": 6,
|
||||
"P_FIN": 7,
|
||||
"P_PERS": 8,
|
||||
"P_IK": 9,
|
||||
"P_NEB": 10,
|
||||
"P_DOC": 10,
|
||||
"P_DOC_NEXT": 2,
|
||||
"P_DOC_NEXT_2": 3,
|
||||
"P_END": 11,
|
||||
"LAST_PARA_4": 4 if num_gf == 2 else 5,
|
||||
# SHA dynamische §-Numerierung (mit/ohne Beirat)
|
||||
"P_NONCOMPETE": 16 if sha.get("has_beirat") else 15,
|
||||
"P_CONFIDENTIAL": 17 if sha.get("has_beirat") else 16,
|
||||
"P_TERM": 18 if sha.get("has_beirat") else 17,
|
||||
"P_FINAL": 19 if sha.get("has_beirat") else 18,
|
||||
"P_IP_PARA_6": 6 if has_academic else 3,
|
||||
"P_IP_PARA_7": 7 if has_academic else 4,
|
||||
"P_IP_PARA_8": 8 if has_academic else 5,
|
||||
"P_DEADLOCK_FINAL": 5,
|
||||
"P_DEADLOCK_LAST": 6,
|
||||
"LAST_ROLE_PARA": len(gesellschafter) + 2,
|
||||
"LAST_ROLE_PARA_PLUS_1": len(gesellschafter) + 3,
|
||||
# Satzung dynamische §-Numerierung
|
||||
"P_EINZIEHUNG": 7,
|
||||
"P_VORKAUF": 8,
|
||||
"P_TAGALONG": 9,
|
||||
"P_DRAGALONG": 10,
|
||||
"P_VERSAMMLUNG": 11,
|
||||
"P_JA": 12,
|
||||
"P_ERGEBNIS": 13,
|
||||
"P_AUFGRIFF": 14,
|
||||
"P_ABTRETUNG": 15,
|
||||
"P_ERBE": 16,
|
||||
"P_AUFL": 17,
|
||||
"P_SCHLUSS": 18,
|
||||
# SHA Eskalation und sonstige Schwellenwerte
|
||||
"ESKALATION_TAGE_INTERN": 5,
|
||||
"ESKALATION_TAGE_GESELLSCHAFTER": 14,
|
||||
"ERHEBLICH_EUR": "10.000",
|
||||
"DEADLOCK_FRIST_TAGE": 30,
|
||||
"MEDIATION_INIT_TAGE": 7,
|
||||
"MEDIATOR_FRIST_TAGE": 5,
|
||||
"MEDIATION_MAX_TAGE": 30,
|
||||
"SHOOTOUT_FRIST_TAGE": 14,
|
||||
"SHOOTOUT_ABWICKLUNG_TAGE": 60,
|
||||
"ESKALATION_TAGE": 30,
|
||||
"JURISDICTION_LOCATION": basics.get("company_seat", "[Sitz]"),
|
||||
"PARA_181_DETAILS": "soweit Geschäftsführer von den Beschränkungen befreit",
|
||||
"ARCHIV_VERANTWORTLICH": "Geschäftsführung",
|
||||
"DOKUMENTATIONS_SYSTEM": "elektronischen Dokumentenmanagement",
|
||||
"ARCHIVIERUNG_JAHRE": 10,
|
||||
"REVIEW_VERANTWORTLICH": "Geschäftsführung",
|
||||
"MEETING_OPERATIVE_FREQ": "wöchentliche",
|
||||
"MEETING_STRATEGIE_FREQ": "monatliche",
|
||||
"SCHWELLE_EINZEL_EUR": "10.000",
|
||||
"SCHWELLE_EINZEL_EUR_PLUS_1": "10.001",
|
||||
"SCHWELLE_GEMEINSAM_EUR": "50.000",
|
||||
"SCHWELLE_GESELLSCHAFTER_EUR": "50.000",
|
||||
"BUDGET_ABWEICHUNG_PCT": 10,
|
||||
"VERTRAG_LAUFZEIT_MONATE": 24,
|
||||
"VERTRAG_WERT_EUR": "50.000",
|
||||
"LIQUIDITAET_MIN_MONATE": 3,
|
||||
"FORECAST_HORIZON_MONTHS": 12,
|
||||
"SCHLUESSELPERSON_GEHALT_EUR": "80.000",
|
||||
"NEBENTAETIGKEIT_MAX_STUNDEN": 8,
|
||||
# SHA-Spezifika
|
||||
"VESTING_START_EVENT": "Eintragung der Gesellschaft im Handelsregister",
|
||||
"VESTING_MONTHS": sha.get("vesting_months", 48),
|
||||
"CLIFF_MONTHS": sha.get("cliff_months", 12),
|
||||
"ACCELERATION_THRESHOLD_PCT": 50,
|
||||
"ACCELERATION_PCT": 100,
|
||||
"BAD_LEAVER_UNVESTED_PCT": 20,
|
||||
"FMV_AGREEMENT_DAYS": 14,
|
||||
"ABFINDUNG_RATEN_MAX": 24,
|
||||
"NON_SOLICIT_MONTHS": 12,
|
||||
"VORKAUFSRECHT_TAGE": 14,
|
||||
"TAG_ALONG_THRESHOLD_PCT": sha.get("tag_along_threshold_pct", 20),
|
||||
"TAG_ALONG_FRIST_TAGE": 14,
|
||||
"DRAG_ALONG_THRESHOLD_PCT": sha.get("drag_along_threshold_pct", 75),
|
||||
"RESERVED_MATTERS_MAJORITY_PCT": sha.get("reserved_matters_majority_pct", 75),
|
||||
"ASSET_THRESHOLD_EUR": "50.000",
|
||||
"ESOP_POOL_PCT": sha.get("esop_pool_pct", 0),
|
||||
"INVESTOR_INFO_THRESHOLD_EUR": "50.000",
|
||||
"ANNUAL_REPORT_MONTHS": 6,
|
||||
"BEIRAT_MAX_MITGLIEDER": 5,
|
||||
"BEIRAT_FREQ": "vierteljährlich",
|
||||
"PASSIVE_INVEST_PCT": 5,
|
||||
"POST_EXIT_GOOD_MONTHS": 12,
|
||||
"POST_EXIT_BAD_MONTHS": 24,
|
||||
"ROLES_DESCRIPTION": _roles_description(gesellschafter),
|
||||
"SIGNATURE_DATE": notar.get("notarial_date", "[Datum]"),
|
||||
# Gesellschafterliste
|
||||
"LIST_DATE": notar.get("notarial_date", "[Datum]"),
|
||||
"LIST_AUTHOR": gf_list[0].get("name", "") if gf_list else "",
|
||||
"LIST_AUTHOR_ROLE": "Geschäftsführer",
|
||||
"LIST_REASON": "Erstaufstellung gemäß § 40 GmbHG",
|
||||
"SIGNATORY_NAME": gf_list[0].get("name", "") if gf_list else "",
|
||||
"SIGNATORY_ROLE": "Geschäftsführer",
|
||||
"SIGNATORY_2_NAME": gf_list[1].get("name", "") if len(gf_list) > 1 else "",
|
||||
"SIGNATORY_2_ROLE": "Geschäftsführer",
|
||||
"MULTI_SIGNATORY": len(gf_list) > 1,
|
||||
# Bestellungsbeschluss
|
||||
"MEETING_LOCATION": notar.get("notary_place", "[Notarsitz]"),
|
||||
"RESOLUTION_FORM": "notariell beurkundet",
|
||||
"ANWESENHEITSQUOTE_PCT": 100,
|
||||
"IS_EINSTIMMIG": True,
|
||||
"BESCHLUSS_MEHRHEIT_PCT": 100,
|
||||
"IS_PRESENCE_MEETING": True,
|
||||
"IS_SINGLE_APPOINTMENT": num_gf == 1,
|
||||
"IS_MULTI_APPOINTMENT": num_gf > 1,
|
||||
"IS_FIRST_APPOINTMENT": True,
|
||||
"IS_PLURAL_GF": num_gf > 1,
|
||||
"GF_NAME": gf_list[0].get("name", "") if gf_list else "",
|
||||
"GF_BIRTHDATE": gf_list[0].get("geburtsdatum", "") if gf_list else "",
|
||||
"GF_BIRTHDATE_PLACE": "[Geburtsort]",
|
||||
"GF_ADDRESS": gf_list[0].get("adresse", "") if gf_list else "",
|
||||
"GF_VERTRETUNG": "einzelvertretungsberechtigt" if num_gf == 1 else "gemeinsam mit einem weiteren Geschäftsführer vertretungsberechtigt",
|
||||
"GF_PARA_181_RELEASE": True,
|
||||
"GF_LISTE_MIT_VERTRETUNGSART": "\n".join(
|
||||
f"- {g.get('name', '')}, geb. {g.get('geburtsdatum', '')}, wohnhaft in {g.get('adresse', '')}, "
|
||||
f"vertretungsberechtigt {'allein' if num_gf == 1 else 'gemeinsam'}; § 181 BGB-Befreiung erteilt"
|
||||
for g in gf_list
|
||||
),
|
||||
"HAS_RESSORT_ZUWEISUNG": True,
|
||||
"HAS_DIENSTVERTRAG": True,
|
||||
"SIGNATURES_GESELLSCHAFTER": "\n".join(
|
||||
f"___________________________\n{g.get('name', '')}"
|
||||
for g in gesellschafter
|
||||
),
|
||||
"HAS_VERSICHERUNG_BESTELLT": True,
|
||||
"BELEHRUNG_DURCH": "den beurkundenden Notar",
|
||||
"HAS_DELAYED_START": False,
|
||||
# HRB-Anmeldung
|
||||
"VERTRETUNGSREGELUNG": (
|
||||
"Die Gesellschaft wird durch einen Geschäftsführer allein vertreten."
|
||||
if num_gf == 1 else
|
||||
"Die Gesellschaft wird durch zwei Geschäftsführer gemeinsam vertreten. "
|
||||
"Bei nur einem bestellten Geschäftsführer Einzelvertretung."
|
||||
),
|
||||
"GF_SIGNATURES_BEGLAUBIGUNG": "\n".join(
|
||||
f"___________________________\n{g.get('name', '')}, Geschäftsführer"
|
||||
for g in gf_list
|
||||
),
|
||||
"HAS_EMPFANGSBERECHTIGTER": False,
|
||||
"EMPFANGSBERECHTIGTER_NAME": "",
|
||||
"EMPFANGSBERECHTIGTER_ADDRESS": "",
|
||||
"HAS_GENEHMIGUNG": False,
|
||||
"GENEHMIGUNG_DETAILS": "",
|
||||
"NEXT_DOC_NUMBER": 6,
|
||||
# GF-Dienstvertrag (Defaults für alle GFs, einzelne Felder per Contract überschreiben)
|
||||
"COMPANY_REPRESENTATIVE": "die Gesellschafterversammlung",
|
||||
"APPOINTMENT_DATE": notar.get("notarial_date", "[Datum]"),
|
||||
"GF_INTERNAL_TITLE": gf_list[0].get("internal_role", "Geschäftsführer") if gf_list else "Geschäftsführer",
|
||||
"CONTRACT_START_DATE": notar.get("notarial_date", "[Datum]"),
|
||||
"HAS_PARA_181_RELEASE": True,
|
||||
"PARA_181_RELEASE_DATE": notar.get("notarial_date", "[Datum]"),
|
||||
"HAS_BONUS": False, "HAS_TANTIEME": False, "HAS_COMPANY_CAR": False, "HAS_BAV": False,
|
||||
"HAS_HINTERBLIEBENEN_VERSORGUNG": False, "HAS_KOPPLUNG_BESTELLUNG_VERTRAG": False,
|
||||
"HAS_NONCOMPETE_COMPENSATION": False,
|
||||
"POST_CONTRACT_NONCOMPETE_MONTHS": 12,
|
||||
"GROSS_ANNUAL_SALARY_EUR": "84.000",
|
||||
"COMPANY_CAR_CLASS": "",
|
||||
"BAV_EMPLOYER_PCT": 0,
|
||||
"SV_STATUS": "sozialversicherungsfrei",
|
||||
"VACATION_DAYS": 30,
|
||||
"KRANKHEIT_FORTZAHLUNG_WOCHEN": 6,
|
||||
"AU_BESCHEINIGUNG_TAG": 4,
|
||||
"HINTERBLIEBENEN_VERSORGUNG_MONATE": 6,
|
||||
"DO_INSURANCE_EUR": "5.000.000",
|
||||
"KUENDIGUNGSFRIST_GESELLSCHAFT_MONATE": 6,
|
||||
"KUENDIGUNGSFRIST_GF_MONATE": 3,
|
||||
"ANNEX_LIST": "- Anlage 1: Bonusplan (sofern vereinbart)\n- Anlage 2: D&O-Versicherungspolice",
|
||||
# IP-Assignment
|
||||
"ASSIGNOR_NAME": gf_list[0].get("name", "") if gf_list else "",
|
||||
"ASSIGNOR_BIRTHDATE": gf_list[0].get("geburtsdatum", "") if gf_list else "",
|
||||
"ASSIGNOR_ADDRESS": gf_list[0].get("adresse", "") if gf_list else "",
|
||||
"ASSIGNOR_ROLE": gf_list[0].get("internal_role", "Gründer und Geschäftsführer") if gf_list else "Gründer",
|
||||
"AGREEMENT_DATE": notar.get("notarial_date", "[Datum]"),
|
||||
"HAS_BAR_VERGUETUNG": False,
|
||||
"HAS_SHARES_AS_COMPENSATION": True,
|
||||
"HAS_NO_VERGUETUNG": False,
|
||||
"IP_VERGUETUNG_EUR": 0,
|
||||
"ZAHLUNGSFRIST_TAGE": 30,
|
||||
"GUARANTEE_VERJAEHRUNG_JAHRE": 3,
|
||||
"HAS_ACADEMIC_BACKGROUND": has_academic,
|
||||
"SIGNATURE_LOCATION": basics.get("company_seat", "[Sitz]"),
|
||||
"IP_LIST_DETAILS": "- Software-Architektur und Quellcode (bestehend zum Zeitpunkt der Gründung)\n- Konzepte, Designs, Datenbankstrukturen\n- Marken, Logos, Domainnamen",
|
||||
"IP_EXCEPTIONS_DETAILS": "Keine Ausnahmen bekannt.",
|
||||
}
|
||||
# Ressort-Variablen aus GF-Liste ableiten (1 Ressort pro GF)
|
||||
ressort_defaults = [
|
||||
("Operative & Kommerzielle Leitung", "Finanzen, HR, Vertrieb, Business Development, operative Steuerung"),
|
||||
("Technik & Engineering", "Softwareentwicklung, Architektur, Infrastruktur, Sicherheit, technische Roadmap"),
|
||||
("Research & Partnerships", "Forschungskooperationen, Drittmittel, wissenschaftliche Methodik"),
|
||||
]
|
||||
for idx, gf in enumerate(gf_list[:3]):
|
||||
n = idx + 1
|
||||
default_name, default_aufgaben = ressort_defaults[idx] if idx < 3 else ("Allgemeine Leitung", "Sonstige Aufgaben")
|
||||
ctx[f"RESSORT_{n}_NAME"] = gf.get("internal_role") or default_name
|
||||
ctx[f"RESSORT_{n}_GF"] = gf.get("name", "")
|
||||
ctx[f"RESSORT_{n}_AUFGABEN"] = f"- {default_aufgaben}"
|
||||
ctx["HAS_RESSORT_3"] = len(gf_list) >= 3
|
||||
return ctx
|
||||
@@ -0,0 +1,325 @@
|
||||
"""
|
||||
P82 — GF-1-Pager (Geschaeftsfuehrer-Kurzfassung).
|
||||
|
||||
Eine kompakte 5-7-Bullet-Zusammenfassung ganz oben in der Mail. GF liest
|
||||
sonst die 124k-Char-Komplettpruefung nicht. Ton sachlich, keine Panik
|
||||
(Memory: feedback_breakpilot_tonalitaet).
|
||||
|
||||
Bildet ab:
|
||||
- Compliance-Score + Vergleichswert (wenn Vorlauf vorhanden)
|
||||
- Top-3 priorisierte Themen (HIGH oder kritisches MEDIUM)
|
||||
- Aufwand-Schaetzung (4-8 Wochen) + Wer-macht-was (DSB / IT / Marketing)
|
||||
- Realer Risiko-Hinweis (ohne 4%-Weltumsatz-Drohung)
|
||||
|
||||
Wird VOR Critical-Findings und Exec-Summary gerendert.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_AREA_LABEL = {
|
||||
"banner": "Cookie-Banner",
|
||||
"cookie": "Cookie-Richtlinie",
|
||||
"dse": "Datenschutzerklaerung",
|
||||
"impressum": "Impressum",
|
||||
"agb": "AGB",
|
||||
"library_mismatch": "Cookie-Klassifikation",
|
||||
"vendor": "Vendor-Liste / VVT",
|
||||
"consent": "Einwilligung",
|
||||
"rights": "Betroffenenrechte",
|
||||
}
|
||||
|
||||
|
||||
def _normalize_finding(item: dict) -> dict:
|
||||
sev = str(item.get("severity") or item.get("level") or "").upper()
|
||||
if sev not in ("HIGH", "MEDIUM", "LOW"):
|
||||
sev = "MEDIUM"
|
||||
label = (item.get("label") or item.get("title")
|
||||
or item.get("check") or item.get("name") or "").strip()
|
||||
if not label:
|
||||
return {}
|
||||
area = (item.get("area") or item.get("doc_type") or item.get("category") or "").lower()
|
||||
return {
|
||||
"severity": sev,
|
||||
"label": label[:200],
|
||||
"area": _AREA_LABEL.get(area, area.replace("_", " ").title() or "Allgemein"),
|
||||
"owner": item.get("owner") or _guess_owner(label, area),
|
||||
}
|
||||
|
||||
|
||||
def _guess_owner(label: str, area: str) -> str:
|
||||
"""Heuristik: wer ist der wahrscheinliche Ansprechpartner."""
|
||||
lab = label.lower()
|
||||
if any(w in lab for w in ("banner", "cookie", "consent",
|
||||
"einwilligung", "tracking")):
|
||||
return "DSB + Marketing/CMP-Admin"
|
||||
if any(w in lab for w in ("vendor", "avv", "auftragsverarbeitung",
|
||||
"drittland", "schrems")):
|
||||
return "DSB + Einkauf/Legal"
|
||||
if any(w in lab for w in ("impressum", "agb", "widerruf", "kontakt")):
|
||||
return "Legal + Web-Team"
|
||||
if any(w in lab for w in ("dsfa", "dsr", "loeschfrist", "art. 15",
|
||||
"auskunft", "betroffenenrecht")):
|
||||
return "DSB"
|
||||
if any(w in lab for w in ("tom", "verschluesselung", "backup",
|
||||
"incident", "logging")):
|
||||
return "IT-Security + DSB"
|
||||
if area in ("banner", "cookie"):
|
||||
return "DSB + Marketing"
|
||||
return "DSB"
|
||||
|
||||
|
||||
def _collect_top_findings(
|
||||
banner_result: dict | None,
|
||||
scorecard: dict | None,
|
||||
library_mismatch_findings: list[dict] | None,
|
||||
audit_quality_findings: list[dict] | None = None,
|
||||
limit: int = 5,
|
||||
) -> list[dict]:
|
||||
out: list[dict] = []
|
||||
|
||||
# 0) Audit-Quality-Vorbehalte (Banner-Detect-Fail, Vendor-thin) zuerst —
|
||||
# die sind WICHTIGER als alle anderen Findings weil sie den Audit
|
||||
# selbst infrage stellen.
|
||||
for aq in (audit_quality_findings or []):
|
||||
if isinstance(aq, dict):
|
||||
out.append({
|
||||
"severity": aq.get("severity", "HIGH"),
|
||||
"label": aq.get("label", "Audit-Vorbehalt"),
|
||||
"area": aq.get("area", "Audit-Qualitaet"),
|
||||
"owner": aq.get("owner", "DSB + Web-Team"),
|
||||
})
|
||||
|
||||
# 1) Banner deep-check findings (HIGH zuerst)
|
||||
if banner_result:
|
||||
for ph in (banner_result.get("phases") or {}).values():
|
||||
if not isinstance(ph, dict):
|
||||
continue
|
||||
for f in (ph.get("findings") or []):
|
||||
if not isinstance(f, dict):
|
||||
continue
|
||||
n = _normalize_finding({**f, "area": "banner"})
|
||||
if n:
|
||||
out.append(n)
|
||||
|
||||
# 2) Library-Mismatch HIGH (Marketing-Cookies als essential deklariert)
|
||||
for mm in (library_mismatch_findings or []):
|
||||
if isinstance(mm, dict) and mm.get("severity") == "HIGH":
|
||||
out.append({
|
||||
"severity": "HIGH",
|
||||
"label": f'Cookie "{mm.get("cookie","?")}" als '
|
||||
f'{mm.get("declared_category","?")} deklariert, '
|
||||
f'tatsaechlicher Zweck typischerweise '
|
||||
f'{mm.get("library_category","?")}',
|
||||
"area": _AREA_LABEL["library_mismatch"],
|
||||
"owner": "DSB + Marketing/CMP-Admin",
|
||||
})
|
||||
|
||||
# 3) Scorecard FAILs (MC-Audit)
|
||||
if scorecard:
|
||||
for entry in (scorecard.get("failed") or scorecard.get("items") or []):
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
n = _normalize_finding(entry)
|
||||
if n and n["severity"] == "HIGH":
|
||||
out.append(n)
|
||||
|
||||
# Sort: HIGH first, then MEDIUM, stable order. Dedup by label.
|
||||
seen: set[str] = set()
|
||||
order = {"HIGH": 0, "MEDIUM": 1, "LOW": 2}
|
||||
out.sort(key=lambda f: order.get(f["severity"], 3))
|
||||
dedup: list[dict] = []
|
||||
for f in out:
|
||||
key = f["label"].lower()[:80]
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
dedup.append(f)
|
||||
if len(dedup) >= limit:
|
||||
break
|
||||
return dedup
|
||||
|
||||
|
||||
def _score_color(score: float | int | None) -> str:
|
||||
if score is None:
|
||||
return "#64748b"
|
||||
try:
|
||||
s = float(score)
|
||||
except (TypeError, ValueError):
|
||||
return "#64748b"
|
||||
if s >= 80:
|
||||
return "#16a34a"
|
||||
if s >= 60:
|
||||
return "#ca8a04"
|
||||
return "#dc2626"
|
||||
|
||||
|
||||
def _delta_html(curr: float | None, prev: float | None) -> str:
|
||||
if curr is None or prev is None:
|
||||
return ""
|
||||
try:
|
||||
d = float(curr) - float(prev)
|
||||
except (TypeError, ValueError):
|
||||
return ""
|
||||
if abs(d) < 0.5:
|
||||
return (
|
||||
' <span style="color:#64748b;font-size:11px">'
|
||||
'(unveraendert ggue. letztem Lauf)</span>'
|
||||
)
|
||||
arrow = "↑" if d > 0 else "↓"
|
||||
color = "#16a34a" if d > 0 else "#dc2626"
|
||||
return (
|
||||
f' <span style="color:{color};font-size:11px">'
|
||||
f'{arrow} {abs(d):.1f} Punkte ggue. letztem Lauf</span>'
|
||||
)
|
||||
|
||||
|
||||
def build_gf_one_pager_html(
|
||||
site_name: str,
|
||||
scorecard: dict | None = None,
|
||||
previous_scorecard: dict | None = None,
|
||||
banner_result: dict | None = None,
|
||||
library_mismatch_findings: list[dict] | None = None,
|
||||
scan_context: dict | None = None,
|
||||
audit_quality_findings: list[dict] | None = None,
|
||||
) -> str:
|
||||
"""5-7-Bullet-Zusammenfassung. Leere Top-Findings: nur Status-Bullet."""
|
||||
score = None
|
||||
if scorecard:
|
||||
score = scorecard.get("compliance_score") or scorecard.get("score")
|
||||
prev_score = None
|
||||
if previous_scorecard:
|
||||
prev_score = (previous_scorecard.get("compliance_score")
|
||||
or previous_scorecard.get("score"))
|
||||
|
||||
top = _collect_top_findings(
|
||||
banner_result=banner_result,
|
||||
scorecard=scorecard,
|
||||
library_mismatch_findings=library_mismatch_findings,
|
||||
audit_quality_findings=audit_quality_findings,
|
||||
limit=6,
|
||||
)
|
||||
audit_warn = bool(audit_quality_findings)
|
||||
|
||||
n_high = sum(1 for f in top if f["severity"] == "HIGH")
|
||||
n_med = sum(1 for f in top if f["severity"] == "MEDIUM")
|
||||
|
||||
if score is not None:
|
||||
score_str = f'{float(score):.0f}/100'
|
||||
else:
|
||||
score_str = "—"
|
||||
score_color = _score_color(score)
|
||||
|
||||
ctx_line = ""
|
||||
if scan_context:
|
||||
bits: list[str] = []
|
||||
if scan_context.get("industry"):
|
||||
bits.append(scan_context["industry"])
|
||||
if scan_context.get("business_model"):
|
||||
bits.append(scan_context["business_model"].upper())
|
||||
if scan_context.get("employee_count"):
|
||||
bits.append(f'{scan_context["employee_count"]} MA')
|
||||
if bits:
|
||||
ctx_line = (
|
||||
'<div style="font-size:11px;color:#64748b;margin-bottom:6px">'
|
||||
f'Klassifizierung: {" · ".join(bits)}'
|
||||
'</div>'
|
||||
)
|
||||
|
||||
bullets: list[str] = []
|
||||
sev_pill = {
|
||||
"HIGH": '<span style="background:#fee2e2;color:#991b1b;'
|
||||
'padding:1px 6px;border-radius:8px;font-size:10px;'
|
||||
'font-weight:600">HOCH</span>',
|
||||
"MEDIUM": '<span style="background:#fef3c7;color:#92400e;'
|
||||
'padding:1px 6px;border-radius:8px;font-size:10px;'
|
||||
'font-weight:600">MITTEL</span>',
|
||||
"LOW": '<span style="background:#dbeafe;color:#1e40af;'
|
||||
'padding:1px 6px;border-radius:8px;font-size:10px;'
|
||||
'font-weight:600">NIEDRIG</span>',
|
||||
}
|
||||
try:
|
||||
from compliance.services.finding_confidence import confidence_pill_html
|
||||
except Exception:
|
||||
def confidence_pill_html(_label: str) -> str:
|
||||
return ""
|
||||
|
||||
for f in top:
|
||||
bullets.append(
|
||||
f'<li style="margin-bottom:4px;font-size:12px;line-height:1.45">'
|
||||
f'{sev_pill.get(f["severity"], "")} <strong>{f["area"]}:</strong> '
|
||||
f'{f["label"]}'
|
||||
f'{confidence_pill_html(f["label"])} '
|
||||
f'<span style="color:#64748b">— typisch zustaendig: '
|
||||
f'{f["owner"]}</span></li>'
|
||||
)
|
||||
|
||||
if not bullets:
|
||||
if audit_warn:
|
||||
bullets.append(
|
||||
'<li style="margin-bottom:4px;font-size:12px;color:#991b1b">'
|
||||
'<strong>Audit selbst war unvollstaendig</strong> — siehe '
|
||||
'roten Audit-Vorbehalt-Block weiter unten. Eine pauschale '
|
||||
'"alles ok"-Aussage ist auf Basis dieser Datenlage nicht '
|
||||
'moeglich.</li>'
|
||||
)
|
||||
else:
|
||||
bullets.append(
|
||||
'<li style="margin-bottom:4px;font-size:12px;color:#475569">'
|
||||
'Keine kritischen Themen erkannt — der Audit-Lauf hat fuer '
|
||||
'die geprueften Dokumente keine HIGH-Findings produziert. '
|
||||
'Details im weiteren Verlauf der Mail.</li>'
|
||||
)
|
||||
|
||||
return (
|
||||
'<div style="font-family:-apple-system,BlinkMacSystemFont,sans-serif;'
|
||||
'max-width:760px;margin:0 auto 16px;padding:18px 20px;'
|
||||
'background:#f8fafc;border:1px solid #cbd5e1;border-radius:8px">'
|
||||
'<div style="font-size:11px;color:#475569;text-transform:uppercase;'
|
||||
'letter-spacing:1.4px;margin-bottom:4px;font-weight:600">'
|
||||
f'Kurzfassung fuer die Geschaeftsfuehrung — {site_name or "—"}'
|
||||
'</div>'
|
||||
+ ctx_line +
|
||||
'<div style="display:flex;align-items:baseline;gap:14px;'
|
||||
'margin:8px 0 14px;flex-wrap:wrap">'
|
||||
f'<div style="font-size:28px;font-weight:700;color:{score_color}">'
|
||||
f'{score_str}</div>'
|
||||
'<div style="font-size:11px;color:#64748b">'
|
||||
f'Compliance-Score{_delta_html(score, prev_score)}</div>'
|
||||
f'<div style="margin-left:auto;font-size:11px;color:#475569">'
|
||||
f'<strong>{n_high}</strong> hoch · '
|
||||
f'<strong>{n_med}</strong> mittel'
|
||||
'</div></div>'
|
||||
'<div style="font-size:11px;color:#475569;margin-bottom:6px;'
|
||||
'font-weight:600;text-transform:uppercase;letter-spacing:1px">'
|
||||
'Was kurzfristig angegangen werden sollte'
|
||||
'</div>'
|
||||
'<ul style="margin:0 0 12px 18px;padding:0">'
|
||||
+ "".join(bullets) +
|
||||
'</ul>'
|
||||
'<div style="font-size:11px;color:#475569;line-height:1.5;'
|
||||
'padding:8px 10px;background:#fff;border:1px solid #e2e8f0;'
|
||||
'border-radius:4px">'
|
||||
+ (
|
||||
'<strong style="color:#991b1b">Wichtig — Audit unvollstaendig:'
|
||||
'</strong> An mindestens einer Stelle ist unser Crawler an '
|
||||
'Grenzen gestossen (siehe roter Audit-Vorbehalt-Block weiter '
|
||||
'unten). Diese Bereiche sollten manuell oder im Copy-Paste-Modus '
|
||||
'nachgereicht werden, bevor eine belastbare Compliance-Aussage '
|
||||
'getroffen wird.'
|
||||
if audit_warn else
|
||||
'<strong>Realistische Einordnung:</strong> Wir analysieren das '
|
||||
'Aussenbild Ihrer Website automatisiert — einzelne Findings '
|
||||
'koennen durch interne Dokumentation bereits abgedeckt sein. '
|
||||
'Empfohlenes Vorgehen: priorisierte Punkte mit DSB / Marketing / '
|
||||
'IT in einem Termin durchsprechen (4-8 Wochen sind ein '
|
||||
'realistischer Zeitrahmen fuer die Umsetzung). Eine pauschale '
|
||||
'Bussgeld-Erwartung leiten wir aus diesem Audit nicht ab.'
|
||||
)
|
||||
+ '</div>'
|
||||
'</div>'
|
||||
)
|
||||
@@ -0,0 +1,117 @@
|
||||
"""
|
||||
P86 — Branchen-Benchmark.
|
||||
|
||||
Vergleicht den eigenen Compliance-Score mit dem Branchen-Median aus
|
||||
allen bisherigen Snapshots derselben industry (P79 scan_context).
|
||||
Liefert: "Sie 42% — Automotive-Median 58% (Stichprobe: 12 Sites)".
|
||||
|
||||
Wird in der Mail-Composition direkt unter dem Score im GF-1-Pager
|
||||
gerendert. Mindest-Stichprobe = 3 vergleichbare Snapshots, sonst skip.
|
||||
|
||||
Heuristik fuer Score-Extraktion aus banner_result:
|
||||
- banner_result.completeness_pct ODER
|
||||
- banner_result.correctness_pct ODER
|
||||
- 100 - len(banner_checks.violations) * 5 als Fallback.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_MIN_SAMPLE = 3
|
||||
|
||||
|
||||
def _extract_score(banner_result: dict | None) -> float | None:
|
||||
if not isinstance(banner_result, dict):
|
||||
return None
|
||||
for key in ("compliance_score", "completeness_pct", "correctness_pct"):
|
||||
v = banner_result.get(key)
|
||||
if isinstance(v, (int, float)):
|
||||
return float(v)
|
||||
bc = banner_result.get("banner_checks") or {}
|
||||
if isinstance(bc, dict):
|
||||
viols = bc.get("violations") or []
|
||||
if isinstance(viols, list):
|
||||
return max(0.0, 100.0 - len(viols) * 5)
|
||||
return None
|
||||
|
||||
|
||||
def compute_benchmark(
|
||||
db: Session,
|
||||
industry: str,
|
||||
current_score: float | None,
|
||||
current_check_id: str,
|
||||
) -> dict | None:
|
||||
if not industry or current_score is None:
|
||||
return None
|
||||
# Snapshots mit gleicher industry in scan_context.
|
||||
rows = db.execute(text(
|
||||
"""
|
||||
SELECT banner_result FROM compliance.compliance_check_snapshots
|
||||
WHERE check_id != :cid
|
||||
AND scan_context IS NOT NULL
|
||||
AND scan_context->>'industry' = :ind
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 50
|
||||
"""
|
||||
), {"cid": current_check_id, "ind": industry}).fetchall()
|
||||
scores: list[float] = []
|
||||
for r in rows:
|
||||
br = r[0]
|
||||
if isinstance(br, str):
|
||||
try:
|
||||
br = json.loads(br)
|
||||
except Exception:
|
||||
continue
|
||||
s = _extract_score(br)
|
||||
if s is not None:
|
||||
scores.append(s)
|
||||
if len(scores) < _MIN_SAMPLE:
|
||||
return None
|
||||
scores.sort()
|
||||
n = len(scores)
|
||||
median = scores[n // 2] if n % 2 else (scores[n // 2 - 1] + scores[n // 2]) / 2
|
||||
pct_lower = round(sum(1 for s in scores if s < current_score) / n * 100)
|
||||
return {
|
||||
"industry": industry,
|
||||
"current": round(current_score, 1),
|
||||
"median": round(median, 1),
|
||||
"sample_size": n,
|
||||
"percentile": pct_lower, # 80 = besser als 80% der Branche
|
||||
}
|
||||
|
||||
|
||||
def build_benchmark_html(bench: dict) -> str:
|
||||
if not bench:
|
||||
return ""
|
||||
delta = bench["current"] - bench["median"]
|
||||
if delta >= 5:
|
||||
color = "#16a34a"
|
||||
verdict = "ueber dem Branchen-Median"
|
||||
elif delta <= -5:
|
||||
color = "#dc2626"
|
||||
verdict = "unter dem Branchen-Median"
|
||||
else:
|
||||
color = "#ca8a04"
|
||||
verdict = "etwa auf Branchen-Median"
|
||||
return (
|
||||
'<div style="font-family:-apple-system,BlinkMacSystemFont,sans-serif;'
|
||||
'max-width:760px;margin:0 auto 12px;padding:8px 14px;'
|
||||
'background:#f0f9ff;border:1px solid #bfdbfe;border-radius:6px;'
|
||||
'font-size:11px;color:#1e293b">'
|
||||
f'<strong>Branchen-Vergleich ({bench["industry"]}):</strong> '
|
||||
f'Ihr Score <strong>{bench["current"]:.1f}</strong> '
|
||||
f'<span style="color:{color}">({verdict}, '
|
||||
f'Median {bench["median"]:.1f})</span>. '
|
||||
f'<span style="color:#64748b">Sie sind besser als '
|
||||
f'{bench["percentile"]}% der bisher von uns gepruften '
|
||||
f'{bench["sample_size"]} Sites in dieser Branche.</span>'
|
||||
'</div>'
|
||||
)
|
||||
@@ -0,0 +1,116 @@
|
||||
"""
|
||||
P71 — JC-vs-AVV Entscheidungsbaum.
|
||||
|
||||
Hilft dem Nutzer zu bestimmen, ob ein bestimmtes Verarbeitungsverhaeltnis
|
||||
gemeinsame Verantwortlichkeit (Art. 26 DSGVO) oder Auftragsverarbeitung
|
||||
(Art. 28 DSGVO) ist. EDPB 7/2020 ist die Grundlage.
|
||||
|
||||
Wird gerendert als kleiner Block am Ende der Mail, wenn im DSE-Text
|
||||
Konstrukte vorkommen die ambivalent sind (z.B. 'gemeinsame Auswertung
|
||||
mit Schwesterunternehmen', 'gemeinsame Plattform-Nutzung'). Liefert
|
||||
3-4 Leitfragen + jeweilige Empfehlung.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_JC_SIGNALS = (
|
||||
"schwesterunternehmen", "konzernschwester", "gemeinsame plattform",
|
||||
"gemeinsame auswertung", "gemeinsame studie", "joint venture",
|
||||
"konzernweite analyse", "gemeinsame zwecke", "gemeinsame ziele",
|
||||
"konzernweit", "gemeinsamer kunde", "gemeinsamer datenpool",
|
||||
)
|
||||
|
||||
_AVV_SIGNALS = (
|
||||
"auftragsverarbeiter", "auftragsverarbeitung", "weisungsgebunden",
|
||||
"im auftrag von", "im namen des verantwortlichen",
|
||||
"art. 28 dsgvo", "art 28 dsgvo", "dpa (data processing agreement",
|
||||
)
|
||||
|
||||
_QUESTIONS = [
|
||||
{
|
||||
"q": "Bestimmen beide Seiten gemeinsam Zweck UND Mittel der Verarbeitung?",
|
||||
"yes": "JC (Art. 26)",
|
||||
"no": "AVV-Indikator",
|
||||
"explain": "EDPB 7/2020 Rn. 51-65: beidseitige Zweckbestimmung ist "
|
||||
"das Hauptmerkmal der gemeinsamen Verantwortlichkeit.",
|
||||
},
|
||||
{
|
||||
"q": "Verfolgen die Parteien eigene, getrennte Zwecke (z.B. eigene "
|
||||
"Kundenbeziehung) oder einen gemeinsamen Zweck?",
|
||||
"yes": "Wenn getrennt: AVV (oder zwei getrennte Verantwortliche)",
|
||||
"no": "Wenn gemeinsam: JC (Art. 26)",
|
||||
"explain": "EuGH C-25/17 Zeugen Jehovas: getrennte Zwecke "
|
||||
"schliessen JC aus.",
|
||||
},
|
||||
{
|
||||
"q": "Existiert eine schriftliche Weisungs-Hierarchie und Pflicht "
|
||||
"zur Loeschung am Vertragsende?",
|
||||
"yes": "AVV (Art. 28 Pflichten erfuellt)",
|
||||
"no": "Pruefen ob JC vorliegt + Art. 26-Vereinbarung noetig",
|
||||
"explain": "Art. 28 (3)(g) DSGVO + EDPB 7/2020 Rn. 88.",
|
||||
},
|
||||
{
|
||||
"q": "Haben Betroffene gegenueber beiden Stellen vollstaendige "
|
||||
"Rechte (Art. 15-22)?",
|
||||
"yes": "JC — Art. 26 (3) verlangt einheitliche Anlaufstelle",
|
||||
"no": "AVV — Auftragsverarbeiter weist Rechtsausuebung an "
|
||||
"Verantwortlichen zurueck",
|
||||
"explain": "Art. 26 (3) DSGVO macht beide Stellen als gemeinsame "
|
||||
"Anlaufstelle ansprechbar.",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def detect_ambiguous_jc_avv(dse_text: str | None) -> bool:
|
||||
"""Heuristik: liegen sowohl JC- als auch AVV-Signale im DSE? Dann
|
||||
ist die Konstellation typischerweise unklar und der Entscheidungsbaum
|
||||
hilft."""
|
||||
if not dse_text:
|
||||
return False
|
||||
t = dse_text.lower()
|
||||
has_jc = any(s in t for s in _JC_SIGNALS)
|
||||
has_avv = any(s in t for s in _AVV_SIGNALS)
|
||||
return has_jc and has_avv
|
||||
|
||||
|
||||
def build_jc_avv_decision_html(dse_text: str | None) -> str:
|
||||
if not detect_ambiguous_jc_avv(dse_text):
|
||||
return ""
|
||||
items = []
|
||||
for i, q in enumerate(_QUESTIONS, 1):
|
||||
items.append(
|
||||
f'<li style="margin-bottom:8px;font-size:11px;line-height:1.5">'
|
||||
f'<strong>{i}. {q["q"]}</strong><br>'
|
||||
f'<span style="color:#16a34a">Ja: </span>{q["yes"]} | '
|
||||
f'<span style="color:#dc2626">Nein: </span>{q["no"]}<br>'
|
||||
f'<span style="color:#64748b;font-size:10px;font-style:italic">'
|
||||
f'{q["explain"]}</span>'
|
||||
f'</li>'
|
||||
)
|
||||
return (
|
||||
'<div style="font-family:-apple-system,BlinkMacSystemFont,sans-serif;'
|
||||
'max-width:760px;margin:0 auto 16px;padding:12px 16px;'
|
||||
'background:#f1f5f9;border:1px solid #cbd5e1;border-radius:6px">'
|
||||
'<div style="font-size:11px;color:#475569;text-transform:uppercase;'
|
||||
'letter-spacing:1.2px;margin-bottom:4px;font-weight:600">'
|
||||
'JC vs AVV — Entscheidungshilfe</div>'
|
||||
'<h3 style="margin:0 0 6px;font-size:14px;color:#1e293b">'
|
||||
'Im DSE-Text gibt es sowohl gemeinsame-Verantwortlichkeits- als '
|
||||
'auch Auftragsverarbeitungs-Hinweise</h3>'
|
||||
'<p style="margin:0 0 10px;font-size:11px;color:#475569;line-height:1.5">'
|
||||
'Pruefen Sie mit dem DSB die folgenden 4 Leitfragen aus EDPB 7/2020. '
|
||||
'Das Ergebnis bestimmt ob eine Art. 26-Vereinbarung (JC) oder ein '
|
||||
'Art. 28-AVV vorliegen muss.'
|
||||
'</p>'
|
||||
'<ol style="margin:0 0 0 18px;padding:0">'
|
||||
+ "".join(items) +
|
||||
'</ol>'
|
||||
'<p style="margin:8px 0 0;font-size:10px;color:#94a3b8;'
|
||||
'font-style:italic">Quelle: EDPB Guidelines 7/2020 (Controller/Processor) '
|
||||
'+ EuGH C-25/17, C-40/17.</p>'
|
||||
'</div>'
|
||||
)
|
||||
@@ -0,0 +1,86 @@
|
||||
"""
|
||||
P88 — PDF-Export der Audit-Mail.
|
||||
|
||||
Rendert dieselbe HTML wie die Mail via WeasyPrint zu PDF. Endpoint:
|
||||
GET /api/compliance/agent/snapshots/{snapshot_id}/pdf → application/pdf
|
||||
|
||||
Verwendung:
|
||||
- GF/Lawyer-Uebergabe (kein E-Mail-Programm noetig)
|
||||
- Archivierung
|
||||
- Mandatsausgabe an externen Berater
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from compliance.services.check_replay import replay_from_snapshot
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_PDF_WRAPPER_HEAD = """<!DOCTYPE html>
|
||||
<html lang="de"><head><meta charset="utf-8"><title>{title}</title>
|
||||
<style>
|
||||
@page {{ size: A4; margin: 18mm 14mm 18mm 14mm;
|
||||
@bottom-right {{ content: "Seite " counter(page) " / " counter(pages);
|
||||
color: #94a3b8; font-size: 9pt; }} }}
|
||||
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI',
|
||||
Roboto, sans-serif; font-size: 11pt;
|
||||
color: #1e293b; max-width: 760px; margin: 0 auto;
|
||||
line-height: 1.45; }}
|
||||
h1, h2, h3 {{ page-break-after: avoid; }}
|
||||
table {{ page-break-inside: auto; }}
|
||||
tr {{ page-break-inside: avoid; }}
|
||||
.header {{ border-bottom: 2px solid #0f172a; padding-bottom: 8mm;
|
||||
margin-bottom: 8mm; }}
|
||||
.header h1 {{ margin: 0; font-size: 16pt; color: #0f172a; }}
|
||||
.header .meta {{ font-size: 9pt; color: #64748b; margin-top: 2mm; }}
|
||||
</style></head><body>
|
||||
<div class="header">
|
||||
<h1>BreakPilot Compliance-Audit — {site}</h1>
|
||||
<div class="meta">PDF-Export erstellt am {ts} · Snapshot {snap_short}</div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
def render_snapshot_as_pdf(
|
||||
db: Session,
|
||||
snapshot_id: str,
|
||||
) -> bytes | None:
|
||||
"""Returns PDF bytes or None on failure."""
|
||||
try:
|
||||
from weasyprint import HTML # noqa: WPS433 — Optional dep
|
||||
except Exception as e:
|
||||
logger.error("WeasyPrint nicht verfuegbar: %s", e)
|
||||
return None
|
||||
|
||||
res = replay_from_snapshot(db, snapshot_id, recipient=None, dry_run=True)
|
||||
if not res or res.get("error"):
|
||||
logger.warning("PDF-Export: Snapshot %s nicht gefunden", snapshot_id)
|
||||
return None
|
||||
|
||||
# The replay returns html via "preview" (truncated) — fetch the full
|
||||
# render by injecting site_label into a wrapper.
|
||||
full_html = _build_full_html(res, snapshot_id)
|
||||
try:
|
||||
pdf_bytes = HTML(string=full_html).write_pdf()
|
||||
return pdf_bytes
|
||||
except Exception as e:
|
||||
logger.exception("WeasyPrint PDF render failed: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
def _build_full_html(replay_result: dict, snapshot_id: str) -> str:
|
||||
"""Wraps the replay's full_html in the PDF-print wrapper."""
|
||||
full = replay_result.get("full_html") or replay_result.get("preview") or ""
|
||||
site = replay_result.get("site_domain") or "—"
|
||||
snap_short = snapshot_id[:8]
|
||||
ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
||||
header = _PDF_WRAPPER_HEAD.format(
|
||||
title=f"BreakPilot Audit — {site}",
|
||||
site=site, snap_short=snap_short, ts=ts,
|
||||
)
|
||||
return header + full + "</body></html>"
|
||||
@@ -0,0 +1,257 @@
|
||||
"""
|
||||
P73 — MC-Solution-Generator.
|
||||
|
||||
Generiert pro Fail-MC eine konkrete Einfuege-Empfehlung mit Anchor:
|
||||
"Bitte ergaenzen Sie nach Abschnitt 'Kontaktdaten DSB' folgenden
|
||||
Absatz: ...". LLM-Cascade Qwen (lokal) -> OVH 120B.
|
||||
|
||||
Cache: in-process LRU per (mc_id, doc_md5) damit Re-Runs derselben
|
||||
Site denselben Vorschlag liefern. Volle DB-Cache kommt spaeter (P31).
|
||||
|
||||
Integration: wird im build_critical_findings_html / mc-detail-rendering
|
||||
unter jedem HIGH-Fail als eingeklappbarer Block angezeigt.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from functools import lru_cache
|
||||
from typing import Iterable
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_SYSTEM_PROMPT = (
|
||||
"Du bist Datenschutz-Redakteur. Du formulierst kurze, einfueg-bereite "
|
||||
"Absaetze fuer Datenschutz-Dokumente — sachlich, in deutscher "
|
||||
"Rechtssprache, ohne Marketing-Floskeln.\n\n"
|
||||
"Du bekommst:\n"
|
||||
"- den FAIL-MC (was geprueft wurde, warum es nicht erfuellt ist)\n"
|
||||
"- einen Auszug aus dem Ist-Dokument\n"
|
||||
"- den Dokument-Typ\n\n"
|
||||
"Du lieferst JSON:\n"
|
||||
'{\n'
|
||||
' "solution_text": "<3-6 Saetze Vorschlags-Absatz fuer das Dokument>",\n'
|
||||
' "anchor_hint": "<wo einfuegen, z.B. \\"nach Abschnitt Kontaktdaten\\">",\n'
|
||||
' "effort_min": "<gering|mittel|hoch>"\n'
|
||||
'}\n\n'
|
||||
"Regeln:\n"
|
||||
"- KEINE Normtexte 1:1 zitieren — eigene Formulierung + Norm-Referenz.\n"
|
||||
"- KEINE Annahmen ueber Konkretes (z.B. Firmennamen, Adressen) — "
|
||||
"Platzhalter [Ihr Firmenname] / [Ihre Adresse] verwenden.\n"
|
||||
"- Wenn schon eine schwache Variante im Dokument steht, anchor_hint "
|
||||
"auf 'ersetzen' setzen statt einfuegen.\n"
|
||||
"- Nur reines JSON, keine Prosa, keine Code-Fences."
|
||||
)
|
||||
|
||||
|
||||
def _doc_hash(doc_text: str) -> str:
|
||||
return hashlib.md5(doc_text.encode("utf-8")).hexdigest()[:12]
|
||||
|
||||
|
||||
_CACHE: dict[str, dict] = {}
|
||||
_CACHE_MAX = 500
|
||||
|
||||
|
||||
def _cache_get(key: str) -> dict | None:
|
||||
return _CACHE.get(key)
|
||||
|
||||
|
||||
def _cache_put(key: str, val: dict) -> None:
|
||||
if len(_CACHE) >= _CACHE_MAX:
|
||||
# Drop oldest 50 entries
|
||||
for k in list(_CACHE.keys())[:50]:
|
||||
_CACHE.pop(k, None)
|
||||
_CACHE[key] = val
|
||||
|
||||
|
||||
async def _call_ollama(prompt: str) -> str:
|
||||
base = os.getenv("OLLAMA_URL", "http://host.docker.internal:11434")
|
||||
model = os.getenv("MC_SOLUTION_MODEL",
|
||||
os.getenv("CMP_LLM_MODEL", "qwen3:30b-a3b"))
|
||||
payload = {
|
||||
"model": model, "stream": False, "format": "json",
|
||||
"messages": [
|
||||
{"role": "system", "content": _SYSTEM_PROMPT},
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
"options": {"temperature": 0.1, "num_predict": 600},
|
||||
}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=90.0) as client:
|
||||
resp = await client.post(f"{base.rstrip('/')}/api/chat", json=payload)
|
||||
resp.raise_for_status()
|
||||
return (resp.json().get("message") or {}).get("content", "")
|
||||
except Exception as e:
|
||||
logger.warning("Qwen MC-solution failed: %s", e)
|
||||
return ""
|
||||
|
||||
|
||||
async def _call_ovh(prompt: str) -> str:
|
||||
base = os.getenv("OVH_LLM_URL", "").strip()
|
||||
key = os.getenv("OVH_LLM_KEY", "").strip()
|
||||
model = os.getenv("OVH_LLM_MODEL", "").strip()
|
||||
if not base or not model:
|
||||
return ""
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if key:
|
||||
headers["Authorization"] = f"Bearer {key}"
|
||||
payload = {
|
||||
"model": model, "temperature": 0.1, "max_tokens": 600,
|
||||
"messages": [
|
||||
{"role": "system", "content": _SYSTEM_PROMPT},
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
"response_format": {"type": "json_object"},
|
||||
}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=45.0) as client:
|
||||
resp = await client.post(
|
||||
f"{base.rstrip('/')}/v1/chat/completions",
|
||||
json=payload, headers=headers,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
choice = (resp.json().get("choices") or [{}])[0]
|
||||
return (choice.get("message") or {}).get("content", "") or ""
|
||||
except Exception as e:
|
||||
logger.warning("OVH MC-solution failed: %s", e)
|
||||
return ""
|
||||
|
||||
|
||||
def _parse(content: str) -> dict | None:
|
||||
if not content:
|
||||
return None
|
||||
txt = content.strip()
|
||||
if txt.startswith("```"):
|
||||
txt = "\n".join(txt.split("\n")[1:-1])
|
||||
a, b = txt.find("{"), txt.rfind("}")
|
||||
if 0 <= a < b:
|
||||
try:
|
||||
obj = json.loads(txt[a:b + 1])
|
||||
if isinstance(obj, dict) and obj.get("solution_text"):
|
||||
return {
|
||||
"solution_text": str(obj["solution_text"])[:1200],
|
||||
"anchor_hint": str(obj.get("anchor_hint", ""))[:200],
|
||||
"effort_min": str(obj.get("effort_min", "mittel"))[:20],
|
||||
}
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
async def generate_solution(
|
||||
mc: dict,
|
||||
doc_text: str,
|
||||
doc_type: str,
|
||||
) -> dict | None:
|
||||
"""Generates a solution dict for a single FAIL-MC.
|
||||
|
||||
mc must contain: label, hint, severity. Returns
|
||||
{solution_text, anchor_hint, effort_min} or None.
|
||||
"""
|
||||
if not mc or not doc_text:
|
||||
return None
|
||||
mc_id = str(mc.get("id") or mc.get("label", ""))[:80]
|
||||
cache_key = f"{mc_id}:{doc_type}:{_doc_hash(doc_text)}"
|
||||
cached = _cache_get(cache_key)
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
excerpt = doc_text[:3500]
|
||||
prompt = (
|
||||
f"FAIL-MC: {mc.get('label', '')}\n"
|
||||
f"Severity: {mc.get('severity', 'MEDIUM')}\n"
|
||||
f"Aktueller Hint: {mc.get('hint', '')[:300]}\n\n"
|
||||
f"Dokument-Typ: {doc_type}\n"
|
||||
f"Dokument-Auszug:\n---\n{excerpt}\n---\n\n"
|
||||
"Liefere die Loesung als JSON."
|
||||
)
|
||||
|
||||
content = await _call_ollama(prompt)
|
||||
parsed = _parse(content)
|
||||
if not parsed:
|
||||
content = await _call_ovh(prompt)
|
||||
parsed = _parse(content)
|
||||
if parsed:
|
||||
_cache_put(cache_key, parsed)
|
||||
return parsed
|
||||
|
||||
|
||||
async def generate_solutions_for_fails(
|
||||
failed_mcs: Iterable[dict],
|
||||
doc_text: str,
|
||||
doc_type: str,
|
||||
limit: int = 5,
|
||||
) -> list[dict]:
|
||||
"""Returns a list of {mc_label, severity, solution_text, anchor_hint,
|
||||
effort_min} for the top-N HIGH/CRITICAL fails. Skips MEDIUM/LOW
|
||||
to keep latency bounded."""
|
||||
sev_order = {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "LOW": 3}
|
||||
high_fails = [m for m in (failed_mcs or [])
|
||||
if (m.get("severity") or "").upper() in ("CRITICAL", "HIGH")]
|
||||
high_fails.sort(key=lambda m: sev_order.get(
|
||||
(m.get("severity") or "").upper(), 3))
|
||||
high_fails = high_fails[:limit]
|
||||
|
||||
out: list[dict] = []
|
||||
for mc in high_fails:
|
||||
sol = await generate_solution(mc, doc_text, doc_type)
|
||||
if not sol:
|
||||
continue
|
||||
out.append({
|
||||
"mc_label": mc.get("label", "")[:200],
|
||||
"severity": mc.get("severity", "MEDIUM"),
|
||||
"solution_text": sol["solution_text"],
|
||||
"anchor_hint": sol["anchor_hint"],
|
||||
"effort_min": sol["effort_min"],
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def build_solutions_block_html(solutions: list[dict]) -> str:
|
||||
"""Renders the LLM-generated solutions as a Mail-Block."""
|
||||
if not solutions:
|
||||
return ""
|
||||
items: list[str] = []
|
||||
for s in solutions:
|
||||
sev_color = "#dc2626" if s["severity"].upper() == "CRITICAL" else "#d97706"
|
||||
items.append(
|
||||
f'<li style="margin-bottom:12px;font-size:11px;line-height:1.5">'
|
||||
f'<div style="font-weight:600;color:{sev_color}">'
|
||||
f'[{s["severity"]}] {s["mc_label"]}</div>'
|
||||
f'<div style="background:#fff;padding:8px 10px;border:1px solid '
|
||||
f'#cbd5e1;border-radius:4px;margin-top:4px;color:#1e293b;'
|
||||
f'white-space:pre-wrap">{s["solution_text"]}</div>'
|
||||
f'<div style="font-size:10px;color:#64748b;margin-top:3px">'
|
||||
f'<strong>Anchor:</strong> {s["anchor_hint"] or "—"} '
|
||||
f' · <strong>Aufwand:</strong> {s["effort_min"]}'
|
||||
f'</div></li>'
|
||||
)
|
||||
return (
|
||||
'<div style="font-family:-apple-system,BlinkMacSystemFont,sans-serif;'
|
||||
'max-width:760px;margin:0 auto 16px;padding:14px 18px;'
|
||||
'background:#f0f9ff;border:1px solid #bfdbfe;border-radius:8px">'
|
||||
'<div style="font-size:11px;color:#1e40af;text-transform:uppercase;'
|
||||
'letter-spacing:1.2px;margin-bottom:4px;font-weight:600">'
|
||||
'Loesungs-Vorschlaege (KI-generiert)</div>'
|
||||
f'<h3 style="margin:0 0 6px;font-size:14px;color:#1e293b">'
|
||||
f'{len(solutions)} konkrete Einfuege-Empfehlung'
|
||||
f'{"en" if len(solutions) != 1 else ""} '
|
||||
'fuer die kritischen Findings</h3>'
|
||||
'<p style="margin:0 0 10px;font-size:11px;color:#475569;line-height:1.5">'
|
||||
'Folgende Absaetze koennen Sie direkt uebernehmen — Platzhalter '
|
||||
'[Ihr Firmenname] / [Ihre Adresse] sind zu ersetzen. Inhaltliche '
|
||||
'Korrektheit ist mit DSB / Rechtsabteilung zu pruefen.</p>'
|
||||
'<ul style="margin:0 0 0 18px;padding:0">'
|
||||
+ "".join(items) +
|
||||
'</ul>'
|
||||
'<p style="margin:8px 0 0;font-size:10px;color:#94a3b8;'
|
||||
'font-style:italic">Generiert via Qwen3-30b lokal (Fallback: '
|
||||
'OVH 120B). Vorschlaege sind kein Rechts-Beratung.</p>'
|
||||
'</div>'
|
||||
)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user