Compare commits

..

11 Commits

Author SHA1 Message Date
Benjamin Admin 90da26745b fix(mc-api): NODE_TLS_REJECT_UNAUTHORIZED=0 for self-signed cert
Build + Deploy / build-admin-compliance (push) Successful in 2m19s
Build + Deploy / build-backend-compliance (push) Successful in 3m39s
Build + Deploy / build-ai-sdk (push) Successful in 57s
Build + Deploy / build-developer-portal (push) Successful in 1m12s
Build + Deploy / build-tts (push) Successful in 1m44s
Build + Deploy / build-document-crawler (push) Successful in 44s
Build + Deploy / build-dsms-gateway (push) Successful in 30s
Build + Deploy / build-dsms-node (push) Successful in 17s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / loc-budget (push) Failing after 20s
CI / secret-scan (push) Has been skipped
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Successful in 3m0s
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / test-go (push) Successful in 44s
CI / test-python-backend (push) Successful in 40s
CI / test-python-document-crawler (push) Successful in 29s
CI / test-python-dsms-gateway (push) Successful in 23s
CI / validate-canonical-controls (push) Successful in 14s
Build + Deploy / trigger-orca (push) Successful in 3m13s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-11 20:56:38 +02:00
Benjamin Admin 0d0e705117 feat: Unified Compliance-Check — 8 document types in one form
New 3-tab structure: Website-Scan, Compliance-Check, Banner-Check.

Compliance-Check Tab (replaces Dokumenten-Pruefung + Impressum-Check):
- 8 document rows: DSI, Impressum, Social Media, Cookie, AGB,
  Nutzungsbedingungen, Widerruf, DSB-Kontakt
- Each row: URL input + "Text laden" + file upload + manual text
- "Text laden" extracts via consent-tester, shows in editable textarea
- User verifies/corrects text before checking
- Empty fields = "not present" → own finding

Business Profiler (business_profiler.py):
- Detects B2B/B2C/B2G from all documents together
- Recognizes regulated professions, online shops, editorial content
- Context-aware: INFO checks become PASS/FAIL based on profile

Backend: /compliance-check + /extract-text endpoints
Frontend: ComplianceCheckTab.tsx + DocumentRow.tsx
API proxies: compliance-check/route.ts + extract-text/route.ts

Also: Impressum regex fixes (Telefon, AG, Geschaeftsfuehrung)
and INFO severity for context-dependent checks.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-11 20:56:10 +02:00
Benjamin Admin b214cbc003 fix(mc-api): accept self-signed SSL cert for production DB
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-11 20:49:44 +02:00
Benjamin Admin 19d8a7e2b9 fix(mc-api): use COMPLIANCE_DATABASE_URL for production DB
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-11 20:11:03 +02:00
Benjamin Admin b8770e1b9c feat(mc-browser): reuse Control Library UI for Master Controls
- MC page.tsx imports ControlListView + useControlLibraryState directly
- useControlLibraryState accepts optional backendUrl override
- MC API route returns data in canonical control format
- Same filters, pagination, sorting, click-to-detail as Control Library

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-11 20:02:31 +02:00
Benjamin Admin 6af9353bad feat(sidebar): add Master Controls between Control Library and Provenance
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-11 18:04:57 +02:00
Benjamin Admin 4279197954 fix(sidebar): move Master Controls to main nav section
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-11 16:53:17 +02:00
Benjamin Admin 0c25832b5c fix: Context-aware Impressum checks + 3 regex fixes
3 Regex fixes:
- Telefon: matches '0761 / 48 98 09 01' format (spaces around /)
- Registergericht: matches 'AG Freiburg' (not just 'Amtsgericht')
- Vertretung: matches 'Geschaeftsfuehrung:' (not just 'Geschaeftsfuehrer:')

6 checks changed from FAIL to INFO severity:
- V.i.S.d.P.: only relevant if website has editorial content
- Streitbeilegung: only relevant for B2C online shops
- Berufsrecht: only relevant for regulated professions
- Stammkapital: legally required but rarely enforced
- Aufsichtsbehoerde: only for licensed activities
- Berufshaftpflicht: only for mandatory insurance

INFO checks don't count towards completeness percentage.
They appear as hints, not findings.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-11 15:23:19 +02:00
Benjamin Admin 916337b503 fix: Restore new page.tsx with 4 tabs (was overwritten by merge)
Merge took the old page.tsx from main which still had useAgentAnalysis.
Restored: Website-Scan, Dokumenten-Pruefung, Banner-Check, Impressum-Check.
Removed: Schnellanalyse, Consent-Test, Compare, Auth-Test tabs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-11 15:04:29 +02:00
Benjamin Admin fde2f551d7 fix: Add impressum keywords to dsi_discovery.py inline DSI_KEYWORDS
The inline DSI_KEYWORDS in dsi_discovery.py was missing 'impressum'.
This caused self-extraction to skip impressum pages, returning
datenschutz text instead. Added: impressum, anbieterkennzeichnung,
imprint, legal notice, site notice.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-11 14:43:47 +02:00
Benjamin Admin 3c7ed65f86 fix: remove dangling SDKPipelineSidebar reference
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-11 14:34:52 +02:00
18 changed files with 1721 additions and 606 deletions
@@ -0,0 +1,39 @@
/**
* Unified Compliance Check Proxy
* POST: start check for all documents, GET: poll status
*/
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL = process.env.BACKEND_API_URL || 'http://backend-compliance:8002'
export async function POST(request: NextRequest) {
try {
const body = await request.text()
const response = await fetch(`${BACKEND_URL}/api/compliance/agent/compliance-check`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
signal: AbortSignal.timeout(30000),
})
const data = await response.json()
return NextResponse.json(data, { status: response.status })
} catch (error) {
return NextResponse.json({ error: 'Pruefung konnte nicht gestartet werden' }, { status: 503 })
}
}
export async function GET(request: NextRequest) {
const checkId = request.nextUrl.searchParams.get('check_id')
if (!checkId) return NextResponse.json({ error: 'check_id required' }, { status: 400 })
try {
const response = await fetch(
`${BACKEND_URL}/api/compliance/agent/compliance-check/${checkId}`,
{ signal: AbortSignal.timeout(10000) },
)
const data = await response.json()
return NextResponse.json(data)
} catch {
return NextResponse.json({ error: 'Status-Abfrage fehlgeschlagen' }, { status: 503 })
}
}
@@ -0,0 +1,27 @@
/**
* Text Extraction Proxy — extract text from a URL via consent-tester
* POST: { url: string } -> { text, word_count, title, error }
*/
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL = process.env.BACKEND_API_URL || 'http://backend-compliance:8002'
export async function POST(request: NextRequest) {
try {
const body = await request.text()
const response = await fetch(`${BACKEND_URL}/api/compliance/agent/extract-text`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
signal: AbortSignal.timeout(120000),
})
const data = await response.json()
return NextResponse.json(data, { status: response.status })
} catch (error) {
return NextResponse.json(
{ text: '', word_count: 0, title: '', error: 'Text-Extraktion fehlgeschlagen' },
{ status: 503 },
)
}
}
@@ -1,46 +1,56 @@
import { NextRequest, NextResponse } from 'next/server'
import { Pool } from 'pg'
const pool = new Pool({
connectionString: process.env.DATABASE_URL || 'postgresql://breakpilot:breakpilot123@bp-core-postgres:5432/breakpilot_db',
})
// Disable SSL rejection for self-signed certs
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'
const dbUrl = process.env.COMPLIANCE_DATABASE_URL ||
process.env.DATABASE_URL ||
'postgresql://breakpilot:breakpilot123@bp-core-postgres:5432/breakpilot_db'
const pool = new Pool({ connectionString: dbUrl })
/**
* GET /api/sdk/v1/master-controls?action=list|detail|members
* MC API that returns data in the same format as the canonical controls
* endpoint. This allows the MC page to reuse ControlListView components.
*/
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const action = searchParams.get('action') || 'list'
const endpoint = searchParams.get('endpoint') || 'controls'
if (action === 'list') {
return handleList(searchParams)
}
if (action === 'detail') {
return handleDetail(searchParams)
}
if (action === 'members') {
return handleMembers(searchParams)
}
switch (endpoint) {
case 'frameworks':
return NextResponse.json([])
return NextResponse.json({ error: 'Unknown action' }, { status: 400 })
case 'controls':
return handleControls(searchParams)
case 'controls-count':
return handleCount(searchParams)
case 'controls-meta':
return handleMeta(searchParams)
case 'control':
return handleDetail(searchParams)
default:
return NextResponse.json({ error: 'unknown' }, { status: 400 })
}
} catch (e) {
return NextResponse.json({ error: String(e) }, { status: 500 })
}
}
async function handleList(params: URLSearchParams) {
async function handleControls(params: URLSearchParams) {
const search = params.get('search') || ''
const minControls = parseInt(params.get('min_controls') || '0')
const sortBy = params.get('sort') || 'total_controls'
const order = params.get('order') || 'DESC'
const limit = Math.min(parseInt(params.get('limit') || '100'), 500)
const limit = Math.min(parseInt(params.get('limit') || '50'), 200)
const offset = parseInt(params.get('offset') || '0')
const sort = params.get('sort') || 'control_id'
const order = params.get('order') === 'desc' ? 'DESC' : 'ASC'
const validSort = ['total_controls', 'canonical_name', 'created_at'].includes(sortBy) ? sortBy : 'total_controls'
const validOrder = order === 'ASC' ? 'ASC' : 'DESC'
let where = 'WHERE 1=1'
let where = "WHERE 1=1"
const args: unknown[] = []
let idx = 1
@@ -49,81 +59,150 @@ async function handleList(params: URLSearchParams) {
args.push(`%${search}%`)
idx++
}
if (minControls > 0) {
where += ` AND mc.total_controls >= $${idx}`
args.push(minControls)
idx++
}
const countRes = await pool.query(
`SELECT count(*) FROM compliance.master_controls mc ${where}`, args
)
const total = parseInt(countRes.rows[0].count)
const sortCol = sort === 'control_id' ? 'mc.master_control_id' :
sort === 'created_at' ? 'mc.created_at' : 'mc.master_control_id'
args.push(limit, offset)
const res = await pool.query(`
SELECT mc.master_control_id as control_id,
mc.canonical_name as title,
'Master Control mit ' || mc.total_controls || ' Atomic Controls' as objective,
CASE WHEN mc.total_controls > 100 THEN 'high'
WHEN mc.total_controls > 20 THEN 'medium'
ELSE 'low' END as severity,
'master_control' as category,
mc.total_controls,
mc.phases_covered,
mc.id,
mc.created_at
FROM compliance.master_controls mc
${where}
ORDER BY ${sortCol} ${order}
LIMIT $${idx} OFFSET $${idx + 1}
`, args)
// Map to canonical control format
const controls = res.rows.map(r => ({
id: r.id,
control_id: r.control_id,
title: r.title,
objective: r.objective,
severity: r.severity,
category: r.category,
release_state: 'active',
source_citation: null,
verification_method: null,
evidence_type: null,
target_audience: [],
requirements: [],
test_procedure: [],
evidence: [],
open_anchors: [],
total_controls: r.total_controls,
phases_covered: r.phases_covered,
created_at: r.created_at,
}))
return NextResponse.json(controls)
}
async function handleCount(params: URLSearchParams) {
const search = params.get('search') || ''
let where = "WHERE 1=1"
const args: unknown[] = []
if (search) {
where += ` AND mc.canonical_name ILIKE $1`
args.push(`%${search}%`)
}
const res = await pool.query(
`SELECT mc.id, mc.master_control_id, mc.canonical_name,
mc.total_controls, mc.phases_covered, mc.phase_control_count
FROM compliance.master_controls mc
${where}
ORDER BY mc.${validSort} ${validOrder}
LIMIT $${idx} OFFSET $${idx + 1}`,
args
`SELECT count(*) FROM compliance.master_controls mc ${where}`, args
)
return NextResponse.json({ total: parseInt(res.rows[0].count) })
}
async function handleMeta(params: URLSearchParams) {
const res = await pool.query(`
SELECT count(*) as total,
count(CASE WHEN total_controls > 100 THEN 1 END) as high_count,
count(CASE WHEN total_controls BETWEEN 20 AND 100 THEN 1 END) as medium_count,
count(CASE WHEN total_controls < 20 THEN 1 END) as low_count
FROM compliance.master_controls
`)
const r = res.rows[0]
// Get top L1 tokens as "domains"
const domainRes = await pool.query(`
SELECT split_part(canonical_name, '_', 1) as domain, count(*) as count
FROM compliance.master_controls
GROUP BY 1 ORDER BY 2 DESC LIMIT 30
`)
return NextResponse.json({
total,
limit,
offset,
master_controls: res.rows,
total: parseInt(r.total),
severity_counts: {
high: parseInt(r.high_count),
medium: parseInt(r.medium_count),
low: parseInt(r.low_count),
},
domains: domainRes.rows.map(d => ({ domain: d.domain, count: parseInt(d.count) })),
sources: [],
no_source_count: 0,
release_state_counts: { active: parseInt(r.total) },
verification_method_counts: {},
category_counts: {},
evidence_type_counts: {},
})
}
async function handleDetail(params: URLSearchParams) {
const id = params.get('id')
if (!id) return NextResponse.json({ error: 'id required' }, { status: 400 })
const res = await pool.query(
`SELECT mc.id, mc.master_control_id, mc.canonical_name,
mc.total_controls, mc.phases_covered, mc.phase_control_count
FROM compliance.master_controls mc
WHERE mc.master_control_id = $1 OR mc.id::text = $1`,
[id]
)
const id = params.get('id') || ''
const res = await pool.query(`
SELECT mc.id, mc.master_control_id as control_id, mc.canonical_name as title,
'Master Control mit ' || mc.total_controls || ' Atomic Controls' as objective,
mc.total_controls, mc.phases_covered, mc.phase_control_count, mc.created_at
FROM compliance.master_controls mc
WHERE mc.master_control_id = $1 OR mc.id::text = $1
`, [id])
if (res.rows.length === 0) {
return NextResponse.json({ error: 'not found' }, { status: 404 })
}
return NextResponse.json({ master_control: res.rows[0] })
}
const mc = res.rows[0]
async function handleMembers(params: URLSearchParams) {
const mcId = params.get('mc_id')
if (!mcId) return NextResponse.json({ error: 'mc_id required' }, { status: 400 })
const limit = Math.min(parseInt(params.get('limit') || '50'), 200)
const offset = parseInt(params.get('offset') || '0')
const res = await pool.query(
`SELECT cc.control_id, cc.title, cc.objective, cc.severity,
mcm.phase, mcm.action,
COALESCE(pc.source_citation::jsonb->>'source', '') as regulation_source,
COALESCE(pc.source_citation::jsonb->>'article', '') as regulation_article
FROM compliance.master_control_members mcm
JOIN compliance.canonical_controls cc ON cc.id = mcm.control_uuid
LEFT JOIN compliance.canonical_controls pc ON pc.id = cc.parent_control_uuid
WHERE mcm.master_control_uuid = (
SELECT id FROM compliance.master_controls WHERE master_control_id = $1 OR id::text = $1 LIMIT 1
)
ORDER BY mcm.phase, cc.control_id
LIMIT $2 OFFSET $3`,
[mcId, limit, offset]
)
// Load members
const membersRes = await pool.query(`
SELECT cc.control_id, cc.title, cc.severity, mcm.phase, mcm.action
FROM compliance.master_control_members mcm
JOIN compliance.canonical_controls cc ON cc.id = mcm.control_uuid
WHERE mcm.master_control_uuid = $1
ORDER BY mcm.phase, cc.control_id
LIMIT 100
`, [mc.id])
return NextResponse.json({
mc_id: mcId,
members: res.rows,
count: res.rows.length,
id: mc.id,
control_id: mc.control_id,
title: mc.title,
objective: mc.objective,
severity: mc.total_controls > 100 ? 'high' : mc.total_controls > 20 ? 'medium' : 'low',
category: 'master_control',
release_state: 'active',
total_controls: mc.total_controls,
phases_covered: mc.phases_covered,
phase_control_count: mc.phase_control_count,
members: membersRes.rows,
requirements: membersRes.rows.map((m: { control_id: string; title: string; phase: string }) =>
`[${m.phase}] ${m.control_id}: ${m.title}`
),
test_procedure: [],
evidence: [],
open_anchors: [],
target_audience: [],
source_citation: null,
created_at: mc.created_at,
})
}
@@ -0,0 +1,352 @@
'use client'
import React, { useState, useCallback } from 'react'
import { ChecklistView } from './ChecklistView'
import { DocumentRow } from './DocumentRow'
const DOCUMENT_TYPES = [
{ id: 'dse', label: 'DSI (Datenschutzinformation)', required: true },
{ id: 'impressum', label: 'Impressum', required: true },
{ id: 'social_media', label: 'Social Media DSE', required: false },
{ id: 'cookie', label: 'Cookie-Richtlinie', required: false },
{ id: 'agb', label: 'AGB', required: false },
{ id: 'nutzungsbedingungen', label: 'Nutzungsbedingungen', required: false },
{ id: 'widerruf', label: 'Widerrufsbelehrung', required: false },
{ id: 'dsb', label: 'DSB-Kontakt', required: false },
] as const
type DocTypeId = typeof DOCUMENT_TYPES[number]['id']
interface DocState {
url: string
text: string
loading: boolean
error: string | null
}
type DocsState = Record<DocTypeId, DocState>
const STORAGE_KEY_STATE = 'compliance-check-state'
const STORAGE_KEY_RESULTS = 'compliance-check-results'
const STORAGE_KEY_HISTORY = 'compliance-check-history'
function emptyDocState(): DocState {
return { url: '', text: '', loading: false, error: null }
}
function initState(): DocsState {
if (typeof window === 'undefined') {
return Object.fromEntries(DOCUMENT_TYPES.map(d => [d.id, emptyDocState()])) as DocsState
}
try {
const saved = localStorage.getItem(STORAGE_KEY_STATE)
if (saved) {
const parsed = JSON.parse(saved) as Record<string, { url?: string; text?: string }>
return Object.fromEntries(
DOCUMENT_TYPES.map(d => [d.id, {
url: parsed[d.id]?.url || '',
text: parsed[d.id]?.text || '',
loading: false,
error: null,
}])
) as DocsState
}
} catch { /* ignore */ }
return Object.fromEntries(DOCUMENT_TYPES.map(d => [d.id, emptyDocState()])) as DocsState
}
function countWords(text: string): number {
if (!text.trim()) return 0
return text.trim().split(/\s+/).length
}
interface HistoryEntry {
date: string
docCount: number
findings: number
resultKey: string
}
export function ComplianceCheckTab() {
const [docs, setDocs] = useState<DocsState>(initState)
const [useAgent, setUseAgent] = useState(false)
const [loading, setLoading] = useState(false)
const [progress, setProgress] = useState('')
const [results, setResults] = useState<any>(() => {
if (typeof window === 'undefined') return null
try { const s = localStorage.getItem(STORAGE_KEY_RESULTS); return s ? JSON.parse(s) : null } catch { return null }
})
const [error, setError] = useState<string | null>(null)
const [history, setHistory] = useState<HistoryEntry[]>(() => {
if (typeof window === 'undefined') return []
try { return JSON.parse(localStorage.getItem(STORAGE_KEY_HISTORY) || '[]') } catch { return [] }
})
// Persist URLs and texts (not loading/error state)
React.useEffect(() => {
const toSave: Record<string, { url: string; text: string }> = {}
for (const [key, val] of Object.entries(docs)) {
toSave[key] = { url: val.url, text: val.text }
}
try { localStorage.setItem(STORAGE_KEY_STATE, JSON.stringify(toSave)) } catch { /* quota */ }
}, [docs])
const updateDoc = useCallback((docType: DocTypeId, patch: Partial<DocState>) => {
setDocs(prev => ({ ...prev, [docType]: { ...prev[docType], ...patch } }))
}, [])
const handleFetchText = useCallback(async (docType: DocTypeId) => {
const url = docs[docType].url.trim()
if (!url) return
updateDoc(docType, { loading: true, error: null })
try {
const res = await fetch('/api/sdk/v1/agent/extract-text', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url }),
})
if (!res.ok) {
const msg = res.status === 404
? 'Seite nicht erreichbar'
: `Fehler beim Laden (${res.status})`
throw new Error(msg)
}
const data = await res.json()
updateDoc(docType, { text: data.text || '', loading: false })
} catch (e) {
updateDoc(docType, {
loading: false,
error: e instanceof Error ? e.message : 'Text konnte nicht geladen werden',
})
}
}, [docs, updateDoc])
const handleFileUpload = useCallback(async (docType: DocTypeId, file: File) => {
// For now, read as text. PDF/DOCX parsing can be added server-side later.
const reader = new FileReader()
reader.onload = () => {
updateDoc(docType, { text: reader.result as string })
}
reader.readAsText(file)
}, [updateDoc])
const filledCount = Object.values(docs).filter(d => d.url.trim() || d.text.trim()).length
const handleSubmit = async () => {
if (filledCount === 0) return
setLoading(true)
setError(null)
setResults(null)
setProgress('Compliance-Check wird gestartet...')
try {
const entries = DOCUMENT_TYPES
.filter(dt => docs[dt.id].url.trim() || docs[dt.id].text.trim())
.map(dt => ({
doc_type: dt.id,
label: dt.label,
url: docs[dt.id].url.trim(),
text: docs[dt.id].text.trim() || undefined,
}))
const startRes = await fetch('/api/sdk/v1/agent/compliance-check', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
entries,
use_agent: useAgent,
}),
})
if (!startRes.ok) throw new Error(`Pruefung konnte nicht gestartet werden: ${startRes.status}`)
const { check_id } = await startRes.json()
if (!check_id) throw new Error('Keine Check-ID erhalten')
// Poll for results
let attempts = 0
while (attempts < 120) {
await new Promise(r => setTimeout(r, 3000))
const pollRes = await fetch(`/api/sdk/v1/agent/compliance-check?check_id=${check_id}`)
if (!pollRes.ok) { attempts++; continue }
const pollData = await pollRes.json()
if (pollData.progress) setProgress(pollData.progress)
if (pollData.status === 'completed' && pollData.result) {
setResults(pollData.result)
setProgress('')
localStorage.setItem(STORAGE_KEY_RESULTS, JSON.stringify(pollData.result))
const resultKey = `compliance-check-result-${Date.now()}`
try { localStorage.setItem(resultKey, JSON.stringify(pollData.result)) } catch { /* quota */ }
const entry: HistoryEntry = {
date: new Date().toISOString(),
docCount: entries.length,
findings: pollData.result.total_findings || 0,
resultKey,
}
const updated = [entry, ...history].slice(0, 30)
setHistory(updated)
localStorage.setItem(STORAGE_KEY_HISTORY, JSON.stringify(updated))
break
}
if (pollData.status === 'failed') {
throw new Error(pollData.error || 'Pruefung fehlgeschlagen')
}
attempts++
}
if (attempts >= 120) throw new Error('Zeitlimit ueberschritten')
} catch (e) {
setError(e instanceof Error ? e.message : 'Unbekannter Fehler')
setProgress('')
} finally {
setLoading(false)
}
}
const loadFromHistory = (entry: HistoryEntry) => {
if (entry.resultKey) {
try {
const saved = localStorage.getItem(entry.resultKey)
if (saved) { setResults(JSON.parse(saved)); return }
} catch { /* ignore */ }
}
try {
const last = localStorage.getItem(STORAGE_KEY_RESULTS)
if (last) setResults(JSON.parse(last))
} catch { /* ignore */ }
}
return (
<div className="space-y-4">
{/* Info box */}
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4">
<h3 className="text-sm font-semibold text-purple-900">Compliance-Check (Alle Dokumente)</h3>
<p className="text-xs text-purple-700 mt-1">
Geben Sie die URLs Ihrer Rechtstexte ein oder laden Sie die Dokumente hoch.
Das System prueft alle Pflichtangaben nach DSGVO, TDDDG, TMG und UWG.
Pflichtdokumente sind mit * markiert.
</p>
</div>
{/* Document rows */}
<div className="space-y-2">
{DOCUMENT_TYPES.map(dt => (
<DocumentRow
key={dt.id}
label={dt.label}
docType={dt.id}
required={dt.required}
url={docs[dt.id].url}
text={docs[dt.id].text}
loading={docs[dt.id].loading}
error={docs[dt.id].error}
wordCount={countWords(docs[dt.id].text)}
onUrlChange={url => updateDoc(dt.id, { url })}
onFetchText={() => handleFetchText(dt.id)}
onTextChange={text => updateDoc(dt.id, { text })}
onFileUpload={file => handleFileUpload(dt.id, file)}
/>
))}
</div>
{/* Agent toggle + submit */}
<div className="flex items-center justify-between">
<button
type="button"
onClick={() => setUseAgent(!useAgent)}
className={`flex items-center gap-2 px-3 py-1.5 rounded-full text-xs font-medium border transition-colors ${
useAgent
? 'bg-emerald-100 border-emerald-300 text-emerald-800'
: 'bg-gray-50 border-gray-200 text-gray-500 hover:bg-gray-100'
}`}
>
<span className={`w-2 h-2 rounded-full ${useAgent ? 'bg-emerald-500' : 'bg-gray-300'}`} />
{useAgent ? 'KI-Agent aktiv (alle MCs)' : 'KI-Agent aus'}
</button>
<span className="text-xs text-gray-500">
{filledCount} von {DOCUMENT_TYPES.length} Dokumenten ausgefuellt
</span>
</div>
{/* Submit button */}
<button
onClick={handleSubmit}
disabled={loading || filledCount === 0}
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"
>
{loading ? (
<>
<svg className="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Pruefe...
</>
) : (
`Compliance-Check starten (${filledCount} Dokument${filledCount !== 1 ? 'e' : ''})`
)}
</button>
{/* Progress */}
{progress && (
<div className="bg-purple-50 border border-purple-200 rounded-lg p-3 text-sm text-purple-700 flex items-center gap-3">
<svg className="animate-spin w-4 h-4 text-purple-500 shrink-0" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
{progress}
</div>
)}
{/* Error */}
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-3 text-sm text-red-700">{error}</div>
)}
{/* Results */}
{results && results.results && (
<div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm">
<ChecklistView results={results.results} />
{/* Email status */}
{results.email_status && (
<div className="mt-3 text-xs text-gray-500 flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${results.email_status === 'sent' ? 'bg-green-400' : 'bg-gray-300'}`} />
E-Mail: {results.email_status === 'sent' ? 'Gesendet' : results.email_status}
</div>
)}
</div>
)}
{/* History */}
{history.length > 0 && (
<div className="border border-gray-200 rounded-xl p-4">
<h4 className="text-sm font-medium text-gray-700 mb-2">Letzte Compliance-Checks</h4>
<div className="space-y-1">
{history.map((h, i) => (
<button
key={i}
onClick={() => loadFromHistory(h)}
className="w-full flex items-center justify-between text-sm py-2 px-2 rounded-lg border border-gray-50 hover:border-purple-200 hover:bg-purple-50/30 transition-all text-left"
>
<span className="text-gray-600">
{new Date(h.date).toLocaleDateString('de-DE', {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit',
})}
</span>
<div className="flex items-center gap-3">
<span className="text-xs text-gray-500">{h.docCount} Dok.</span>
<span className={`text-xs font-medium ${h.findings > 0 ? 'text-amber-600' : 'text-green-600'}`}>
{h.findings} Findings
</span>
</div>
</button>
))}
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,163 @@
'use client'
import React, { useState, useRef } from 'react'
interface DocumentRowProps {
label: string
docType: string
required?: boolean
url: string
text: string
loading: boolean
error: string | null
wordCount: number
onUrlChange: (url: string) => void
onFetchText: () => void
onTextChange: (text: string) => void
onFileUpload: (file: File) => void
}
export function DocumentRow({
label,
docType,
required,
url,
text,
loading,
error,
wordCount,
onUrlChange,
onFetchText,
onTextChange,
onFileUpload,
}: DocumentRowProps) {
const [showText, setShowText] = useState(false)
const fileRef = useRef<HTMLInputElement>(null)
const textVisible = showText || text.length > 0
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
// Read text-based files directly
const reader = new FileReader()
reader.onload = () => {
const content = reader.result as string
onTextChange(content)
}
reader.onerror = () => {
// Let parent handle via onFileUpload for binary formats
onFileUpload(file)
}
if (file.name.endsWith('.txt') || file.type === 'text/plain') {
reader.readAsText(file)
} else {
// PDF, DOCX — pass to parent for server-side parsing
onFileUpload(file)
}
// Reset input so the same file can be re-selected
e.target.value = ''
}
return (
<div className="border border-gray-200 rounded-lg p-3 space-y-2">
{/* Header row: label + inputs */}
<div className="flex items-center gap-2">
<div className="w-52 shrink-0">
<span className="text-sm font-medium text-gray-700">
{label}
{required && <span className="text-red-500 ml-0.5">*</span>}
</span>
</div>
<input
type="url"
value={url}
onChange={e => onUrlChange(e.target.value)}
placeholder="https://example.com/datenschutz"
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
{/* Fetch text button */}
<button
type="button"
onClick={onFetchText}
disabled={loading || !url.trim()}
className="px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-700 hover:bg-gray-50 disabled:opacity-40 disabled:cursor-not-allowed whitespace-nowrap transition-colors"
>
{loading ? (
<svg className="animate-spin w-4 h-4 text-purple-500" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
) : (
'Text laden'
)}
</button>
{/* File upload button */}
<button
type="button"
onClick={() => fileRef.current?.click()}
className="px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-700 hover:bg-gray-50 transition-colors"
title="PDF, DOCX oder TXT hochladen"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
</button>
<input
ref={fileRef}
type="file"
accept=".pdf,.docx,.doc,.txt"
onChange={handleFileChange}
className="hidden"
/>
{/* Toggle text area */}
<button
type="button"
onClick={() => setShowText(!showText)}
className={`px-3 py-2 border rounded-lg text-sm transition-colors ${
textVisible
? 'border-purple-300 bg-purple-50 text-purple-700'
: 'border-gray-300 text-gray-700 hover:bg-gray-50'
}`}
title={textVisible ? 'Text ausblenden' : 'Text anzeigen'}
>
<svg className={`w-4 h-4 transition-transform ${textVisible ? 'rotate-180' : ''}`}
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{/* Word count badge */}
{wordCount > 0 && (
<span className="text-xs px-2 py-1 rounded-full bg-green-100 text-green-700 font-medium shrink-0">
{wordCount.toLocaleString('de-DE')} W.
</span>
)}
</div>
{/* Error */}
{error && (
<div className="text-xs text-red-600 px-1">{error}</div>
)}
{/* Collapsible textarea */}
{textVisible && (
<textarea
value={text}
onChange={e => onTextChange(e.target.value)}
placeholder="Dokumenttext hier einfuegen oder per URL / Upload laden..."
rows={6}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm font-mono resize-y focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
)}
</div>
)
}
+115 -197
View File
@@ -2,41 +2,33 @@
import React, { useState } from 'react'
import { ScanResult } from './_components/ScanResult'
import { ConsentTestResult } from './_components/ConsentTestResult'
import { CompareResult } from './_components/CompareResult'
import { AuthTestResult } from './_components/AuthTestResult'
import { ComplianceCheckTab } from './_components/ComplianceCheckTab'
import { BannerCheckTab } from './_components/BannerCheckTab'
import { ComplianceFAQ } from './_components/ComplianceFAQ'
type Mode = 'pre_launch' | 'post_launch'
type Tab = 'quick' | 'scan' | 'consent' | 'compare' | 'auth'
type AnalysisTab = 'scan' | 'compliance-check' | 'banner-check'
const MODES = [
{ id: 'pre_launch' as Mode, label: 'Internes Dokument', desc: 'Vor Veroeffentlichung', icon: '📋' },
{ id: 'post_launch' as Mode, label: 'Live-Website', desc: 'Bereits online', icon: '🌐' },
]
const TABS = [
{ id: 'quick' as Tab, label: 'Schnellanalyse', info: 'Einzelne URL klassifizieren und bewerten.' },
{ id: 'scan' as Tab, label: 'Website-Scan', info: '5-10 Seiten scannen, Dienstleister abgleichen, Pflichtinhalte pruefen.' },
{ id: 'consent' as Tab, label: 'Cookie-Test', info: 'Testet mit Browser was VOR und NACH Cookie-Einwilligung geladen wird.' },
{ id: 'compare' as Tab, label: 'Vergleich', info: '2-5 Websites parallel scannen und Compliance vergleichen.' },
{ id: 'auth' as Tab, label: 'Login-Test', info: 'Nach Login pruefen: Kuendigung, Daten loeschen, Export, Einwilligungen.' },
const TABS: { id: AnalysisTab; label: string; desc: string }[] = [
{ id: 'scan', label: 'Website-Scan', desc: 'Rechtliche Dokumente finden + Dienstleister erkennen' },
{ id: 'compliance-check', label: 'Compliance-Check', desc: 'Alle rechtlichen Dokumente zusammen pruefen' },
{ id: 'banner-check', label: 'Banner-Check', desc: 'Cookie-Banner auf DSGVO-Konformitaet testen' },
]
export default function AgentPage() {
const [url, setUrl] = useState('')
const [urls, setUrls] = useState('')
const [mode, setMode] = useState<Mode>('post_launch')
const [tab, setTab] = useState<Tab>('quick')
const [url, setUrl] = useState(() => typeof window !== 'undefined' ? localStorage.getItem('agent-scan-url') || '' : '')
const [tab, setTab] = useState<AnalysisTab>(() => (typeof window !== 'undefined' ? localStorage.getItem('agent-scan-tab') as AnalysisTab : null) || 'compliance-check')
const [scanLoading, setScanLoading] = useState(false)
const [scanError, setScanError] = useState<string | null>(null)
const [scanData, setScanData] = useState<any>(null)
const [scanHistory, setScanHistory] = useState<any[]>([])
const [consentData, setConsentData] = useState<any>(null)
const [compareData, setCompareData] = useState<any>(null)
const [authData, setAuthData] = useState<any>(null)
const [authUser, setAuthUser] = useState('')
const [authPass, setAuthPass] = useState('')
const { analyze, answerFollowUp, loading, error, result, history } = useAgentAnalysis()
const [scanData, setScanData] = useState<any>(() => {
if (typeof window === 'undefined') return null
try { const s = localStorage.getItem('agent-scan-result'); return s ? JSON.parse(s) : null } catch { return null }
})
const [scanProgress, setScanProgress] = useState<string>('')
const [activeScanId, setActiveScanId] = useState<string>(() => typeof window !== 'undefined' ? localStorage.getItem('agent-scan-id') || '' : '')
const [scanHistory, setScanHistory] = useState<{ url: string; date: string; findings: number; docs: number; resultKey: string }[]>(() => {
if (typeof window === 'undefined') return []
try { return JSON.parse(localStorage.getItem('agent-scan-history') || '[]') } catch { return [] }
})
React.useEffect(() => { localStorage.setItem('agent-scan-url', url) }, [url])
React.useEffect(() => { localStorage.setItem('agent-scan-tab', tab) }, [tab])
@@ -56,24 +48,17 @@ export default function AgentPage() {
const data = await res.json()
if (data.progress) setScanProgress(data.progress)
if (data.status === 'completed' && data.result) {
setScanData(data.result)
setScanProgress('')
setScanLoading(false)
setScanData(data.result); setScanProgress(''); setScanLoading(false)
localStorage.setItem('agent-scan-result', JSON.stringify(data.result))
localStorage.removeItem('agent-scan-id')
setActiveScanId('')
_addToHistory(data.result)
return
localStorage.removeItem('agent-scan-id'); setActiveScanId('')
_addToHistory(data.result); return
}
if (data.status === 'failed' || data.status === 'not_found') {
if (data.status === 'failed') setScanError(data.error || 'Scan fehlgeschlagen')
setScanProgress('')
setScanLoading(false)
localStorage.removeItem('agent-scan-id')
setActiveScanId('')
return
setScanProgress(''); setScanLoading(false)
localStorage.removeItem('agent-scan-id'); setActiveScanId(''); return
}
} catch { /* retry */ }
} catch {}
}
}
poll()
@@ -83,83 +68,47 @@ export default function AgentPage() {
const _addToHistory = (result: any) => {
const resultKey = `scan-result-${Date.now()}`
try { localStorage.setItem(resultKey, JSON.stringify(result)) } catch {}
const entry = {
url: url || result.url || '',
date: new Date().toISOString(),
findings: result.findings?.length || 0,
docs: result.discovered_documents?.length || 0,
resultKey,
}
const entry = { url: url || result.url || '', date: new Date().toISOString(), findings: result.findings?.length || 0, docs: result.discovered_documents?.length || 0, resultKey }
const updated = [entry, ...scanHistory].slice(0, 30)
setScanHistory(updated)
localStorage.setItem('agent-scan-history', JSON.stringify(updated))
setScanHistory(updated); localStorage.setItem('agent-scan-history', JSON.stringify(updated))
}
const handleScan = async (e: React.FormEvent) => {
e.preventDefault()
setScanLoading(true)
setScanError(null)
if (!url.trim()) return
setScanLoading(true); setScanError(null); setScanData(null); setScanProgress('Scan wird gestartet...')
try {
if (tab === 'quick') {
setScanLoading(false)
analyze(url.trim(), mode)
return
const startRes = await fetch('/api/sdk/v1/agent/scan', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url: url.trim(), mode: 'post_launch' }) })
if (!startRes.ok) throw new Error(`Scan konnte nicht gestartet werden: ${startRes.status}`)
const { scan_id } = await startRes.json()
if (!scan_id) throw new Error('Keine Scan-ID erhalten')
setActiveScanId(scan_id); localStorage.setItem('agent-scan-id', scan_id)
let attempts = 0
while (attempts < 120) {
await new Promise(r => setTimeout(r, 5000))
const pollRes = await fetch(`/api/sdk/v1/agent/scan?scan_id=${scan_id}`)
if (!pollRes.ok) { attempts++; continue }
const pollData = await pollRes.json()
if (pollData.progress) setScanProgress(pollData.progress)
if (pollData.status === 'completed' && pollData.result) {
setScanData(pollData.result); setScanProgress('')
localStorage.setItem('agent-scan-result', JSON.stringify(pollData.result))
localStorage.removeItem('agent-scan-id'); setActiveScanId(''); _addToHistory(pollData.result); break
}
if (pollData.status === 'failed') throw new Error(pollData.error || 'Scan fehlgeschlagen')
attempts++
}
let endpoint = ''
let body: any = {}
if (tab === 'scan') {
endpoint = '/api/sdk/v1/agent/scan'
body = { url: url.trim(), mode }
} else if (tab === 'consent') {
endpoint = '/api/sdk/v1/agent/consent-test'
body = { url: url.trim() }
} else if (tab === 'compare') {
endpoint = '/api/sdk/v1/agent/compare'
body = { urls: urls.split('\n').map(u => u.trim()).filter(Boolean), mode }
} else if (tab === 'auth') {
endpoint = '/api/sdk/v1/agent/authenticated-scan'
body = { url: url.trim(), username: authUser, password: authPass }
}
const res = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (!res.ok) throw new Error(`Fehlgeschlagen: ${res.status}`)
const data = await res.json()
if (tab === 'scan') {
setScanData(data)
setScanHistory(prev => [{ url: url.trim(), ...data, scanned_at: new Date().toISOString() }, ...prev].slice(0, 20))
} else if (tab === 'consent') setConsentData(data)
else if (tab === 'compare') setCompareData(data)
else if (tab === 'auth') setAuthData(data)
} catch (e) {
setScanError(e instanceof Error ? e.message : 'Fehler')
} finally {
setScanLoading(false)
}
if (attempts >= 120) throw new Error('Scan-Timeout (10 Minuten)')
} catch (e) { setScanError(e instanceof Error ? e.message : 'Unbekannter Fehler'); setScanProgress('') }
finally { setScanLoading(false) }
}
// Navigate to a specialized tab with a pre-filled URL
const navigateToCheck = (targetTab: AnalysisTab, checkUrl: string) => {
// Store the URL in the target tab's localStorage key
const keyMap: Record<string, string> = {
'doc-check': 'doc-check-prefill-url',
'banner-check': 'banner-check-url',
'impressum-check': 'impressum-check-url',
}
if (keyMap[targetTab]) {
localStorage.setItem(keyMap[targetTab], checkUrl)
}
const keyMap: Record<string, string> = { 'doc-check': 'doc-check-prefill-url', 'banner-check': 'banner-check-url', 'impressum-check': 'impressum-check-url' }
if (keyMap[targetTab]) localStorage.setItem(keyMap[targetTab], checkUrl)
setTab(targetTab)
}
// Extract discovered documents for quick-action buttons
const discoveredDocs = scanData?.discovered_documents || []
const scannedUrl = scanData?.url || url
@@ -167,109 +116,78 @@ export default function AgentPage() {
<div className="space-y-6 max-w-4xl">
<div>
<h1 className="text-2xl font-bold text-gray-900">Compliance Agent</h1>
<p className="text-gray-500 mt-1">Analysiere Dokumente und Webseiten auf DSGVO-Konformitaet.</p>
<p className="text-gray-500 mt-1">Analysiere Webseiten und Dokumente auf DSGVO-Konformitaet.</p>
</div>
{/* Mode */}
<div className="grid grid-cols-2 gap-3">
{MODES.map(m => (
<button key={m.id} onClick={() => setMode(m.id)}
className={`p-3 rounded-xl border-2 text-left transition-all ${mode === m.id ? 'border-purple-500 bg-purple-50' : 'border-gray-200 hover:border-gray-300'}`}>
<div className="flex items-center gap-3">
<span className="text-xl">{m.icon}</span>
<div>
<p className={`text-sm font-semibold ${mode === m.id ? 'text-purple-900' : 'text-gray-900'}`}>{m.label}</p>
<p className="text-xs text-gray-500">{m.desc}</p>
</div>
</div>
<div className="flex border-b border-gray-200 overflow-x-auto">
{TABS.map(t => (
<button key={t.id} onClick={() => setTab(t.id)}
className={`px-4 py-2.5 text-sm font-medium border-b-2 transition-colors whitespace-nowrap ${
tab === t.id ? 'border-purple-500 text-purple-700' : 'border-transparent text-gray-500 hover:text-gray-700'}`}>
{t.label}
</button>
))}
</div>
{/* Tabs */}
<div>
<div className="flex border-b border-gray-200 overflow-x-auto">
{TABS.map(t => (
<button key={t.id} onClick={() => setTab(t.id)}
className={`px-3 py-2.5 text-sm font-medium border-b-2 whitespace-nowrap transition-colors ${tab === t.id ? 'border-purple-500 text-purple-700' : 'border-transparent text-gray-500 hover:text-gray-700'}`}>
{t.label}
</button>
))}
</div>
<p className="text-xs text-gray-400 mt-2 px-1">{TABS.find(t => t.id === tab)?.info}</p>
</div>
{/* Input */}
<form onSubmit={handleSubmit} className="space-y-3">
{tab === 'compare' ? (
<textarea value={urls} onChange={e => setUrls(e.target.value)}
placeholder="https://www.opodo.de&#10;https://www.booking.com&#10;https://www.expedia.de"
rows={3}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 text-sm"
disabled={isLoading} />
) : (
<input type="url" value={url} onChange={e => setUrl(e.target.value)}
placeholder={tab === 'auth' ? 'https://www.example.com/login' : 'https://www.example.com/'}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 text-sm"
disabled={isLoading} required />
)}
{tab === 'auth' && (
<div className="grid grid-cols-2 gap-3">
<input type="text" value={authUser} onChange={e => setAuthUser(e.target.value)}
placeholder="Email / Benutzername" autoComplete="off"
className="px-4 py-2 border border-gray-300 rounded-lg text-sm" />
<input type="password" value={authPass} onChange={e => setAuthPass(e.target.value)}
placeholder="Passwort" autoComplete="off"
className="px-4 py-2 border border-gray-300 rounded-lg text-sm" />
<p className="col-span-2 text-[10px] text-gray-400">Credentials werden NICHT gespeichert nur fuer diesen Test im Browser-Kontext.</p>
{tab === 'scan' && (
<div className="space-y-4">
<div className="bg-indigo-50 border border-indigo-200 rounded-lg p-4">
<h3 className="text-sm font-semibold text-indigo-900">Website-Scan (Discovery)</h3>
<p className="text-xs text-indigo-700 mt-1">Findet alle rechtlichen Dokumente (DSI, AGB, Impressum, Cookie, Widerruf), erkennt eingesetzte Drittdienste und prueft ob sie in der DSE dokumentiert sind.</p>
</div>
)}
<button type="submit" disabled={isLoading || (!url.trim() && tab !== 'compare') || (tab === 'compare' && !urls.trim())}
className="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 transition-colors flex items-center gap-2 text-sm font-medium">
{isLoading ? (
<><svg className="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>Analysiere...</>
) : TABS.find(t => t.id === tab)?.label || 'Starten'}
</button>
</form>
{currentError && <div className="bg-red-50 border border-red-200 rounded-lg p-4 text-sm text-red-700">{currentError}</div>}
{/* Results */}
{tab === 'quick' && result && (
<div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm space-y-6">
<AnalysisResult result={result} />
{result.follow_up_questions.length > 0 && (
<div className="border-t pt-4"><FollowUpQuestions questions={result.follow_up_questions} answers={result.follow_up_answers} onAnswer={answerFollowUp} /></div>
<form onSubmit={handleScan} className="flex gap-3">
<input type="url" value={url} onChange={e => setUrl(e.target.value)} placeholder="https://www.example.com/"
className="flex-1 px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent text-sm" disabled={scanLoading} required />
<button type="submit" disabled={scanLoading || !url.trim()}
className="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 transition-colors flex items-center gap-2 text-sm font-medium whitespace-nowrap">
{scanLoading ? (<><svg className="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" /><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" /></svg>Scanne...</>) : 'Website scannen'}
</button>
</form>
{scanProgress && <div className="bg-purple-50 border border-purple-200 rounded-lg p-4 text-sm text-purple-700 flex items-center gap-3"><svg className="animate-spin w-5 h-5 text-purple-500 shrink-0" fill="none" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" /><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" /></svg>{scanProgress}</div>}
{scanError && <div className="bg-red-50 border border-red-200 rounded-lg p-4 text-sm text-red-700">{scanError}</div>}
{scanData && (
<div className="bg-white border border-gray-200 rounded-xl p-4 shadow-sm">
<h4 className="text-sm font-semibold text-gray-800 mb-3">Jetzt pruefen</h4>
<div className="grid grid-cols-2 gap-2">
<button onClick={() => navigateToCheck('banner-check', scannedUrl)} className="p-3 rounded-lg border border-gray-200 hover:border-purple-300 hover:bg-purple-50 transition-all text-left">
<div className="text-sm font-medium text-gray-900">Cookie-Banner pruefen</div>
<div className="text-xs text-gray-500 mt-0.5">3-Phasen Dark-Pattern-Analyse</div>
</button>
<button onClick={() => navigateToCheck('impressum-check', scannedUrl + '/impressum')} className="p-3 rounded-lg border border-gray-200 hover:border-purple-300 hover:bg-purple-50 transition-all text-left">
<div className="text-sm font-medium text-gray-900">Impressum pruefen</div>
<div className="text-xs text-gray-500 mt-0.5">§5 TMG Pflichtangaben</div>
</button>
{discoveredDocs.map((doc: any, i: number) => (
<button key={i} onClick={() => navigateToCheck('doc-check', doc.url)} className="p-3 rounded-lg border border-gray-200 hover:border-purple-300 hover:bg-purple-50 transition-all text-left">
<div className="text-sm font-medium text-gray-900 truncate">{doc.title || doc.url}</div>
<div className="text-xs text-gray-500 mt-0.5">{doc.doc_type?.toUpperCase()} · {doc.word_count || '?'} Woerter{doc.completeness_pct != null && ` · ${doc.completeness_pct}%`}</div>
</button>
))}
</div>
</div>
)}
{scanData?.services && <div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm"><ScanResult data={scanData} /></div>}
{scanHistory.length > 0 && (
<div className="border border-gray-200 rounded-xl p-4">
<h4 className="text-sm font-medium text-gray-700 mb-3">Letzte Scans</h4>
<div className="space-y-2">
{scanHistory.map((h, i) => (
<button key={i} onClick={() => { setUrl(h.url); if (h.resultKey) { try { const s = localStorage.getItem(h.resultKey); if (s) { setScanData(JSON.parse(s)); return } } catch {} } }}
className="w-full flex items-center justify-between p-3 rounded-lg border border-gray-100 hover:border-purple-200 hover:bg-purple-50/30 transition-all text-left">
<div className="min-w-0 flex-1"><div className="text-sm font-medium text-gray-900 truncate">{h.url}</div><div className="text-xs text-gray-500">{new Date(h.date).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })}</div></div>
<div className="flex items-center gap-3 shrink-0 ml-3">{h.docs > 0 && <span className="text-xs text-purple-600">{h.docs} Dok.</span>}<span className={`text-xs font-medium ${h.findings > 0 ? 'text-red-600' : 'text-green-600'}`}>{h.findings} Findings</span></div>
</button>
))}
</div>
</div>
)}
</div>
)}
{tab === 'scan' && scanData && <div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm"><ScanResult data={scanData} /></div>}
{tab === 'consent' && consentData && <div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm"><ConsentTestResult data={consentData} /></div>}
{tab === 'compare' && compareData?.sites && <div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm"><CompareResult sites={compareData.sites} /></div>}
{tab === 'auth' && authData && <div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm"><AuthTestResult data={authData} /></div>}
{/* History */}
{tab === 'quick' && <AnalysisHistory history={history} onSelect={r => { setUrl(r.url); analyze(r.url, mode) }} />}
{tab === 'scan' && scanHistory.length > 0 && (
<div>
<h3 className="text-sm font-medium text-gray-700 mb-3">Letzte Scans</h3>
<div className="space-y-2">
{scanHistory.map((item, i) => (
<button key={i} onClick={() => setUrl(item.url)}
className="w-full text-left p-3 bg-white border border-gray-200 rounded-lg hover:border-purple-300 transition-colors">
<div className="flex items-center gap-3">
<span className="text-xs text-gray-500 w-8">{item.pages_scanned}p</span>
<span className="text-sm text-gray-700 truncate flex-1">{item.url}</span>
<span className={`text-xs px-2 py-0.5 rounded ${item.findings?.length > 0 ? 'bg-red-100 text-red-700' : 'bg-green-100 text-green-700'}`}>{item.findings?.length || 0}</span>
</div>
</button>
))}
</div>
</div>
)}
{tab === 'compliance-check' && <ComplianceCheckTab />}
{tab === 'banner-check' && <BannerCheckTab />}
<ComplianceFAQ />
</div>
)
}
@@ -18,7 +18,8 @@ export interface ControlsMeta {
const PAGE_SIZE = 50
export function useControlLibraryState() {
export function useControlLibraryState(backendUrlOverride?: string) {
const backendUrl = backendUrlOverride || BACKEND_URL
const [frameworks, setFrameworks] = useState<Framework[]>([])
const [controls, setControls] = useState<CanonicalControl[]>([])
const [totalCount, setTotalCount] = useState(0)
@@ -100,7 +101,7 @@ export function useControlLibraryState() {
const loadFrameworks = useCallback(async () => {
try {
const res = await fetch(`${BACKEND_URL}?endpoint=frameworks`)
const res = await fetch(`${backendUrl}?endpoint=frameworks`)
if (res.ok) setFrameworks(await res.json())
} catch { /* ignore */ }
}, [])
@@ -111,7 +112,7 @@ export function useControlLibraryState() {
metaAbortRef.current = controller
try {
const qs = buildParams()
const res = await fetch(`${BACKEND_URL}?endpoint=controls-meta${qs ? `&${qs}` : ''}`, { signal: controller.signal })
const res = await fetch(`${backendUrl}?endpoint=controls-meta${qs ? `&${qs}` : ''}`, { signal: controller.signal })
if (res.ok && !controller.signal.aborted) setMeta(await res.json())
} catch (e) {
if (e instanceof DOMException && e.name === 'AbortError') return
@@ -130,8 +131,8 @@ export function useControlLibraryState() {
const qs = buildParams({ sort: sortField, order: sortOrder, limit: String(PAGE_SIZE), offset: String(offset) })
const countQs = buildParams()
const [ctrlRes, countRes] = await Promise.all([
fetch(`${BACKEND_URL}?endpoint=controls&${qs}`, { signal: controller.signal }),
fetch(`${BACKEND_URL}?endpoint=controls-count&${countQs}`, { signal: controller.signal }),
fetch(`${backendUrl}?endpoint=controls&${qs}`, { signal: controller.signal }),
fetch(`${backendUrl}?endpoint=controls-count&${countQs}`, { signal: controller.signal }),
])
if (!controller.signal.aborted) {
if (ctrlRes.ok) setControls(await ctrlRes.json())
@@ -147,7 +148,7 @@ export function useControlLibraryState() {
const loadReviewCount = useCallback(async () => {
try {
const res = await fetch(`${BACKEND_URL}?endpoint=controls-count&release_state=needs_review`)
const res = await fetch(`${backendUrl}?endpoint=controls-count&release_state=needs_review`)
if (res.ok) { const data = await res.json(); setReviewCount(data.total || 0) }
} catch { /* ignore */ }
}, [])
@@ -165,14 +166,14 @@ export function useControlLibraryState() {
const loadProcessedStats = async () => {
try {
const res = await fetch(`${BACKEND_URL}?endpoint=processed-stats`)
const res = await fetch(`${backendUrl}?endpoint=processed-stats`)
if (res.ok) { const data = await res.json(); setProcessedStats(data.stats || []) }
} catch { /* ignore */ }
}
const enterReviewMode = async () => {
try {
const res = await fetch(`${BACKEND_URL}?endpoint=controls&release_state=needs_review&limit=1000`)
const res = await fetch(`${backendUrl}?endpoint=controls&release_state=needs_review&limit=1000`)
if (res.ok) {
const items: CanonicalControl[] = await res.json()
if (items.length > 0) {
+1 -2
View File
@@ -208,8 +208,7 @@ function SDKInnerLayout({ children }: { children: React.ReactNode }) {
{/* Command Bar Modal */}
{isCommandBarOpen && <CommandBar onClose={() => setCommandBarOpen(false)} />}
{/* Pipeline Sidebar (FAB on mobile/tablet, fixed on desktop xl+) */}
<SDKPipelineSidebar />
{/* Pipeline Sidebar removed — replaced by per-module FAB navigators */}
{/* Compliance Advisor Widget — immer sichtbar, auch ohne Projekt */}
<ComplianceAdvisorWidget currentStep={currentStep} />
+102 -258
View File
@@ -1,266 +1,110 @@
'use client'
import React, { useState, useEffect, useCallback } from 'react'
interface MC {
id: string
master_control_id: string
canonical_name: string
total_controls: number
phases_covered: string[]
phase_control_count: Record<string, number>
}
interface Member {
control_id: string
title: string
objective: string
severity: string
phase: string
action: string
regulation_source: string
regulation_article: string
}
const API = '/api/sdk/v1/master-controls'
const PAGE_SIZE = 50
const SEV_COLORS: Record<string, string> = {
critical: 'bg-red-100 text-red-800',
high: 'bg-orange-100 text-orange-800',
medium: 'bg-yellow-100 text-yellow-800',
low: 'bg-blue-100 text-blue-800',
}
import { ControlDetail } from '../control-library/components/ControlDetail'
import { ControlListView } from '../control-library/components/ControlListView'
import { useControlLibraryState } from '../control-library/components/useControlLibraryState'
import { BACKEND_URL } from '../control-library/components/helpers'
/**
* Master Controls page — reuses the Control Library UI exactly,
* but shows Master Controls (13.5K grouped controls) instead of
* individual atomic controls (272K).
*
* The MC API route (/api/sdk/v1/master-controls) returns data in
* the same format as the canonical controls endpoint.
*/
export default function MasterControlsPage() {
const [mcs, setMcs] = useState<MC[]>([])
const [total, setTotal] = useState(0)
const [offset, setOffset] = useState(0)
const [search, setSearch] = useState('')
const [sortBy, setSortBy] = useState('total_controls')
const [sortOrder, setSortOrder] = useState('DESC')
const [loading, setLoading] = useState(false)
// Reuse the exact same state hook — it fetches from BACKEND_URL
// We override BACKEND_URL via a wrapper, but for now we reuse as-is
// since both endpoints speak the same format.
const state = useControlLibraryState('/api/sdk/v1/master-controls')
// Detail view
const [selectedMC, setSelectedMC] = useState<MC | null>(null)
const [members, setMembers] = useState<Member[]>([])
const [membersLoading, setMembersLoading] = useState(false)
const loadMCs = useCallback(async () => {
setLoading(true)
try {
const params = new URLSearchParams({
action: 'list', limit: String(PAGE_SIZE), offset: String(offset),
sort: sortBy, order: sortOrder,
})
if (search) params.set('search', search)
const res = await fetch(`${API}?${params}`)
if (res.ok) {
const data = await res.json()
setMcs(data.master_controls || [])
setTotal(data.total || 0)
}
} catch { /* ignore */ }
setLoading(false)
}, [offset, search, sortBy, sortOrder])
useEffect(() => { loadMCs() }, [loadMCs])
const loadMembers = async (mc: MC) => {
setSelectedMC(mc)
setMembersLoading(true)
try {
const res = await fetch(`${API}?action=members&mc_id=${mc.master_control_id}&limit=200`)
if (res.ok) {
const data = await res.json()
setMembers(data.members || [])
}
} catch { /* ignore */ }
setMembersLoading(false)
}
const handleSort = (field: string) => {
if (sortBy === field) {
setSortOrder(sortOrder === 'DESC' ? 'ASC' : 'DESC')
} else {
setSortBy(field)
setSortOrder('DESC')
}
setOffset(0)
}
const totalPages = Math.ceil(total / PAGE_SIZE)
const currentPage = Math.floor(offset / PAGE_SIZE) + 1
// L1 token = part before first underscore that has a sub-part
const getL1Token = (name: string) => {
const parts = name.split('_')
// Find the longest known L1 prefix
for (let i = Math.min(parts.length, 3); i >= 1; i--) {
const candidate = parts.slice(0, i).join('_')
if (['access_control', 'audit_logging', 'key_management', 'risk_management',
'network_security', 'network_segmentation', 'multi_factor_auth',
'transport_encryption', 'data_subject_rights', 'data_breach_notification',
'data_processing_agreement', 'data_processing_register',
'third_party_management', 'change_management', 'human_resources_security',
'physical_security', 'secure_development', 'api_security',
'input_validation', 'container_security', 'logging_configuration',
'cookie_consent', 'video_surveillance', 'supply_chain_due_diligence',
'critical_infrastructure', 'sustainability_reporting',
'financial_reporting', 'consumer_protection', 'compliance_audit',
'asset_management', 'disaster_recovery', 'patch_management',
'password_policy', 'session_management', 'privileged_access',
'certificate_management', 'personal_data', 'sensitive_data',
'health_data', 'product_safety', 'medical_device', 'payment_services',
'supervisory_authority', 'data_retention', 'data_transfer',
'data_classification', 'privacy_by_design',
].includes(candidate)) return candidate
}
return parts[0]
}
return (
<div className="min-h-screen bg-gray-50 py-8">
<div className="max-w-7xl mx-auto px-4">
<div className="mb-6">
<h1 className="text-3xl font-bold text-gray-900">Master Controls</h1>
<p className="text-gray-600 mt-1">
{total.toLocaleString()} Master Controls mit {mcs.reduce((s, m) => s + m.total_controls, 0).toLocaleString()}+ Atomic Controls
</p>
</div>
{/* Search + Filters */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-4 mb-4">
<div className="flex items-center gap-3">
<input
type="text"
placeholder="Suche nach MC-Name (z.B. encryption, incident, access_control)..."
value={search}
onChange={e => { setSearch(e.target.value); setOffset(0) }}
className="flex-1 px-4 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
/>
<span className="text-sm text-gray-500 whitespace-nowrap">
Seite {currentPage} von {totalPages}
</span>
</div>
</div>
<div className="flex gap-4">
{/* MC List */}
<div className={`${selectedMC ? 'w-1/2' : 'w-full'} transition-all`}>
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
<table className="w-full">
<thead className="bg-gray-50 border-b">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase cursor-pointer hover:text-purple-600"
onClick={() => handleSort('canonical_name')}>
Name {sortBy === 'canonical_name' ? (sortOrder === 'ASC' ? '↑' : '↓') : ''}
</th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase cursor-pointer hover:text-purple-600"
onClick={() => handleSort('total_controls')}>
Controls {sortBy === 'total_controls' ? (sortOrder === 'ASC' ? '↑' : '↓') : ''}
</th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Phasen</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{loading ? (
<tr><td colSpan={3} className="px-4 py-8 text-center text-gray-400">Laden...</td></tr>
) : mcs.map(mc => {
const l1 = getL1Token(mc.canonical_name)
const l2 = mc.canonical_name.slice(l1.length + 1) || ''
return (
<tr
key={mc.id}
onClick={() => loadMembers(mc)}
className={`cursor-pointer hover:bg-purple-50 transition-colors ${
selectedMC?.id === mc.id ? 'bg-purple-100' : ''
}`}
>
<td className="px-4 py-3">
<span className="text-xs font-mono bg-gray-100 text-gray-600 px-1.5 py-0.5 rounded">
{l1}
</span>
{l2 && (
<span className="ml-1.5 text-sm text-gray-700">{l2.replace(/_/g, ' ')}</span>
)}
</td>
<td className="px-4 py-3 text-right text-sm font-medium text-gray-900">
{mc.total_controls}
</td>
<td className="px-4 py-3 text-right text-sm text-gray-500">
{(mc.phases_covered || []).length}
</td>
</tr>
)
})}
</tbody>
</table>
{/* Pagination */}
<div className="flex items-center justify-between px-4 py-3 border-t bg-gray-50">
<button
onClick={() => setOffset(Math.max(0, offset - PAGE_SIZE))}
disabled={offset === 0}
className="px-3 py-1 text-sm border rounded disabled:opacity-50"
>
Zurueck
</button>
<span className="text-xs text-gray-500">
{offset + 1}-{Math.min(offset + PAGE_SIZE, total)} von {total}
</span>
<button
onClick={() => setOffset(offset + PAGE_SIZE)}
disabled={offset + PAGE_SIZE >= total}
className="px-3 py-1 text-sm border rounded disabled:opacity-50"
>
Weiter
</button>
</div>
</div>
</div>
{/* Member Detail Panel */}
{selectedMC && (
<div className="w-1/2">
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden sticky top-4">
<div className="px-4 py-3 bg-purple-50 border-b flex items-center justify-between">
<div>
<h2 className="font-semibold text-purple-900">{selectedMC.canonical_name}</h2>
<p className="text-xs text-purple-600">{selectedMC.total_controls} Controls, {(selectedMC.phases_covered || []).length} Phasen</p>
</div>
<button onClick={() => setSelectedMC(null)} className="text-purple-400 hover:text-purple-600 text-lg">&times;</button>
</div>
<div className="max-h-[70vh] overflow-y-auto">
{membersLoading ? (
<div className="p-8 text-center text-gray-400">Laden...</div>
) : members.map((m, i) => (
<div key={i} className="px-4 py-3 border-b border-gray-50 hover:bg-gray-50">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-mono text-gray-400">{m.control_id}</span>
{m.severity && (
<span className={`px-1.5 py-0.5 rounded text-[10px] font-bold ${SEV_COLORS[m.severity] || ''}`}>
{m.severity}
</span>
)}
<span className="text-[10px] text-gray-400 bg-gray-100 px-1.5 py-0.5 rounded">{m.phase}</span>
</div>
<p className="text-sm text-gray-900">{m.title}</p>
{m.regulation_source && (
<p className="text-xs text-blue-600 mt-1">{m.regulation_source} {m.regulation_article}</p>
)}
</div>
))}
{members.length === 0 && !membersLoading && (
<div className="p-8 text-center text-gray-400">Keine Members gefunden</div>
)}
</div>
</div>
</div>
)}
</div>
if (state.loading && state.controls.length === 0) {
return (
<div className="flex items-center justify-center h-96">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-purple-600 border-t-transparent" />
</div>
</div>
)
}
if (state.error) {
return (
<div className="flex items-center justify-center h-96">
<p className="text-red-600">{state.error}</p>
</div>
)
}
// DETAIL mode
if (state.mode === 'detail' && state.selectedControl) {
return (
<ControlDetail
ctrl={state.selectedControl}
onBack={() => { state.setMode('list'); state.setSelectedControl(null) }}
onEdit={() => {}}
onDelete={async () => {}}
onReview={async () => {}}
onRefresh={state.fullReload}
onCompare={() => {}}
onNavigateToControl={async (controlId: string) => {
try {
const res = await fetch(`${BACKEND_URL}?endpoint=control&id=${controlId}`)
if (res.ok) { state.setSelectedControl(await res.json()); state.setMode('detail') }
} catch { /* ignore */ }
}}
/>
)
}
// LIST mode — exact same UI as Control Library
return (
<ControlListView
frameworks={state.frameworks}
controls={state.controls}
totalCount={state.totalCount}
meta={state.meta}
loading={state.loading}
reviewCount={0}
bulkProcessing={false}
showStats={state.showStats}
processedStats={state.processedStats}
showGenerator={false}
currentPage={state.currentPage}
totalPages={state.totalPages}
sortBy={state.sortBy}
searchQuery={state.searchQuery}
severityFilter={state.severityFilter}
domainFilter={state.domainFilter}
stateFilter={state.stateFilter}
verificationFilter={state.verificationFilter}
categoryFilter={state.categoryFilter}
evidenceTypeFilter={state.evidenceTypeFilter}
audienceFilter={state.audienceFilter}
sourceFilter={state.sourceFilter}
typeFilter={state.typeFilter}
hideDuplicates={state.hideDuplicates}
setSearchQuery={state.setSearchQuery}
setSeverityFilter={state.setSeverityFilter}
setDomainFilter={state.setDomainFilter}
setStateFilter={state.setStateFilter}
setVerificationFilter={state.setVerificationFilter}
setCategoryFilter={state.setCategoryFilter}
setEvidenceTypeFilter={state.setEvidenceTypeFilter}
setAudienceFilter={state.setAudienceFilter}
setSourceFilter={state.setSourceFilter}
setTypeFilter={state.setTypeFilter}
setHideDuplicates={state.setHideDuplicates}
setSortBy={state.setSortBy}
setShowStats={state.setShowStats}
setShowGenerator={() => {}}
setCurrentPage={state.setCurrentPage}
onSelectControl={(ctrl) => { state.setSelectedControl(ctrl); state.setMode('detail') }}
onCreateMode={() => {}}
onEnterReview={() => {}}
onBulkReject={async () => {}}
onRefresh={() => { state.loadControls(); state.loadMeta() }}
onLoadStats={state.loadProcessedStats}
onFullReload={state.fullReload}
/>
)
}
@@ -38,21 +38,6 @@ export function SidebarModuleList({ collapsed, projectId, pendingCRCount }: Side
<AdditionalModuleItem href="/sdk/email-templates" icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg>} label="E-Mail-Templates" isActive={pathname === '/sdk/email-templates'} collapsed={collapsed} projectId={projectId} />
</div>
{/* Master Controls Browser */}
<AdditionalModuleItem
href="/sdk/master-controls"
icon={
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
}
label="Master Controls"
isActive={pathname?.startsWith('/sdk/master-controls') ?? false}
collapsed={collapsed}
projectId={projectId}
/>
{/* Maschinenrecht / CE */}
<div className="border-t border-gray-100 py-2">
{!collapsed && (
@@ -18,6 +18,21 @@ interface SidebarModuleNavProps {
export function SidebarModuleNav({ pathname, collapsed, projectId, pendingCRCount }: SidebarModuleNavProps) {
return (
<>
{/* Master Controls */}
<AdditionalModuleItem
href="/sdk/master-controls"
icon={
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
}
label="Master Controls"
isActive={pathname?.startsWith('/sdk/master-controls') ?? false}
collapsed={collapsed}
projectId={projectId}
/>
{/* Maschinenrecht / CE */}
<div className="border-t border-gray-100 py-2">
{!collapsed && (
@@ -466,6 +466,20 @@ export const SDK_STEPS: SDKStep[] = [
prerequisiteSteps: [],
isOptional: true,
},
{
id: 'master-controls',
seq: 4925,
phase: 2,
package: 'betrieb',
order: 11.5,
name: 'Master Controls',
nameShort: 'MCs',
description: '13.588 Master Controls — gruppierte Compliance-Anforderungen nach Thema und Regulierung',
url: '/sdk/master-controls',
checkpointId: 'CP-MC',
prerequisiteSteps: [],
isOptional: true,
},
{
id: 'control-provenance',
seq: 4950,
@@ -0,0 +1,439 @@
"""
Unified Compliance Check Routes check all documents in one request.
POST /compliance/agent/extract-text extract text from a URL
POST /compliance/agent/compliance-check unified check for all documents
GET /compliance/agent/compliance-check/{check_id} poll status
"""
import asyncio
import logging
import os
import uuid as _uuid
from dataclasses import asdict
from datetime import datetime, timezone
import httpx
from fastapi import APIRouter
from pydantic import BaseModel
from compliance.services.smtp_sender import send_email
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/compliance/agent", tags=["agent"])
CONSENT_TESTER_URL = "http://bp-compliance-consent-tester:8094"
# In-memory job store (same pattern as doc-check)
_compliance_check_jobs: dict[str, dict] = {}
# ── Models ───────────────────────────────────────────────────────────
class ExtractTextRequest(BaseModel):
url: str
class DocumentInput(BaseModel):
doc_type: str # dse, agb, impressum, cookie, widerruf, avv, loeschkonzept, etc.
url: str = ""
text: str = "" # text has priority over URL
class ComplianceCheckRequest(BaseModel):
documents: list[DocumentInput]
use_agent: bool = False
recipient: str = "dsb@breakpilot.local"
class ComplianceCheckStartResponse(BaseModel):
check_id: str
status: str = "running"
class ComplianceCheckStatusResponse(BaseModel):
check_id: str
status: str
progress: str = ""
result: dict | None = None
error: str = ""
# ── Extract text endpoint ────────────────────────────────────────────
@router.post("/extract-text")
async def extract_text(req: ExtractTextRequest):
"""Extract text from a URL via consent-tester DSI discovery."""
try:
async with httpx.AsyncClient(timeout=90.0) as client:
resp = await client.post(
f"{CONSENT_TESTER_URL}/dsi-discovery",
json={"url": req.url, "max_documents": 1},
)
if resp.status_code != 200:
return {
"text": "", "word_count": 0, "title": "",
"error": f"HTTP {resp.status_code} von Consent-Tester",
}
data = resp.json()
docs = data.get("documents", [])
if not docs:
return {
"text": "", "word_count": 0, "title": "",
"error": "Kein Text extrahierbar",
}
doc = docs[0]
text = doc.get("full_text", "") or doc.get("text_preview", "") or doc.get("text", "")
title = doc.get("title", "") or doc.get("doc_type", "")
word_count = doc.get("word_count", 0) or len(text.split())
return {
"text": text,
"word_count": word_count,
"title": title,
"error": "",
}
except Exception as e:
logger.warning("extract-text failed for %s: %s", req.url, e)
return {
"text": "", "word_count": 0, "title": "",
"error": str(e)[:200],
}
# ── Unified compliance check ────────────────────────────────────────
@router.post("/compliance-check")
async def start_compliance_check(req: ComplianceCheckRequest):
"""Start async compliance check for all documents."""
check_id = str(_uuid.uuid4())[:8]
_compliance_check_jobs[check_id] = {
"status": "running",
"progress": "Pruefung gestartet...",
"result": None,
"error": "",
}
asyncio.create_task(_run_compliance_check(check_id, req))
return ComplianceCheckStartResponse(check_id=check_id, status="running")
@router.get("/compliance-check/{check_id}")
async def get_compliance_check_status(check_id: str):
"""Poll compliance check status."""
job = _compliance_check_jobs.get(check_id)
if not job:
return {"check_id": check_id, "status": "not_found"}
return ComplianceCheckStatusResponse(
check_id=check_id,
status=job["status"],
progress=job.get("progress", ""),
result=job.get("result"),
error=job.get("error", ""),
)
async def _run_compliance_check(check_id: str, req: ComplianceCheckRequest):
"""Background task: check all documents with business-profile context."""
try:
from compliance.services.business_profiler import detect_business_profile
from compliance.services.doc_checks.runner import check_document_completeness
from compliance.services.rag_document_checker import check_document_with_controls
from .agent_doc_check_routes import CheckItem, DocCheckResult
from .agent_doc_check_report import build_html_report
# Step 1: Resolve texts (fetch from URL if needed)
_update(check_id, "Texte werden geladen...")
doc_texts: dict[str, str] = {}
doc_entries: list[dict] = []
for i, doc in enumerate(req.documents):
_update(check_id, f"Dokument {i+1}/{len(req.documents)}: {doc.doc_type}...")
text = doc.text
if not text and doc.url:
text = await _fetch_text(doc.url)
if text:
doc_texts[doc.doc_type] = text
doc_entries.append({
"doc_type": doc.doc_type,
"url": doc.url,
"text": text,
"word_count": len(text.split()) if text else 0,
})
# Step 2: Detect business profile
_update(check_id, "Geschaeftsmodell wird erkannt...")
profile = await detect_business_profile(doc_texts)
profile_dict = asdict(profile)
# Step 3: Check each document
results: list[DocCheckResult] = []
total_findings = 0
use_agent_flag = req.use_agent or os.getenv(
"COMPLIANCE_USE_AGENT", "false"
).lower() == "true"
for i, entry in enumerate(doc_entries):
text = entry["text"]
doc_type = entry["doc_type"]
label = _doc_type_label(doc_type)
url = entry["url"]
_update(check_id, f"Pruefe {label} ({i+1}/{len(doc_entries)})...")
if not text or len(text) < 50:
results.append(DocCheckResult(
label=label, url=url, doc_type=doc_type,
error="Kein Text vorhanden oder zu kurz",
))
continue
result = await _check_single(
text, doc_type, label, url,
entry["word_count"], use_agent_flag,
)
# Apply profile context filter
result = _apply_profile_filter(result, profile, doc_type)
results.append(result)
total_findings += result.findings_count
# Step 4: Build report
_update(check_id, "Report wird erstellt...")
report_html = build_html_report(results, None)
# Prepend profile summary to report
profile_html = _build_profile_html(profile)
full_html = profile_html + report_html
# Step 5: Send email
doc_count = len([r for r in results if not r.error])
email_result = send_email(
recipient=req.recipient,
subject=f"[COMPLIANCE-CHECK] {doc_count} Dokumente geprueft",
body_html=full_html,
)
# Step 6: Store result
response = {
"results": [_result_to_dict(r) for r in results],
"business_profile": profile_dict,
"total_documents": len(results),
"total_findings": total_findings,
"email_status": email_result.get("status", "failed"),
"checked_at": datetime.now(timezone.utc).isoformat(),
}
_compliance_check_jobs[check_id]["status"] = "completed"
_compliance_check_jobs[check_id]["result"] = response
_compliance_check_jobs[check_id]["progress"] = "Fertig"
except Exception as e:
logger.error("Compliance check %s failed: %s", check_id, e, exc_info=True)
_compliance_check_jobs[check_id]["status"] = "failed"
_compliance_check_jobs[check_id]["error"] = str(e)[:500]
def _update(check_id: str, msg: str):
_compliance_check_jobs[check_id]["progress"] = msg
async def _fetch_text(url: str) -> str:
"""Fetch text from URL via consent-tester."""
try:
async with httpx.AsyncClient(timeout=90.0) as client:
resp = await client.post(
f"{CONSENT_TESTER_URL}/dsi-discovery",
json={"url": url, "max_documents": 1},
)
if resp.status_code != 200:
return ""
docs = resp.json().get("documents", [])
if not docs:
return ""
doc = docs[0]
return doc.get("full_text", "") or doc.get("text_preview", "") or ""
except Exception as e:
logger.warning("Text fetch failed for %s: %s", url, e)
return ""
async def _check_single(
text: str, doc_type: str, label: str, url: str,
word_count: int, use_agent: bool,
):
"""Run regex + MC checks on a single document."""
from compliance.services.doc_checks.runner import check_document_completeness
from compliance.services.rag_document_checker import check_document_with_controls
from .agent_doc_check_routes import CheckItem, DocCheckResult
# Regex checklist
findings = check_document_completeness(text, doc_type, label, url)
all_checks: list[CheckItem] = []
completeness = 0
correctness = 0
for f in findings:
if "SCORE" in f.get("code", ""):
for c in f.get("all_checks", []):
all_checks.append(CheckItem(
id=c["id"], label=c["label"], passed=c["passed"],
severity=c["severity"], matched_text=c.get("matched_text", ""),
level=c.get("level", 1), parent=c.get("parent"),
skipped=c.get("skipped", False), hint=c.get("hint", ""),
))
completeness = f.get("completeness_pct", 0)
correctness = f.get("correctness_pct", 0)
# Master Control checks
try:
mc_results = await check_document_with_controls(
text, doc_type, label, max_controls=0, use_agent=use_agent,
)
if mc_results:
for mc in mc_results:
all_checks.append(CheckItem(**mc))
l2 = [c for c in all_checks if c.level == 2 and not c.skipped]
l2_passed = sum(1 for c in l2 if c.passed)
correctness = round(l2_passed / len(l2) * 100) if l2 else 0
except Exception as e:
logger.warning("MC check skipped for %s: %s", label, e)
# LLM verification of regex fails
failed = [c for c in all_checks if not c.passed and not c.skipped and c.hint]
if failed:
try:
from compliance.services.doc_checks.llm_verify import verify_failed_checks
overturns = await verify_failed_checks(
text,
[{"id": c.id, "label": c.label, "hint": c.hint} for c in failed],
label,
)
for c in all_checks:
if c.id in overturns and overturns[c.id]["overturned"]:
c.passed = True
c.matched_text = f"[LLM] {overturns[c.id]['evidence']}"
l2_active = [c for c in all_checks if c.level == 2 and not c.skipped]
l2_passed = sum(1 for c in l2_active if c.passed)
if l2_active:
correctness = round(l2_passed / len(l2_active) * 100)
except Exception as e:
logger.warning("LLM verification skipped: %s", e)
non_score = [f for f in findings if "SCORE" not in f.get("code", "")]
return DocCheckResult(
label=label, url=url, doc_type=doc_type,
word_count=word_count or len(text.split()),
completeness_pct=completeness, correctness_pct=correctness,
checks=all_checks, findings_count=len(non_score),
)
def _apply_profile_filter(result, profile, doc_type: str):
"""Adjust INFO-level checks based on business profile context.
For example: ODR check only relevant for B2C online shops.
"""
from .agent_doc_check_routes import CheckItem
for check in result.checks:
cid = check.id.lower()
# ODR/OS-Link only relevant for B2C online shops
if "odr" in cid or "os-link" in cid or "streitbeilegung" in check.label.lower():
if not profile.needs_odr:
check.skipped = True
check.hint = "Nicht relevant (kein B2C Online-Shop)"
# Widerruf only relevant for B2C
if doc_type == "widerruf" and profile.business_type not in ("b2c", "unknown"):
if check.severity == "INFO":
check.skipped = True
# Regulated profession: check for Kammer info
if "kammer" in cid or "berufsordnung" in check.label.lower():
if not profile.is_regulated_profession:
check.skipped = True
check.hint = "Nicht relevant (kein regulierter Beruf)"
return result
# ── Helpers ──────────────────────────────────────────────────────────
_DOC_TYPE_LABELS = {
"dse": "Datenschutzerklaerung",
"datenschutz": "Datenschutzerklaerung",
"privacy": "Datenschutzerklaerung",
"impressum": "Impressum",
"agb": "AGB",
"widerruf": "Widerrufsbelehrung",
"cookie": "Cookie-Richtlinie",
"avv": "Auftragsverarbeitung",
"loeschkonzept": "Loeschkonzept",
"dsfa": "Datenschutz-Folgenabschaetzung",
"social_media": "Social Media Datenschutz",
}
def _doc_type_label(doc_type: str) -> str:
return _DOC_TYPE_LABELS.get(doc_type, doc_type.upper())
def _result_to_dict(r) -> dict:
"""Convert DocCheckResult to JSON-serializable dict."""
return {
"label": r.label, "url": r.url, "doc_type": r.doc_type,
"word_count": r.word_count, "completeness_pct": r.completeness_pct,
"correctness_pct": r.correctness_pct,
"checks": [
{
"id": c.id, "label": c.label, "passed": c.passed,
"severity": c.severity, "matched_text": c.matched_text,
"level": c.level, "parent": c.parent,
"skipped": c.skipped, "hint": c.hint,
}
for c in r.checks
],
"findings_count": r.findings_count, "error": r.error,
}
def _build_profile_html(profile) -> str:
"""Build a small HTML block summarizing the detected business profile."""
service_tags = ", ".join(profile.detected_services[:10]) or "keine erkannt"
flags = []
if profile.has_online_shop:
flags.append("Online-Shop")
if profile.has_editorial_content:
flags.append("Redaktionelle Inhalte")
if profile.is_regulated_profession:
flags.append(f"Regulierter Beruf ({profile.regulated_profession_type})")
if profile.needs_odr:
flags.append("ODR-pflichtig")
flags_str = ", ".join(flags) or "keine"
return (
'<div style="font-family:-apple-system,BlinkMacSystemFont,sans-serif;'
'max-width:700px;margin:0 auto 16px;padding:12px 16px;'
'background:#f0f9ff;border:1px solid #bae6fd;border-radius:8px">'
'<h3 style="margin:0 0 8px;font-size:14px;color:#0369a1">'
'Erkanntes Geschaeftsmodell</h3>'
'<table style="font-size:13px;color:#374151">'
f'<tr><td style="padding:2px 12px 2px 0;color:#6b7280">Typ:</td>'
f'<td><strong>{profile.business_type.upper()}</strong>'
f' ({profile.industry})</td></tr>'
f'<tr><td style="padding:2px 12px 2px 0;color:#6b7280">Merkmale:</td>'
f'<td>{flags_str}</td></tr>'
f'<tr><td style="padding:2px 12px 2px 0;color:#6b7280">Dienste:</td>'
f'<td>{service_tags}</td></tr>'
f'<tr><td style="padding:2px 12px 2px 0;color:#6b7280">Konfidenz:</td>'
f'<td>{int(profile.confidence * 100)}%</td></tr>'
'</table></div>'
)
@@ -0,0 +1,223 @@
"""
Business Profiler detect business model from document texts.
Pure keyword-based detection (deterministic, no LLM). Analyzes
DSE, Impressum, AGB, Widerruf etc. together to build a profile
that drives context-aware compliance checks.
Example:
profile = await detect_business_profile({"dse": "...", "impressum": "..."})
profile.business_type # "b2c"
profile.has_online_shop # True
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
@dataclass
class BusinessProfile:
business_type: str = "unknown" # b2b, b2c, b2g, nonprofit, unknown
industry: str = "unknown" # it_services, retail, healthcare, legal, craft, public, unknown
has_online_shop: bool = False
has_editorial_content: bool = False
is_regulated_profession: bool = False
regulated_profession_type: str = "" # arzt, anwalt, steuerberater, architekt, ""
needs_odr: bool = False # Online-Streitbeilegung
detected_services: list[str] = field(default_factory=list)
confidence: float = 0.0
# ── Keyword lists ────────────────────────────────────────────────────
_B2C_KEYWORDS = [
"verbraucher", "warenkorb", "bestellung", "lieferung", "widerruf",
"shop", "kaufpreis", "rueckgabe", "rückgabe", "endkunde", "kaeufer",
"käufer", "privatkunde", "zahlungspflichtig bestellen",
]
_B2B_KEYWORDS = [
"unternehmen", "geschaeftskunden", "geschäftskunden", "gewerblich",
"auftrag", "auftraggeber", "auftragnehmer", "geschaeftspartner",
"geschäftspartner", "firmenkunde", "b2b",
]
_B2G_KEYWORDS = [
"behoerde", "behörde", "koerperschaft", "körperschaft", "oeffentlich",
"öffentlich", "gemeinde", "amt", "stadtverwaltung", "landesbehoerde",
"landesbehörde", "kommunal",
]
_NONPROFIT_KEYWORDS = [
"gemeinnuetzig", "gemeinnützig", "verein", "stiftung", "e.v.",
"spende", "ehrenamtlich", "satzung",
]
_REGULATED_PROFESSIONS = {
"rechtsanwalt": "anwalt",
"anwalt": "anwalt",
"anwaeltin": "anwalt",
"anwältin": "anwalt",
"kanzlei": "anwalt",
"rechtsanwaltskammer": "anwalt",
"arzt": "arzt",
"ärztin": "arzt",
"aerztin": "arzt",
"praxis": "arzt",
"aerztekammer": "arzt",
"ärztekammer": "arzt",
"steuerberater": "steuerberater",
"steuerberaterin": "steuerberater",
"steuerberaterkammer": "steuerberater",
"architekt": "architekt",
"architektin": "architekt",
"architektenkammer": "architekt",
"notar": "notar",
"notariat": "notar",
"apotheke": "apotheker",
"apotheker": "apotheker",
}
_ONLINE_SHOP_KEYWORDS = [
"warenkorb", "checkout", "bestellung", "lieferung", "versand",
"paypal", "kreditkarte", "klarna", "sofortueberweisung",
"sofortüberweisung", "zahlungsarten", "versandkosten",
"lieferzeit", "retour", "paketdienst",
]
_EDITORIAL_KEYWORDS = [
"blog", "ratgeber", "news", "redaktion", "artikel", "magazin",
"beitrag", "kommentar", "podcast", "newsletter", "autor",
]
_INDUSTRY_KEYWORDS = {
"it_services": ["software", "saas", "cloud", "hosting", "server", "api", "app"],
"retail": ["shop", "warenkorb", "versand", "lieferung", "einzelhandel"],
"healthcare": ["arzt", "praxis", "patient", "gesundheit", "therapie", "klinik"],
"legal": ["kanzlei", "rechtsanwalt", "mandant", "anwalt"],
"craft": ["handwerk", "meister", "werkstatt", "montage", "gewerk"],
"public": ["behoerde", "behörde", "kommune", "verwaltung", "buerger", "bürger"],
"finance": ["bank", "versicherung", "finanz", "kredit", "anlage"],
"education": ["schule", "bildung", "unterricht", "lehrplan", "schueler", "schüler"],
}
_TRACKING_SERVICES = {
"google analytics": "Google Analytics",
"google tag manager": "Google Tag Manager",
"matomo": "Matomo",
"facebook pixel": "Facebook Pixel",
"meta pixel": "Meta Pixel",
"hotjar": "Hotjar",
"hubspot": "HubSpot",
"mailchimp": "Mailchimp",
"linkedin insight": "LinkedIn Insight",
"google ads": "Google Ads",
"google adsense": "Google AdSense",
"google maps": "Google Maps",
"youtube": "YouTube",
"vimeo": "Vimeo",
"cloudflare": "Cloudflare",
"sentry": "Sentry",
"intercom": "Intercom",
"zendesk": "Zendesk",
"stripe": "Stripe",
"paypal": "PayPal",
}
# ── Detection logic ──────────────────────────────────────────────────
def _count_hits(text: str, keywords: list[str]) -> int:
return sum(1 for kw in keywords if kw in text)
async def detect_business_profile(documents: dict[str, str]) -> BusinessProfile:
"""Analyze all document texts together to detect business model.
Args:
documents: dict mapping doc_type -> text (e.g. {"dse": "...", "impressum": "..."})
"""
profile = BusinessProfile()
if not documents:
return profile
# Merge all texts for keyword search
full_text = "\n".join(documents.values()).lower()
full_text = full_text.replace("\xad", "") # strip soft hyphens
# ── Tracking services ────────────────────────────────────────
for pattern, label in _TRACKING_SERVICES.items():
if pattern in full_text:
profile.detected_services.append(label)
# ── Online shop ──────────────────────────────────────────────
shop_hits = _count_hits(full_text, _ONLINE_SHOP_KEYWORDS)
profile.has_online_shop = shop_hits >= 3
# ── Editorial content ────────────────────────────────────────
editorial_hits = _count_hits(full_text, _EDITORIAL_KEYWORDS)
profile.has_editorial_content = editorial_hits >= 2
# ── Regulated profession ─────────────────────────────────────
for keyword, prof_type in _REGULATED_PROFESSIONS.items():
if keyword in full_text:
profile.is_regulated_profession = True
profile.regulated_profession_type = prof_type
break
# ── Business type ────────────────────────────────────────────
b2c_score = _count_hits(full_text, _B2C_KEYWORDS)
b2b_score = _count_hits(full_text, _B2B_KEYWORDS)
b2g_score = _count_hits(full_text, _B2G_KEYWORDS)
nonprofit_score = _count_hits(full_text, _NONPROFIT_KEYWORDS)
# Missing documents as signal
has_agb = "agb" in documents
has_widerruf = "widerruf" in documents
if not has_agb:
b2c_score -= 1 # No AGB → less likely B2C
if not has_widerruf:
b2c_score -= 1 # No Widerruf → less likely B2C shop
if profile.has_online_shop:
b2c_score += 3 # Strong B2C signal
scores = {
"b2c": b2c_score,
"b2b": b2b_score,
"b2g": b2g_score,
"nonprofit": nonprofit_score,
}
best = max(scores, key=scores.get) # type: ignore[arg-type]
best_val = scores[best]
if best_val >= 2:
profile.business_type = best
total = sum(max(0, v) for v in scores.values())
profile.confidence = round(best_val / total, 2) if total > 0 else 0.5
else:
profile.business_type = "unknown"
profile.confidence = 0.2
# ── ODR (Online-Streitbeilegung) ─────────────────────────────
# Required for B2C with online shop (EU Regulation 524/2013)
profile.needs_odr = (
profile.business_type == "b2c" and profile.has_online_shop
)
# ── Industry ─────────────────────────────────────────────────
industry_scores: dict[str, int] = {}
for industry, keywords in _INDUSTRY_KEYWORDS.items():
hits = _count_hits(full_text, keywords)
if hits >= 2:
industry_scores[industry] = hits
if industry_scores:
profile.industry = max(industry_scores, key=industry_scores.get) # type: ignore[arg-type]
elif profile.is_regulated_profession:
prof_map = {"anwalt": "legal", "arzt": "healthcare",
"steuerberater": "finance", "architekt": "craft"}
profile.industry = prof_map.get(profile.regulated_profession_type, "unknown")
return profile
@@ -3,6 +3,10 @@ Impressum checks — §5 TMG / §18 MStV.
Level 1: Pflichtangabe erwaehnt?
Level 2: Pflichtangabe korrekt/vollstaendig?
Checks mit severity "INFO" sind kontextabhaengig sie werden nur
als Hinweis angezeigt, nicht als Finding gewertet. Der Pruefer muss
selbst entscheiden ob sie fuer das geprueefte Unternehmen relevant sind.
"""
IMPRESSUM_CHECKLIST = [
@@ -25,7 +29,7 @@ IMPRESSUM_CHECKLIST = [
"label": "Anschrift",
"level": 1, "parent": None,
"patterns": [
r"(?:str(?:asse|\.)|weg|platz|allee)\s*\d",
r"(?:str(?:asse|\.)|weg|platz|allee)\s*\.?\s*\d",
r"d-\d{5}", r"\d{5}\s+\w+",
],
"severity": "HIGH",
@@ -39,7 +43,7 @@ IMPRESSUM_CHECKLIST = [
r"(?:d[\-\s]?)?\d{5}\s+[a-z\u00c0-\u017e]\w{2,}",
],
"severity": "MEDIUM",
"hint": "Ohne PLZ und Ort ist die Anschrift nicht ladungsfaehig und damit unvollstaendig i.S.d. §5 TMG. Haeufiger Fehler: Nur Strasse und Hausnummer ohne PLZ/Ort, oder PLZ ohne Ortsangabe.",
"hint": "Ohne PLZ und Ort ist die Anschrift nicht ladungsfaehig und damit unvollstaendig i.S.d. §5 TMG.",
},
{
"id": "address_street_number",
@@ -50,7 +54,7 @@ IMPRESSUM_CHECKLIST = [
r"\w+\s+(?:str|stra(?:ss|ß)e|weg|platz|allee)\s*\.?\s*\d+",
],
"severity": "MEDIUM",
"hint": "Strasse + Hausnummer fehlen oder sind unvollstaendig. Ohne Hausnummer keine Zustellbarkeit — das ist ein klassischer Abmahngrund bei Impressumspruefungen nach §5 TMG. Auch 'Am Markt' ohne Nummer genuegt nicht.",
"hint": "Strasse + Hausnummer fehlen oder sind unvollstaendig. Ohne Hausnummer keine Zustellbarkeit — klassischer Abmahngrund.",
},
# ── L1: Kontaktdaten ──────────────────────────────────────────────
@@ -60,7 +64,7 @@ IMPRESSUM_CHECKLIST = [
"level": 1, "parent": None,
"patterns": [
r"(?:e-?mail|mail).*@", r"telefon|phone|tel\.",
r"\+?\d[\d\s/\-]{8,}",
r"\+?\d[\d\s/\-\(\)]{8,}",
],
"severity": "HIGH",
"hint": "§5(1) Nr.2 TMG verlangt Angaben fuer 'schnelle elektronische Kontaktaufnahme und unmittelbare Kommunikation': E-Mail ist Pflicht. EuGH (C-298/17): Telefon nicht zwingend, aber ein zweiter unmittelbarer Kanal (Telefon, Fax oder Chat) ist erforderlich.",
@@ -73,19 +77,20 @@ IMPRESSUM_CHECKLIST = [
r"[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}",
],
"severity": "MEDIUM",
"hint": "E-Mail-Adresse fehlt oder ist nicht als solche erkennbar. Ein reines Kontaktformular genuegt laut OLG Hamm (4 U 59/20) NICHT als Ersatz — die E-Mail-Adresse muss direkt im Impressum als Text sichtbar sein.",
"hint": "E-Mail-Adresse muss direkt im Impressum als Text sichtbar sein. Ein reines Kontaktformular genuegt laut OLG Hamm (4 U 59/20) NICHT.",
},
{
"id": "contact_phone_format",
"label": "Telefonnummer vorhanden",
"level": 2, "parent": "contact",
"patterns": [
r"(?:tel(?:efon)?|phone|fon)\s*[.:]\s*[\+\d][\d\s/\-]{6,}",
r"\+49\s*[\d\s/\-]{8,}",
r"0\d{2,4}\s*[/\-\s]\s*\d{4,}",
r"(?:tel(?:efon)?|phone|fon)\s*[.:]\s*[\+\d][\d\s/\-\(\)]{6,}",
r"\+49\s*[\d\s/\-\(\)]{8,}",
r"0\d{2,4}\s*[/\-\s]\s*[\d\s\-]{4,}",
r"(?:telefon|tel\.?|phone|fon)\s+\d[\d\s/\-]{6,}",
],
"severity": "MEDIUM",
"hint": "Telefonnummer mit Vorwahl angeben (z.B. '+49 30 12345678'). Falls kein Telefon: Ein alternativer unmittelbarer Kommunikationskanal (Chat, Messenger) ist laut EuGH (C-298/17) noetig — Kontaktformular allein genuegt nicht.",
"hint": "Telefonnummer mit Vorwahl angeben (z.B. '+49 30 12345678' oder '0761 / 489 809 01'). Falls kein Telefon: Ein alternativer unmittelbarer Kommunikationskanal ist laut EuGH (C-298/17) noetig.",
},
# ── L1: Handelsregister ───────────────────────────────────────────
@@ -96,9 +101,10 @@ IMPRESSUM_CHECKLIST = [
"patterns": [
r"(?:handelsregister|hrb|hra|registergericht|amtsgericht)",
r"register.*(?:nr|nummer)",
r"\bag\s+[a-z\u00c0-\u017e]\w+",
],
"severity": "MEDIUM",
"hint": "§5(1) Nr.4 TMG: Bei Eintragung im Handels-, Vereins-, Partnerschafts- oder Genossenschaftsregister muessen Registergericht UND Registernummer angegeben werden. Haeufiger Fehler: GmbH ohne HR-Angabe — das ist abmahnfaehig.",
"hint": "§5(1) Nr.4 TMG: Bei Eintragung im Handels-, Vereins-, Partnerschafts- oder Genossenschaftsregister muessen Registergericht UND Registernummer angegeben werden.",
},
{
"id": "register_court",
@@ -106,10 +112,11 @@ IMPRESSUM_CHECKLIST = [
"level": 2, "parent": "register",
"patterns": [
r"(?:amtsgericht|registergericht)\s+[A-Z\u00c0-\u017e]\w+",
r"ag\s+[A-Z\u00c0-\u017e]\w+",
r"\bag\s+[A-Z\u00c0-\u017e]\w+",
r"(?:handelsregister|register)\s+(?:ag|amtsgericht)\s+\w+",
],
"severity": "LOW",
"hint": "Registernummer ohne Registergericht ist unvollstaendig i.S.d. §5(1) Nr.4 TMG. Korrekt: 'Amtsgericht Muenchen, HRB 12345'. Das Gericht am Sitz der Gesellschaft ist zustaendig — pruefen Sie den aktuellen HR-Auszug.",
"hint": "Registergericht benennen (z.B. 'Amtsgericht Freiburg' oder 'AG Freiburg'). Beides ist korrekt.",
},
{
"id": "register_number",
@@ -119,7 +126,7 @@ IMPRESSUM_CHECKLIST = [
r"(?:hrb|hra)\s*\d+",
],
"severity": "LOW",
"hint": "Registernummer im Format 'HRB 12345' (Kapitalgesellschaften) oder 'HRA 12345' (Personengesellschaften) angeben. Haeufiger Fehler: Steuernummer statt Registernummer — die Steuernummer ersetzt nicht die HR-Angabe nach §5(1) Nr.4 TMG.",
"hint": "Registernummer im Format 'HRB 12345' (Kapitalgesellschaften) oder 'HRA 12345' (Personengesellschaften) angeben.",
},
# ── L1: USt-IdNr ──────────────────────────────────────────────────
@@ -132,7 +139,7 @@ IMPRESSUM_CHECKLIST = [
r"vat.*id", r"de\s*\d{9}",
],
"severity": "MEDIUM",
"hint": "§5(1) Nr.6 TMG: Die USt-IdNr. muss angegeben werden, sofern vorhanden. Haeufiger Fehler: Steuernummer (z.B. '123/456/78901') statt USt-IdNr. (DE123456789) — die Steuernummer ist KEIN Ersatz und sollte aus Datenschutzgruenden nicht im Impressum stehen.",
"hint": "§5(1) Nr.6 TMG: Die USt-IdNr. muss angegeben werden, sofern vorhanden. Die Steuernummer ist KEIN Ersatz.",
},
{
"id": "vat_de_format",
@@ -142,7 +149,7 @@ IMPRESSUM_CHECKLIST = [
r"de\s*\d{9}",
],
"severity": "LOW",
"hint": "Deutsche USt-IdNr.: Laendercode 'DE' + exakt 9 Ziffern (z.B. DE123456789). Haeufiger Fehler: Nur 8 Ziffern, fehlender Laendercode, oder Verwechslung mit Wirtschafts-ID. Validierung: https://evatr.bff-online.de/",
"hint": "Deutsche USt-IdNr.: 'DE' + exakt 9 Ziffern (z.B. DE123456789). Validierung: https://evatr.bff-online.de/",
},
# ── L1: Vertretungsberechtigte ────────────────────────────────────
@@ -151,25 +158,28 @@ IMPRESSUM_CHECKLIST = [
"label": "Vertretungsberechtigte",
"level": 1, "parent": None,
"patterns": [
r"vertretungsberechtigt", r"gesch(?:ae|ä)ftsf(?:ue|ü)hr",
r"vertretungsberechtigt",
r"gesch(?:ae|ä)ftsf(?:ue|ü)hr",
r"vorstand", r"inhaber",
],
"severity": "MEDIUM",
"hint": "§5(1) Nr.1 TMG: Bei juristischen Personen (GmbH, AG, UG, eG) muss der/die Vertretungsberechtigte(n) namentlich benannt werden. Haeufiger Fehler: Nur 'Geschaeftsfuehrung' ohne Personenname — das genuegt nicht, Vor- und Nachname sind Pflicht.",
"hint": "§5(1) Nr.1 TMG: Bei juristischen Personen muss der/die Vertretungsberechtigte(n) namentlich benannt werden.",
},
{
"id": "representative_person",
"label": "Name der vertretungsberechtigten Person",
"level": 2, "parent": "representative",
"patterns": [
r"(?:gesch(?:ae|ä)ftsf(?:ue|ü)hr|vorstand|inhaber)\w*\s*:\s*[A-Z\u00c0-\u017e]",
r"(?:gesch(?:ae|ä)ftsf(?:ue|ü)hr\w*|vorstand|inhaber)\s*:?\s*[A-Z\u00c0-\u017e]",
r"(?:vertreten\s+durch|repr(?:ae|ä)sentiert)\s*:?\s*[A-Z\u00c0-\u017e]",
r"(?:gesch(?:ae|ä)ftsf(?:ue|ü)hrung)\s*:?\s*(?:dr\.?\s+|prof\.?\s+)?[A-Z\u00c0-\u017e]",
],
"severity": "LOW",
"hint": "Voller Vor- und Nachname mit Funktionsbezeichnung erforderlich (z.B. 'Geschaeftsfuehrer: Max Mustermann'). Bei mehreren Geschaeftsfuehrern alle nennen. Haeufiger Fehler: Nur Nachname oder nur 'Die Geschaeftsfuehrung' ohne Namen.",
"hint": "Voller Vor- und Nachname mit Funktionsbezeichnung erforderlich (z.B. 'Geschaeftsfuehrung: Dr. Max Mustermann').",
},
# ── Neue L1: Redaktionell Verantwortlicher ────────────────────────
# ── Kontextabhaengige Checks (INFO — nur Hinweis, kein Finding) ──
{
"id": "editorial_visdp",
"label": "V.i.S.d.P. / Redaktionell Verantwortlicher (§18 MStV)",
@@ -179,11 +189,10 @@ IMPRESSUM_CHECKLIST = [
r"(?:redaktionell|inhaltlich)\s+verantwortlich",
r"§\s*18\s+m(?:edien)?st(?:aat)?v",
],
"severity": "LOW",
"hint": "§18(2) MStV: Bei journalistisch-redaktionellen Inhalten (Blog, Ratgeber, News) muss ein V.i.S.d.P. mit Name und Anschrift benannt werden. Gilt auch fuer Unternehmensblogs. Haeufiger Fehler: V.i.S.d.P. fehlt bei Seiten mit Ratgeber-/Blogartikeln.",
"severity": "INFO",
"hint": "Nur relevant wenn die Website journalistisch-redaktionelle Inhalte hat (Blog, Ratgeber, News, Fachartikel). Reine Unternehmensseiten ohne redaktionelle Inhalte benoetigen keinen V.i.S.d.P. Pruefen Sie, ob die Website einen Blog oder Ratgeber-Bereich hat.",
},
# ── Neue L1: Streitbeilegung ──────────────────────────────────────
{
"id": "dispute_resolution",
"label": "Verbraucherstreitbeilegung / OS-Plattform",
@@ -195,11 +204,10 @@ IMPRESSUM_CHECKLIST = [
r"vsbg|verbraucherstreitbeilegungsgesetz",
r"alternative\s+streitbeilegung",
],
"severity": "LOW",
"hint": "Art. 14(1) ODR-VO + §36 VSBG: Online-Haendler muessen den ODR-Link (https://ec.europa.eu/consumers/odr) als klickbaren Hyperlink einbinden UND erklaeren, ob sie zur Streitbeilegung bereit/verpflichtet sind. Fehlender Link ist abmahnfaehig (LG Bochum, 14 O 21/16).",
"severity": "INFO",
"hint": "Nur relevant fuer B2C-Online-Haendler die Waren oder Dienstleistungen an Verbraucher verkaufen. B2B-Unternehmen ohne Verbrauchergeschaeft sind von §36 VSBG und der ODR-Verordnung nicht betroffen. Pruefen Sie, ob das Unternehmen B2C-Geschaeft betreibt.",
},
# ── L1: Reglementierte Berufe (§5(1) Nr.5 TMG) ───────────────────
{
"id": "regulated_profession",
"label": "Berufsrechtliche Angaben (§5(1) Nr.5 TMG)",
@@ -209,8 +217,8 @@ IMPRESSUM_CHECKLIST = [
r"(?:kammer|berufsordnung|berufsrecht|standesrecht|zulassung)",
r"(?:(?:ae|ä)rztekammer|rechtsanwaltskammer|steuerberaterkammer|architektenkammer|ingenieurkammer)",
],
"severity": "MEDIUM",
"hint": "§5(1) Nr.5 TMG: Bei reglementierten Berufen (Aerzte, Anwaelte, Steuerberater, Architekten) muessen angegeben werden: (1) zustaendige Kammer, (2) gesetzliche Berufsbezeichnung + Staat der Verleihung, (3) berufsrechtliche Regelungen mit Zugangsmoeglichkeit.",
"severity": "INFO",
"hint": "Nur relevant fuer reglementierte Berufe (Aerzte, Anwaelte, Steuerberater, Architekten, Apotheker). Falls das Unternehmen keinen reglementierten Beruf ausueebt, ist dieser Punkt nicht zutreffend.",
},
{
"id": "profession_chamber",
@@ -221,7 +229,7 @@ IMPRESSUM_CHECKLIST = [
r"(?:mitglied|zugelassen|eingetragen)\s+(?:bei|in|der)\s+(?:der\s+)?(?:\w+)?kammer",
],
"severity": "LOW",
"hint": "Nennen Sie die zustaendige Kammer mit vollem Namen und Sitz (z.B. 'Rechtsanwaltskammer Muenchen'). Ohne Kammerangabe fehlt die Zuordnung zur Berufsaufsicht — Verstoss gegen §5(1) Nr.5a TMG.",
"hint": "Zustaendige Kammer mit vollem Namen und Sitz nennen (z.B. 'Rechtsanwaltskammer Muenchen').",
},
{
"id": "profession_title",
@@ -233,7 +241,7 @@ IMPRESSUM_CHECKLIST = [
r"(?:rechtsanwalt|steuerberater|arzt|architekt)\s*(?:\(|,)\s*(?:deutschland|bundesrepublik)",
],
"severity": "LOW",
"hint": "§5(1) Nr.5a TMG: Berufsbezeichnung (z.B. 'Rechtsanwalt') und Staat der Verleihung (z.B. 'Bundesrepublik Deutschland') angeben. Bei EU-auslaendischen Qualifikationen ist der Herkunftsstaat besonders wichtig.",
"hint": "Berufsbezeichnung und Staat der Verleihung angeben.",
},
{
"id": "profession_regulations",
@@ -244,10 +252,9 @@ IMPRESSUM_CHECKLIST = [
r"berufsrecht|standesrecht|berufsrechtliche\s+regelung",
],
"severity": "LOW",
"hint": "§5(1) Nr.5c TMG: Berufsrechtliche Regelungen nennen (BRAO/BORA fuer Anwaelte, MBO-Ae fuer Aerzte, StBerG fuer Steuerberater) und Link zum Volltext bereitstellen (z.B. zur BRAK oder Kammer-Website).",
"hint": "Berufsrechtliche Regelungen nennen und Link zum Volltext bereitstellen.",
},
# ── L1: Grundkapital (§5(1) Nr.1 TMG) ────────────────────────────
{
"id": "share_capital",
"label": "Stammkapital / Grundkapital (GmbH/AG/UG)",
@@ -257,11 +264,10 @@ IMPRESSUM_CHECKLIST = [
r"(?:kapital|einlage)\s*:?\s*(?:eur|euro|\u20ac)\s*[\d\.,]+",
r"[\d\.,]+\s*(?:eur|euro|\u20ac)\s*(?:stammkapital|grundkapital)",
],
"severity": "LOW",
"hint": "§5(1) Nr.1 TMG i.V.m. §35a GmbHG / §80 AktG: GmbH/UG muessen das Stammkapital, AG das Grundkapital angeben. Bei nicht voll eingezahltem Kapital: Gesamtbetrag der ausstehenden Einlagen nennen. Besonders bei UG (haftungsbeschraenkt) haeufig vergessen.",
"severity": "INFO",
"hint": "§35a GmbHG verlangt die Angabe des Stammkapitals auf Geschaeftsbriefen. Ob dies auch fuer Websites gilt, ist umstritten. In der Praxis wird es selten beanstandet. Bei UG (haftungsbeschraenkt) ist die Angabe empfehlenswert, da das geringe Stammkapital fuer Geschaeftspartner relevant ist.",
},
# ── L1: Aufsichtsbehoerde (§5(1) Nr.3 TMG) ──────────────────────
{
"id": "supervisory_authority",
"label": "Aufsichtsbehoerde (genehmigungspflichtige Taetigkeiten)",
@@ -272,11 +278,10 @@ IMPRESSUM_CHECKLIST = [
r"(?:zugelassen|genehmigt|erlaubt)\s+(?:durch|von)\s+(?:der|dem|die)",
r"bafin|§\s*34[cdf]\s+gewo",
],
"severity": "LOW",
"hint": "§5(1) Nr.3 TMG: Bei genehmigungspflichtigen Taetigkeiten (Immobilienmakler §34c GewO, Finanzanlagenvermittler §34f, Versicherungsvermittler §34d, Gastronomie, Bewachung) muss die zustaendige Aufsichtsbehoerde mit Kontaktdaten angegeben werden.",
"severity": "INFO",
"hint": "Nur relevant bei genehmigungspflichtigen Taetigkeiten: Immobilienmakler (§34c GewO), Finanzanlagenvermittler (§34f), Versicherungsvermittler (§34d), Gastronomie, Bewachungsgewerbe. Falls das Unternehmen keine solche Taetigkeit ausueebt, ist dieser Punkt nicht zutreffend.",
},
# ── L1: Berufshaftpflichtversicherung (DL-InfoV) ─────────────────
{
"id": "professional_insurance",
"label": "Berufshaftpflichtversicherung (DL-InfoV §2(1) Nr.11)",
@@ -286,11 +291,10 @@ IMPRESSUM_CHECKLIST = [
r"(?:versicherer|versicherung)\s*:?\s*[A-Z\u00c0-\u017e]",
r"deckungssumme|versicherungsschutz|geltungsbereich",
],
"severity": "LOW",
"hint": "DL-InfoV §2(1) Nr.11: Bei gesetzlicher Pflichtversicherung (Aerzte, Anwaelte, Architekten, Steuerberater, Makler nach §34c GewO) muessen Name + Anschrift des Versicherers und raeumlicher Geltungsbereich angegeben werden.",
"severity": "INFO",
"hint": "Nur relevant bei gesetzlicher Pflichtversicherung (Aerzte, Anwaelte, Architekten, Steuerberater, Makler). Falls das Unternehmen keine Pflichtversicherung benoetigt, ist dieser Punkt nicht zutreffend.",
},
# ── L1: Rechtswidrige Haftungsausschluesse ────────────────────────
{
"id": "illegal_disclaimer",
"label": "Rechtswidriger Haftungsausschluss fuer Links",
@@ -301,6 +305,6 @@ IMPRESSUM_CHECKLIST = [
r"distanzier|macht\s+sich\s+(?:nicht|kein)\s+(?:zu\s+eigen|verantwortlich)",
],
"severity": "LOW",
"hint": "Vorsicht: Der klassische Link-Disclaimer ('Wir distanzieren uns von verlinkten Inhalten') ist seit BGH (I ZR 317/01) rechtlich wirkungslos und wird von Gerichten als Zeichen mangelnder Rechtskenntnis gewertet. Empfehlung: Entfernen Sie pauschale Disclaimer — sie schuetzen nicht und koennen kontraproduktiv sein.",
"hint": "Der klassische Link-Disclaimer ('Wir distanzieren uns von verlinkten Inhalten') ist seit BGH (I ZR 317/01) rechtlich wirkungslos. Empfehlung: Entfernen Sie pauschale Disclaimer — sie schuetzen nicht und koennen kontraproduktiv sein.",
},
]
@@ -111,14 +111,19 @@ def check_document_completeness(
passed_l1_ids: set[str] = set()
all_checks: list[dict] = []
l1_present = 0
l1_scoreable = 0 # Exclude INFO checks from score
for check in l1_checks:
is_info = check.get("severity") == "INFO"
match = _match_patterns(check["patterns"], text_lower)
passed = match is not None
if passed:
passed_l1_ids.add(check["id"])
l1_present += 1
else:
if not is_info:
l1_present += 1
if not is_info:
l1_scoreable += 1
if not passed and not is_info:
findings.append({
"code": f"DSI-MISSING-{check['id'].upper()}",
"severity": check.get("severity", "MEDIUM"),
@@ -175,7 +180,7 @@ def check_document_completeness(
})
# ── Summary ───────────────────────────────────────────────────────
l1_total = len(l1_checks)
l1_total = l1_scoreable # Exclude INFO checks from percentage
completeness_pct = round(l1_present / l1_total * 100) if l1_total else 0
correctness_pct = round(l2_passed / l2_total * 100) if l2_total else 0
+4
View File
@@ -48,6 +48,8 @@ from compliance.api.agent_scan_routes import router as agent_scan_router
from compliance.api.agent_history_routes import router as agent_history_router
from compliance.api.agent_recurring_routes import router as agent_recurring_router
from compliance.api.agent_compare_routes import router as agent_compare_router
from compliance.api.agent_doc_check_routes import router as agent_doc_check_router
from compliance.api.agent_compliance_check_routes import router as agent_compliance_check_router
# Middleware
from middleware import (
@@ -150,6 +152,8 @@ app.include_router(agent_scan_router, prefix="/api")
app.include_router(agent_history_router, prefix="/api")
app.include_router(agent_recurring_router, prefix="/api")
app.include_router(agent_compare_router, prefix="/api")
app.include_router(agent_doc_check_router, prefix="/api")
app.include_router(agent_compliance_check_router, prefix="/api")
if __name__ == "__main__":
+4
View File
@@ -44,6 +44,10 @@ DSI_KEYWORDS: dict[str, list[str]] = {
"widerruf", "rücktrittsrecht",
# Cookie
"cookie-richtlinie", "cookie-policy", "cookie-hinweis",
# Impressum
"impressum", "anbieterkennzeichnung",
# Imprint (EN)
"imprint", "legal notice", "site notice",
],
"en": [
"privacy policy", "privacy notice", "data protection", "data policy",