Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 872145d883 | |||
| 9bdaa28038 | |||
| e2be51b0aa | |||
| bd65b6f318 | |||
| c771d8ecb9 | |||
| 772ff35e8d | |||
| 8cbb513e2c | |||
| 6c35bcf116 | |||
| 19d4b12e07 | |||
| 2e87b74749 | |||
| 94233b7c66 |
@@ -55,5 +55,9 @@ EXPOSE 3000
|
||||
# Set hostname
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
# P83 — Build-SHA fuer check-rebuild-needed.sh
|
||||
ARG BUILD_SHA="unknown"
|
||||
ENV BUILD_SHA=${BUILD_SHA}
|
||||
|
||||
# Start the application
|
||||
CMD ["node", "server.js"]
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { calculateAP } from './useFMEA'
|
||||
|
||||
describe('calculateAP — AIAG-VDA 2019 Handbook Action Priority', () => {
|
||||
it('returns H for severity 10 with mid occurrence', () => {
|
||||
expect(calculateAP(10, 5, 5)).toBe('H')
|
||||
})
|
||||
|
||||
it('returns H for severity 9 with low detection', () => {
|
||||
expect(calculateAP(9, 4, 7)).toBe('H')
|
||||
})
|
||||
|
||||
it('returns M for severity 9 with low occurrence and good detection', () => {
|
||||
expect(calculateAP(9, 2, 5)).toBe('M')
|
||||
})
|
||||
|
||||
it('returns L for severity 9 with very low occurrence and detection', () => {
|
||||
expect(calculateAP(9, 1, 4)).toBe('L')
|
||||
})
|
||||
|
||||
it('returns H for severity 7 with high occurrence', () => {
|
||||
expect(calculateAP(7, 5, 1)).toBe('H')
|
||||
})
|
||||
|
||||
it('returns M for severity 7 with mid occurrence', () => {
|
||||
expect(calculateAP(7, 3, 5)).toBe('M')
|
||||
})
|
||||
|
||||
it('returns L for low-severity well-controlled mode', () => {
|
||||
expect(calculateAP(3, 1, 1)).toBe('L')
|
||||
})
|
||||
|
||||
it('returns L for severity 5 with very low occurrence and detection', () => {
|
||||
expect(calculateAP(5, 1, 1)).toBe('L')
|
||||
})
|
||||
})
|
||||
@@ -156,5 +156,52 @@ export function useFMEA(projectId: string) {
|
||||
// Get unique components for the suggest button
|
||||
const components = [...new Map(rows.map((r) => [r.component.id, r.component])).values()]
|
||||
|
||||
return { rows, loading, stats, components, suggestFMs, suggesting, suggestions, suggestSource, setSuggestions }
|
||||
/**
|
||||
* Accept a suggested FM: build an FMEA row from the FM defaults, prepend it
|
||||
* to the table state, and remove the FM from the suggestion list.
|
||||
* Returns false if the (component, fm.id) combo already exists in rows.
|
||||
*/
|
||||
function acceptSuggestion(fm: FailureMode, componentId: string): boolean {
|
||||
const comp = components.find((c) => c.id === componentId)
|
||||
if (!comp) return false
|
||||
const dup = rows.find((r) => r.component.id === componentId && r.failureMode.id === fm.id)
|
||||
if (dup) {
|
||||
// Still drop the suggestion so the UI does not keep offering it.
|
||||
setSuggestions((prev) => prev.filter((s) => s.id !== fm.id))
|
||||
return false
|
||||
}
|
||||
const s = fm.default_severity || 5
|
||||
const o = fm.default_occurrence || 5
|
||||
const d = fm.default_detection || 5
|
||||
const newRow: FMEARow = {
|
||||
component: comp,
|
||||
failureMode: fm,
|
||||
severity: s,
|
||||
occurrence: o,
|
||||
detection: d,
|
||||
rpz: s * o * d,
|
||||
ap: calculateAP(s, o, d),
|
||||
}
|
||||
setRows((prev) => [newRow, ...prev].sort((a, b) => b.rpz - a.rpz))
|
||||
setSuggestions((prev) => prev.filter((sg) => sg.id !== fm.id))
|
||||
return true
|
||||
}
|
||||
|
||||
function rejectSuggestion(fmId: string) {
|
||||
setSuggestions((prev) => prev.filter((sg) => sg.id !== fmId))
|
||||
}
|
||||
|
||||
return {
|
||||
rows,
|
||||
loading,
|
||||
stats,
|
||||
components,
|
||||
suggestFMs,
|
||||
suggesting,
|
||||
suggestions,
|
||||
suggestSource,
|
||||
setSuggestions,
|
||||
acceptSuggestion,
|
||||
rejectSuggestion,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { useFMEA, type FMEARow } from './_hooks/useFMEA'
|
||||
|
||||
@@ -27,8 +27,17 @@ function rpzLabel(rpz: number): string {
|
||||
|
||||
export default function FMEAPage() {
|
||||
const { projectId } = useParams<{ projectId: string }>()
|
||||
const { rows, loading, stats, components, suggestFMs, suggesting, suggestions, suggestSource, setSuggestions } = useFMEA(projectId)
|
||||
const { rows, loading, stats, components, suggestFMs, suggesting, suggestions, suggestSource, setSuggestions, acceptSuggestion, rejectSuggestion } = useFMEA(projectId)
|
||||
const [suggestComp, setSuggestComp] = useState<string | null>(null)
|
||||
const [acceptedCount, setAcceptedCount] = useState(0)
|
||||
|
||||
// Reset accepted-count when a fresh suggestion run is loaded or the panel closes.
|
||||
useEffect(() => {
|
||||
if (suggesting) setAcceptedCount(0)
|
||||
}, [suggesting])
|
||||
useEffect(() => {
|
||||
if (suggestions.length === 0) setAcceptedCount(0)
|
||||
}, [suggestions.length])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -97,26 +106,60 @@ export default function FMEAPage() {
|
||||
{suggestions.length > 0 && (
|
||||
<div className="bg-purple-50 dark:bg-purple-900/20 border border-purple-200 dark:border-purple-800 rounded-xl p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-purple-800 dark:text-purple-300">
|
||||
KI-Vorschlaege ({suggestions.length}) — {suggestSource === 'llm' ? 'LLM-generiert' : 'Bibliothek'}
|
||||
</h3>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-purple-800 dark:text-purple-300">
|
||||
KI-Vorschlaege ({suggestions.length}) — {suggestSource === 'llm' ? 'LLM-generiert' : 'Bibliothek-Fallback'}
|
||||
</h3>
|
||||
{acceptedCount > 0 && (
|
||||
<div className="text-xs text-green-700 dark:text-green-400 mt-0.5">
|
||||
{acceptedCount} Vorschlag{acceptedCount > 1 ? 'e' : ''} uebernommen
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={() => setSuggestions([])} className="text-xs text-purple-600 hover:text-purple-800">Schliessen</button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{suggestions.map((fm, i) => (
|
||||
<div key={i} className="flex items-center justify-between bg-white dark:bg-gray-800 rounded-lg p-3 border border-purple-100 dark:border-purple-800">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">{fm.name_de}</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5">{fm.effect}</div>
|
||||
<div className="flex gap-3 mt-1 text-xs text-gray-400">
|
||||
<span>S={fm.default_severity}</span>
|
||||
<span>O={fm.default_occurrence}</span>
|
||||
<span>D={fm.default_detection}</span>
|
||||
<span className="font-bold">RPZ={fm.default_severity * fm.default_occurrence * fm.default_detection}</span>
|
||||
{suggestions.map((fm) => {
|
||||
const rpz = fm.default_severity * fm.default_occurrence * fm.default_detection
|
||||
return (
|
||||
<div key={fm.id} className="flex items-start justify-between gap-3 bg-white dark:bg-gray-800 rounded-lg p-3 border border-purple-100 dark:border-purple-800">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">{fm.name_de}</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5">{fm.effect}</div>
|
||||
<div className="flex gap-3 mt-1 text-xs text-gray-400">
|
||||
<span>S={fm.default_severity}</span>
|
||||
<span>O={fm.default_occurrence}</span>
|
||||
<span>D={fm.default_detection}</span>
|
||||
<span className={`font-bold ${rpz > 200 ? 'text-red-600' : rpz > 100 ? 'text-orange-600' : 'text-gray-500'}`}>RPZ={rpz}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!suggestComp) return
|
||||
const ok = acceptSuggestion(fm, suggestComp)
|
||||
if (ok) setAcceptedCount((c) => c + 1)
|
||||
}}
|
||||
disabled={!suggestComp}
|
||||
className="px-3 py-1.5 bg-green-600 hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed text-white text-xs font-medium rounded transition-colors"
|
||||
title="Diesen Fehlermodus der FMEA-Tabelle hinzufuegen"
|
||||
>
|
||||
Uebernehmen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => rejectSuggestion(fm.id)}
|
||||
className="px-3 py-1.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 text-xs font-medium rounded transition-colors"
|
||||
title="Diesen Vorschlag verwerfen"
|
||||
>
|
||||
Ablehnen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="text-[10px] text-purple-700 dark:text-purple-400 mt-3">
|
||||
Hinweis: Uebernommene Fehlermodi erscheinen sofort in der Tabelle unten. Bewertung (S/O/D) ist anpassbar — Standardwerte aus der Bibliothek.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
'use client'
|
||||
|
||||
// LLM Gap-Review Modal — Task #8.
|
||||
//
|
||||
// Triggers POST /projects/:id/llm-gap-review on mount and lists the
|
||||
// LLM's gap suggestions with an Adopt / Reject UX. Adoption goes through
|
||||
// the regular CreateHazard / CreateMitigation endpoints — the modal
|
||||
// itself never mutates project state on its own.
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
type Suggestion = {
|
||||
kind: 'hazard' | 'mitigation'
|
||||
title: string
|
||||
description: string
|
||||
category?: string
|
||||
hazard_ref?: string
|
||||
pattern_ref?: string
|
||||
norm_refs?: string[]
|
||||
confidence?: 'high' | 'medium' | 'low'
|
||||
rationale?: string
|
||||
}
|
||||
|
||||
type Response = {
|
||||
project_id: string
|
||||
source: 'llm_gap_review' | 'fallback_static'
|
||||
model?: string
|
||||
suggestions: Suggestion[]
|
||||
input_summary: {
|
||||
hazard_count: number
|
||||
mitigation_count: number
|
||||
limits_form_fields: number
|
||||
}
|
||||
}
|
||||
|
||||
const CONF_COLOR: Record<string, string> = {
|
||||
high: 'bg-emerald-100 text-emerald-800 border-emerald-200',
|
||||
medium: 'bg-amber-100 text-amber-800 border-amber-200',
|
||||
low: 'bg-slate-100 text-slate-600 border-slate-200',
|
||||
}
|
||||
|
||||
interface Props {
|
||||
projectId: string
|
||||
onClose: () => void
|
||||
onAdoptHazard?: (s: Suggestion) => Promise<void>
|
||||
onAdoptMitigation?: (s: Suggestion) => Promise<void>
|
||||
}
|
||||
|
||||
export function LLMGapReviewModal({ projectId, onClose, onAdoptHazard, onAdoptMitigation }: Props) {
|
||||
const [data, setData] = useState<Response | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [adopted, setAdopted] = useState<Set<number>>(new Set())
|
||||
const [rejected, setRejected] = useState<Set<number>>(new Set())
|
||||
const [adopting, setAdopting] = useState<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
fetch(`/api/sdk/v1/iace/projects/${projectId}/llm-gap-review`, { method: 'POST' })
|
||||
.then((r) => (r.ok ? r.json() : Promise.reject(`HTTP ${r.status}`)))
|
||||
.then(setData)
|
||||
.catch((e) => setError(String(e)))
|
||||
.finally(() => setLoading(false))
|
||||
}, [projectId])
|
||||
|
||||
async function adopt(idx: number) {
|
||||
if (!data) return
|
||||
const s = data.suggestions[idx]
|
||||
setAdopting(idx)
|
||||
try {
|
||||
if (s.kind === 'hazard' && onAdoptHazard) await onAdoptHazard(s)
|
||||
else if (s.kind === 'mitigation' && onAdoptMitigation) await onAdoptMitigation(s)
|
||||
setAdopted((prev) => new Set(prev).add(idx))
|
||||
} catch (e) {
|
||||
setError(`Adopt fehlgeschlagen: ${e}`)
|
||||
} finally {
|
||||
setAdopting(null)
|
||||
}
|
||||
}
|
||||
|
||||
function reject(idx: number) {
|
||||
setRejected((prev) => new Set(prev).add(idx))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="bg-white rounded-xl shadow-2xl w-full max-w-3xl max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between flex-shrink-0">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900">KI-Gap-Review</h2>
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
LLM-gestuetzte Suche nach fehlenden Gefaehrdungen und Schutzmassnahmen — Vorschlaege sind unverbindlich bis explizit uebernommen.
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-2xl leading-none">×</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-3">
|
||||
{loading && (
|
||||
<div className="text-center py-12">
|
||||
<div className="animate-spin rounded-full h-10 w-10 border-b-2 border-purple-600 mx-auto" />
|
||||
<p className="text-sm text-gray-500 mt-3">LLM laeuft (Qwen/Claude). Das kann bis zu 30 Sekunden dauern.</p>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-sm text-red-700">
|
||||
Fehler: {error}
|
||||
</div>
|
||||
)}
|
||||
{data && (
|
||||
<>
|
||||
<div className="text-xs text-gray-500 flex items-center gap-3 border-b border-gray-100 pb-2">
|
||||
<span>
|
||||
Eingabe: {data.input_summary.hazard_count} Gefaehrdungen,{' '}
|
||||
{data.input_summary.mitigation_count} Massnahmen, {data.input_summary.limits_form_fields} Grenzen-Felder
|
||||
</span>
|
||||
<span className="text-gray-300">·</span>
|
||||
<span>
|
||||
Quelle: {data.source === 'llm_gap_review'
|
||||
? `LLM (${data.model ?? 'unbekannt'})`
|
||||
: 'Statische Fallback-Liste'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{data.suggestions.length === 0 && (
|
||||
<div className="text-center text-gray-500 py-12 text-sm">
|
||||
Keine Lueckenvorschlaege. Die deterministische Pattern-Engine hat vermutlich bereits alle Standard-Gefaehrdungen abgedeckt.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.suggestions.map((s, i) => {
|
||||
const isAdopted = adopted.has(i)
|
||||
const isRejected = rejected.has(i)
|
||||
const isWorking = adopting === i
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`border rounded-lg p-3 ${
|
||||
isAdopted ? 'border-emerald-200 bg-emerald-50' :
|
||||
isRejected ? 'border-slate-200 bg-slate-50 opacity-50' :
|
||||
'border-gray-200 bg-white'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap mb-1">
|
||||
<span className={`px-1.5 py-0.5 text-[10px] rounded font-medium ${
|
||||
s.kind === 'hazard' ? 'bg-red-100 text-red-700' : 'bg-blue-100 text-blue-700'
|
||||
}`}>
|
||||
{s.kind === 'hazard' ? 'Gefaehrdung' : 'Massnahme'}
|
||||
</span>
|
||||
{s.category && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] rounded bg-gray-100 text-gray-700">{s.category}</span>
|
||||
)}
|
||||
{s.confidence && (
|
||||
<span className={`px-1.5 py-0.5 text-[10px] rounded border ${CONF_COLOR[s.confidence]}`}>
|
||||
{s.confidence}
|
||||
</span>
|
||||
)}
|
||||
{(s.norm_refs ?? []).map((n) => (
|
||||
<span key={n} className="px-1.5 py-0.5 text-[10px] rounded bg-indigo-50 text-indigo-700 font-mono">{n}</span>
|
||||
))}
|
||||
{s.pattern_ref && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] rounded bg-purple-50 text-purple-700 font-mono">{s.pattern_ref}</span>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-gray-900">{s.title}</h3>
|
||||
<p className="text-xs text-gray-600 mt-1">{s.description}</p>
|
||||
{s.hazard_ref && (
|
||||
<p className="text-[11px] text-gray-500 mt-1">Bezogen auf: <em>{s.hazard_ref}</em></p>
|
||||
)}
|
||||
{s.rationale && (
|
||||
<p className="text-[11px] text-gray-400 mt-1 italic">{s.rationale}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 flex-shrink-0">
|
||||
{!isAdopted && !isRejected && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => adopt(i)}
|
||||
disabled={isWorking}
|
||||
className="px-3 py-1 text-xs bg-emerald-600 text-white rounded hover:bg-emerald-700 disabled:opacity-50"
|
||||
>
|
||||
{isWorking ? '…' : 'Uebernehmen'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => reject(i)}
|
||||
className="px-3 py-1 text-xs text-gray-600 border border-gray-300 rounded hover:bg-gray-50"
|
||||
>
|
||||
Verwerfen
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{isAdopted && <span className="text-xs text-emerald-700 font-medium">✓ Uebernommen</span>}
|
||||
{isRejected && <span className="text-xs text-gray-500">Verworfen</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-3 border-t border-gray-200 bg-gray-50 flex items-center justify-between flex-shrink-0">
|
||||
<p className="text-[11px] text-gray-500">
|
||||
Hinweis: LLM-Vorschlaege sind NICHT die deterministische Engine-Output. Jede Uebernahme wird als <code>source=llm_gap_review</code> markiert.
|
||||
</p>
|
||||
<button onClick={onClose} className="px-3 py-1.5 text-sm border border-gray-300 rounded hover:bg-white">
|
||||
Schliessen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LLMGapReviewModal
|
||||
@@ -12,6 +12,7 @@ import type { ResidualFilter } from './_components/ResidualRiskPanel'
|
||||
import { LibraryModal } from './_components/LibraryModal'
|
||||
import { AutoSuggestPanel } from './_components/AutoSuggestPanel'
|
||||
import { CustomHazardModal } from './_components/CustomHazardModal'
|
||||
import { LLMGapReviewModal } from './_components/LLMGapReviewModal'
|
||||
import { useHazards } from './_hooks/useHazards'
|
||||
|
||||
type ViewMode = 'list' | 'risk' | 'blocks'
|
||||
@@ -22,6 +23,7 @@ export default function HazardsPage() {
|
||||
const h = useHazards(projectId)
|
||||
const [view, setView] = useState<ViewMode>('risk')
|
||||
const [showCustomModal, setShowCustomModal] = useState(false)
|
||||
const [showGapReview, setShowGapReview] = useState(false)
|
||||
const [residualFilter, setResidualFilter] = useState<ResidualFilter>('all')
|
||||
const [decisions, setDecisions] = useState<Record<string, boolean | null>>({})
|
||||
|
||||
@@ -104,6 +106,15 @@ export default function HazardsPage() {
|
||||
</svg>
|
||||
Eigene Gefaehrdung
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowGapReview(true)}
|
||||
title="LLM (Qwen/Claude) prueft auf fehlende Gefaehrdungen und Massnahmen — Vorschlaege sind unverbindlich."
|
||||
className="flex items-center gap-2 px-3 py-2 border border-indigo-300 text-indigo-700 rounded-lg hover:bg-indigo-50 transition-colors text-sm">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||
</svg>
|
||||
KI-Gap-Review
|
||||
</button>
|
||||
<button onClick={() => h.setShowForm(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors text-sm">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
@@ -170,6 +181,13 @@ export default function HazardsPage() {
|
||||
onClose={() => setShowCustomModal(false)} />
|
||||
)}
|
||||
|
||||
{showGapReview && (
|
||||
<LLMGapReviewModal
|
||||
projectId={projectId}
|
||||
onClose={() => setShowGapReview(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{h.hazards.length > 0 ? (
|
||||
view === 'risk' ? (
|
||||
<>
|
||||
|
||||
@@ -73,6 +73,7 @@ export function SidebarModuleList({ collapsed, projectId, pendingCRCount }: Side
|
||||
<AdditionalModuleItem href="/sdk/ai-registration" 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 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" /></svg>} label="EU Registrierung" isActive={pathname?.startsWith('/sdk/ai-registration') ?? false} collapsed={collapsed} projectId={projectId} />
|
||||
<AdditionalModuleItem href="/sdk/compliance-optimizer" icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" /></svg>} label="Compliance Optimizer" isActive={pathname?.startsWith('/sdk/compliance-optimizer') ?? false} collapsed={collapsed} projectId={projectId} />
|
||||
<AdditionalModuleItem href="/sdk/agent" icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg>} label="Compliance Agent" isActive={pathname?.startsWith('/sdk/agent') ?? false} collapsed={collapsed} projectId={projectId} />
|
||||
<AdditionalModuleItem href="/sdk/benchmark" icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" /></svg>} label="Branchen-Benchmark" isActive={pathname?.startsWith('/sdk/benchmark') ?? false} collapsed={collapsed} projectId={projectId} />
|
||||
</div>
|
||||
|
||||
{/* CRA Compliance */}
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
package handlers
|
||||
|
||||
// LLM Gap-Review handler — Task #7.
|
||||
//
|
||||
// After the deterministic Pattern-Engine has generated hazards and
|
||||
// mitigations for an IACE project, this endpoint asks a configured LLM
|
||||
// (Qwen / Claude / OpenAI) to spot what the engine MISSED. The LLM is
|
||||
// fed the Limits-Form, the current hazard list, and a compressed
|
||||
// pattern catalogue summary; it returns a list of suggested additional
|
||||
// hazards or mitigations.
|
||||
//
|
||||
// Important guardrails:
|
||||
// - Every suggestion must point to an existing pattern_id or norm
|
||||
// identifier — pure free-form LLM hallucinations are filtered.
|
||||
// - The response is provenance-tagged source="llm_gap_review" so
|
||||
// the frontend renders an Adopt/Reject UX rather than committing.
|
||||
// - Engine output (deterministic patterns) is never overwritten by
|
||||
// LLM output; the gap-review is a SUPPLEMENT, not a replacement.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/iace"
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/llm"
|
||||
)
|
||||
|
||||
// GapSuggestion is one LLM-proposed addition. Each suggestion is
|
||||
// non-binding until the user adopts it via the frontend.
|
||||
type GapSuggestion struct {
|
||||
Kind string `json:"kind"` // "hazard" | "mitigation"
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Category string `json:"category,omitempty"`
|
||||
HazardRef string `json:"hazard_ref,omitempty"` // for mitigation: name of existing hazard
|
||||
PatternRef string `json:"pattern_ref,omitempty"` // HP-XXXX from engine library
|
||||
NormRefs []string `json:"norm_refs,omitempty"` // EN ISO 12100 / DGUV / OSHA
|
||||
Confidence string `json:"confidence,omitempty"` // "high" | "medium" | "low"
|
||||
Rationale string `json:"rationale,omitempty"`
|
||||
}
|
||||
|
||||
// GapReviewResponse is the wire format for the frontend modal.
|
||||
type GapReviewResponse struct {
|
||||
ProjectID string `json:"project_id"`
|
||||
Source string `json:"source"` // "llm_gap_review" | "fallback_static"
|
||||
Model string `json:"model,omitempty"`
|
||||
Suggestions []GapSuggestion `json:"suggestions"`
|
||||
InputSummary struct {
|
||||
HazardCount int `json:"hazard_count"`
|
||||
MitigationCount int `json:"mitigation_count"`
|
||||
LimitsFormFields int `json:"limits_form_fields"`
|
||||
} `json:"input_summary"`
|
||||
}
|
||||
|
||||
// LLMGapReview handles POST /projects/:id/llm-gap-review.
|
||||
//
|
||||
// The endpoint is intentionally idempotent — repeated calls do not mutate
|
||||
// project state. The Adopt step (user-driven) is what changes data, via
|
||||
// the existing CreateHazard / CreateMitigation handlers.
|
||||
func (h *IACEHandler) LLMGapReview(c *gin.Context) {
|
||||
projectID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project id"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
project, err := h.store.GetProject(ctx, projectID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "project not found"})
|
||||
return
|
||||
}
|
||||
|
||||
hazards, err := h.store.ListHazards(ctx, projectID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "list hazards: " + err.Error()})
|
||||
return
|
||||
}
|
||||
mitigations, err := h.store.ListMitigationsByProject(ctx, projectID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "list mitigations: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
limitsForm := extractLimitsForm(project)
|
||||
prompt := buildGapReviewPrompt(project, hazards, mitigations, limitsForm)
|
||||
|
||||
resp := GapReviewResponse{ProjectID: projectID.String()}
|
||||
resp.InputSummary.HazardCount = len(hazards)
|
||||
resp.InputSummary.MitigationCount = len(mitigations)
|
||||
resp.InputSummary.LimitsFormFields = countLimitsFields(limitsForm)
|
||||
|
||||
suggestions, model, err := callLLMForGapReview(ctx, h.llmRegistry, prompt)
|
||||
if err != nil {
|
||||
resp.Source = "fallback_static"
|
||||
resp.Suggestions = staticFallbackSuggestions(hazards)
|
||||
c.JSON(http.StatusOK, resp)
|
||||
return
|
||||
}
|
||||
|
||||
resp.Source = "llm_gap_review"
|
||||
resp.Model = model
|
||||
resp.Suggestions = filterAndProvenance(suggestions)
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// extractLimitsForm pulls the structured limits-form out of project metadata.
|
||||
func extractLimitsForm(p *iace.Project) map[string]any {
|
||||
if len(p.Metadata) == 0 {
|
||||
return nil
|
||||
}
|
||||
var md map[string]any
|
||||
if err := json.Unmarshal(p.Metadata, &md); err != nil {
|
||||
return nil
|
||||
}
|
||||
lf, _ := md["limits_form"].(map[string]any)
|
||||
return lf
|
||||
}
|
||||
|
||||
func countLimitsFields(lf map[string]any) int {
|
||||
n := 0
|
||||
for _, v := range lf {
|
||||
if s, ok := v.(string); ok && strings.TrimSpace(s) != "" {
|
||||
n++
|
||||
} else if arr, ok := v.([]any); ok && len(arr) > 0 {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// buildGapReviewPrompt assembles the LLM input. Kept compact — the LLM
|
||||
// only needs the limits-form context, the current hazard headlines, and
|
||||
// a reminder of the pattern-id naming so its suggestions can be linked
|
||||
// back to engine output later.
|
||||
func buildGapReviewPrompt(p *iace.Project, hz []iace.Hazard, mt []iace.Mitigation, lf map[string]any) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("Du bist CE-Sicherheitsexperte fuer Maschinen nach EN ISO 12100. ")
|
||||
sb.WriteString("Analysiere die folgende Risikobeurteilung und identifiziere FEHLENDE ")
|
||||
sb.WriteString("Gefaehrdungen oder Schutzmassnahmen, die ein erfahrener Auditor ergaenzen wuerde.\n\n")
|
||||
|
||||
sb.WriteString(fmt.Sprintf("Maschine: %s (Typ: %s, Hersteller: %s)\n",
|
||||
p.MachineName, p.MachineType, p.Manufacturer))
|
||||
if p.CEMarkingTarget != "" {
|
||||
sb.WriteString(fmt.Sprintf("CE-Ziel: %s\n", p.CEMarkingTarget))
|
||||
}
|
||||
sb.WriteString("\nGrenzen-Form (Limits & Verwendung):\n")
|
||||
for k, v := range lf {
|
||||
sb.WriteString(fmt.Sprintf("- %s: %v\n", k, truncForPrompt(v, 200)))
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("\nBereits identifizierte Gefaehrdungen (%d):\n", len(hz)))
|
||||
for i, h := range hz {
|
||||
if i >= 25 {
|
||||
sb.WriteString(fmt.Sprintf("... und %d weitere\n", len(hz)-25))
|
||||
break
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("- [%s] %s\n", h.Category, h.Name))
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("\nBereits hinterlegte Schutzmassnahmen (%d, gekuerzt):\n", len(mt)))
|
||||
for i, m := range mt {
|
||||
if i >= 25 {
|
||||
sb.WriteString(fmt.Sprintf("... und %d weitere\n", len(mt)-25))
|
||||
break
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("- [%s] %s\n", m.ReductionType, m.Name))
|
||||
}
|
||||
|
||||
sb.WriteString("\nAufgabe: Liste max. 8 LUECKEN als JSON-Array. Jede Luecke MUSS einer der folgenden Kategorien entsprechen ")
|
||||
sb.WriteString("und SOLL eine Norm- oder Pattern-Referenz nennen (HP-XXXX, EN ISO 12100, EN 13849, EN 13855, DGUV-Info, OSHA 29 CFR).\n")
|
||||
sb.WriteString("Kategorien: mechanical_hazard, electrical_hazard, thermal_hazard, noise_vibration, ergonomic, ")
|
||||
sb.WriteString("material_environmental, pneumatic_hydraulic, radiation_hazard.\n\n")
|
||||
sb.WriteString(`Antworte NUR mit JSON, keine Erklaerung:
|
||||
[
|
||||
{"kind":"hazard","title":"...","description":"...","category":"...","norm_refs":["EN ISO 12100"],"confidence":"high","rationale":"..."},
|
||||
{"kind":"mitigation","title":"...","description":"...","hazard_ref":"Name der bestehenden Gefahr","norm_refs":["DGUV 209-072"],"confidence":"medium","rationale":"..."}
|
||||
]`)
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func truncForPrompt(v any, max int) string {
|
||||
s := fmt.Sprintf("%v", v)
|
||||
if len(s) <= max {
|
||||
return s
|
||||
}
|
||||
return s[:max] + "…"
|
||||
}
|
||||
|
||||
// callLLMForGapReview sends the prompt and parses the JSON suggestion list.
|
||||
func callLLMForGapReview(ctx context.Context, registry *llm.ProviderRegistry, prompt string) ([]GapSuggestion, string, error) {
|
||||
if registry == nil {
|
||||
return nil, "", fmt.Errorf("no LLM registry configured")
|
||||
}
|
||||
provider, err := registry.GetAvailable(ctx)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("no LLM provider available: %w", err)
|
||||
}
|
||||
resp, err := provider.Chat(ctx, &llm.ChatRequest{
|
||||
Messages: []llm.Message{{Role: "user", Content: prompt}},
|
||||
Temperature: 0.25,
|
||||
MaxTokens: 2000,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("llm chat: %w", err)
|
||||
}
|
||||
|
||||
body := strings.TrimSpace(resp.Message.Content)
|
||||
// LLMs occasionally wrap JSON in ```json … ``` fences; strip them.
|
||||
body = strings.TrimPrefix(body, "```json")
|
||||
body = strings.TrimPrefix(body, "```")
|
||||
body = strings.TrimSuffix(body, "```")
|
||||
body = strings.TrimSpace(body)
|
||||
|
||||
// Find first '[' so any leading prose is ignored.
|
||||
if i := strings.Index(body, "["); i > 0 {
|
||||
body = body[i:]
|
||||
}
|
||||
var out []GapSuggestion
|
||||
if err := json.Unmarshal([]byte(body), &out); err != nil {
|
||||
return nil, "", fmt.Errorf("parse llm response: %w (body=%.200s)", err, body)
|
||||
}
|
||||
return out, provider.Name(), nil
|
||||
}
|
||||
|
||||
// filterAndProvenance drops obviously malformed suggestions and stamps
|
||||
// every survivor with a `confidence` default. Pure-free-form suggestions
|
||||
// without any norm reference are demoted to "low".
|
||||
func filterAndProvenance(in []GapSuggestion) []GapSuggestion {
|
||||
out := make([]GapSuggestion, 0, len(in))
|
||||
for _, s := range in {
|
||||
if strings.TrimSpace(s.Title) == "" || s.Kind == "" {
|
||||
continue
|
||||
}
|
||||
if s.Confidence == "" {
|
||||
if len(s.NormRefs) == 0 && s.PatternRef == "" {
|
||||
s.Confidence = "low"
|
||||
} else {
|
||||
s.Confidence = "medium"
|
||||
}
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// staticFallbackSuggestions returns a generic checklist when no LLM is
|
||||
// available. Conservative, all confidence="low".
|
||||
func staticFallbackSuggestions(hz []iace.Hazard) []GapSuggestion {
|
||||
hasMechanical := false
|
||||
for _, h := range hz {
|
||||
if strings.Contains(h.Category, "mechanical") {
|
||||
hasMechanical = true
|
||||
break
|
||||
}
|
||||
}
|
||||
out := []GapSuggestion{
|
||||
{
|
||||
Kind: "hazard", Title: "Fuss-Quetschung unter absenkendem Werkstueck/Hubeinheit",
|
||||
Description: "Wenn die Maschine eine Hubbewegung ausfuehrt, pruefe ob Fuesse/Beine im Verfahrbereich gequetscht werden koennen.",
|
||||
Category: "mechanical_hazard", NormRefs: []string{"EN ISO 12100 6.3.5.5"},
|
||||
Confidence: "low", Rationale: "Static checklist fallback — LLM nicht verfuegbar.",
|
||||
},
|
||||
{
|
||||
Kind: "hazard", Title: "Hand-Quetschung gegen feste Strukturen beim Hochfahren",
|
||||
Description: "Pruefe Mindestabstand zu festen Strukturen oberhalb der hoechsten Hubposition.",
|
||||
Category: "mechanical_hazard", NormRefs: []string{"EN ISO 13854"},
|
||||
Confidence: "low",
|
||||
},
|
||||
{
|
||||
Kind: "mitigation", Title: "Kriechgeschwindigkeit am Endanschlag (Hubgeraete)",
|
||||
Description: "Hubgeschwindigkeit am Ende der Verfahrbewegung auf <=15 mm/s reduzieren.",
|
||||
NormRefs: []string{"OSHA 29 CFR 1910.217 (Hand-Speed-Konstante)"},
|
||||
Confidence: "low",
|
||||
},
|
||||
}
|
||||
if !hasMechanical {
|
||||
// Trim if not a mechanical context
|
||||
out = out[:1]
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -355,117 +355,6 @@ func registerWhistleblowerRoutes(v1 *gin.RouterGroup, h *handlers.WhistleblowerH
|
||||
}
|
||||
}
|
||||
|
||||
func registerIACERoutes(v1 *gin.RouterGroup, h *handlers.IACEHandler) {
|
||||
iaceRoutes := v1.Group("/iace")
|
||||
{
|
||||
iaceRoutes.GET("/hazard-library", h.ListHazardLibrary)
|
||||
iaceRoutes.GET("/controls-library", h.ListControlsLibrary)
|
||||
iaceRoutes.GET("/norms-library", h.ListNormsLibrary)
|
||||
iaceRoutes.GET("/lifecycle-phases", h.ListLifecyclePhases)
|
||||
iaceRoutes.GET("/roles", h.ListRoles)
|
||||
iaceRoutes.GET("/evidence-types", h.ListEvidenceTypes)
|
||||
iaceRoutes.GET("/protective-measures-library", h.ListProtectiveMeasures)
|
||||
iaceRoutes.GET("/failure-modes", h.ListFailureModes)
|
||||
iaceRoutes.GET("/operational-states", h.ListOperationalStates)
|
||||
iaceRoutes.GET("/component-library", h.ListComponentLibrary)
|
||||
iaceRoutes.GET("/energy-sources", h.ListEnergySources)
|
||||
iaceRoutes.GET("/tags", h.ListTags)
|
||||
iaceRoutes.GET("/hazard-patterns", h.ListHazardPatterns)
|
||||
iaceRoutes.POST("/projects", h.CreateProject)
|
||||
iaceRoutes.GET("/projects", h.ListProjects)
|
||||
iaceRoutes.GET("/projects/:id", h.GetProject)
|
||||
iaceRoutes.PUT("/projects/:id", h.UpdateProject)
|
||||
iaceRoutes.DELETE("/projects/:id", h.ArchiveProject)
|
||||
iaceRoutes.POST("/projects/:id/init-from-profile", h.InitFromProfile)
|
||||
iaceRoutes.POST("/projects/:id/variants", h.CreateVariant)
|
||||
iaceRoutes.GET("/projects/:id/variants", h.ListVariants)
|
||||
iaceRoutes.GET("/projects/:id/variant-gap", h.GetVariantGap)
|
||||
iaceRoutes.POST("/projects/:id/completeness-check", h.CheckCompleteness)
|
||||
iaceRoutes.POST("/projects/:id/components", h.CreateComponent)
|
||||
iaceRoutes.GET("/projects/:id/components", h.ListComponents)
|
||||
iaceRoutes.PUT("/projects/:id/components/:cid", h.UpdateComponent)
|
||||
iaceRoutes.DELETE("/projects/:id/components/:cid", h.DeleteComponent)
|
||||
iaceRoutes.POST("/projects/:id/classify", h.Classify)
|
||||
iaceRoutes.GET("/projects/:id/classifications", h.GetClassifications)
|
||||
iaceRoutes.POST("/projects/:id/classify/:regulation", h.ClassifySingle)
|
||||
iaceRoutes.POST("/projects/:id/hazards", h.CreateHazard)
|
||||
iaceRoutes.GET("/projects/:id/hazards", h.ListHazards)
|
||||
iaceRoutes.PUT("/projects/:id/hazards/:hid", h.UpdateHazard)
|
||||
iaceRoutes.POST("/projects/:id/hazards/suggest", h.SuggestHazards)
|
||||
iaceRoutes.POST("/projects/:id/match-patterns", h.MatchPatterns)
|
||||
iaceRoutes.POST("/projects/:id/parse-narrative", h.ParseNarrative)
|
||||
iaceRoutes.POST("/projects/:id/delta-analysis", h.DeltaAnalysis)
|
||||
iaceRoutes.GET("/projects/:id/fmea/export", h.ExportFMEA)
|
||||
iaceRoutes.POST("/projects/:id/components/:cid/suggest-fms", h.SuggestFailureModes)
|
||||
iaceRoutes.POST("/projects/:id/apply-patterns", h.ApplyPatternResults)
|
||||
iaceRoutes.POST("/projects/:id/hazards/:hid/suggest-measures", h.SuggestMeasuresForHazard)
|
||||
iaceRoutes.POST("/projects/:id/mitigations/:mid/suggest-evidence", h.SuggestEvidenceForMitigation)
|
||||
iaceRoutes.POST("/projects/:id/hazards/:hid/assess", h.AssessRisk)
|
||||
iaceRoutes.GET("/projects/:id/risk-summary", h.GetRiskSummary)
|
||||
iaceRoutes.GET("/projects/:id/suggested-norms", h.SuggestProjectNorms)
|
||||
iaceRoutes.POST("/projects/:id/hazards/:hid/reassess", h.ReassessRisk)
|
||||
iaceRoutes.GET("/projects/:id/mitigations", h.ListProjectMitigations)
|
||||
iaceRoutes.POST("/projects/:id/hazards/:hid/mitigations", h.CreateMitigation)
|
||||
iaceRoutes.DELETE("/projects/:id/mitigations/:mid", h.DeleteMitigation)
|
||||
iaceRoutes.PUT("/mitigations/:mid", h.UpdateMitigation)
|
||||
iaceRoutes.POST("/mitigations/:mid/verify", h.VerifyMitigation)
|
||||
iaceRoutes.POST("/projects/:id/validate-mitigation-hierarchy", h.ValidateMitigationHierarchy)
|
||||
iaceRoutes.POST("/projects/:id/evidence", h.UploadEvidence)
|
||||
iaceRoutes.GET("/projects/:id/evidence", h.ListEvidence)
|
||||
iaceRoutes.POST("/projects/:id/verification-plan", h.CreateVerificationPlan)
|
||||
iaceRoutes.PUT("/verification-plan/:vid", h.UpdateVerificationPlan)
|
||||
iaceRoutes.POST("/verification-plan/:vid/complete", h.CompleteVerification)
|
||||
iaceRoutes.GET("/projects/:id/verifications", h.ListVerificationPlans)
|
||||
iaceRoutes.POST("/projects/:id/verifications", h.CreateVerificationAlias)
|
||||
iaceRoutes.DELETE("/projects/:id/verifications/:vid", h.DeleteVerificationPlan)
|
||||
iaceRoutes.POST("/projects/:id/verifications/:vid/complete", h.CompleteVerificationAlias)
|
||||
iaceRoutes.POST("/projects/:id/tech-file/generate", h.GenerateTechFile)
|
||||
iaceRoutes.GET("/projects/:id/tech-file", h.ListTechFileSections)
|
||||
iaceRoutes.PUT("/projects/:id/tech-file/:section", h.UpdateTechFileSection)
|
||||
iaceRoutes.POST("/projects/:id/tech-file/:section/approve", h.ApproveTechFileSection)
|
||||
iaceRoutes.POST("/projects/:id/tech-file/:section/generate", h.GenerateSingleSection)
|
||||
iaceRoutes.GET("/projects/:id/tech-file/export", h.ExportTechFile)
|
||||
iaceRoutes.POST("/projects/:id/monitoring", h.CreateMonitoringEvent)
|
||||
iaceRoutes.GET("/projects/:id/monitoring", h.ListMonitoringEvents)
|
||||
iaceRoutes.PUT("/projects/:id/monitoring/:eid", h.UpdateMonitoringEvent)
|
||||
iaceRoutes.GET("/projects/:id/audit-trail", h.GetAuditTrail)
|
||||
iaceRoutes.POST("/library-search", h.SearchLibrary)
|
||||
iaceRoutes.GET("/ce-corpus-documents", h.ListCECorpusDocuments)
|
||||
iaceRoutes.POST("/projects/:id/initialize", h.InitializeProject)
|
||||
iaceRoutes.GET("/projects/:id/hazard-blocks", h.GetHazardBlocks)
|
||||
iaceRoutes.POST("/projects/:id/benchmark/import-gt", h.ImportGroundTruth)
|
||||
iaceRoutes.GET("/projects/:id/benchmark", h.RunBenchmark)
|
||||
iaceRoutes.GET("/projects/:id/benchmark/summary", h.GetBenchmarkSummary)
|
||||
iaceRoutes.GET("/projects/:id/hazards/:hid/regulatory-hints", h.EnrichHazardWithRegulations)
|
||||
iaceRoutes.GET("/projects/:id/mitigations/:mid/regulatory-hints", h.EnrichMitigationWithRegulations)
|
||||
iaceRoutes.GET("/projects/:id/regulatory-hints", h.EnrichProjectHazardsBatch)
|
||||
iaceRoutes.POST("/projects/:id/tech-file/:section/enrich", h.EnrichTechFileSection)
|
||||
|
||||
// Production Lines
|
||||
iaceRoutes.POST("/production-lines", h.CreateProductionLine)
|
||||
iaceRoutes.GET("/production-lines", h.ListProductionLines)
|
||||
iaceRoutes.GET("/production-lines/:lid/dashboard", h.GetProductionLineDashboard)
|
||||
iaceRoutes.POST("/production-lines/:lid/stations", h.AddStationToLine)
|
||||
iaceRoutes.DELETE("/production-lines/:lid/stations/:sid", h.RemoveStationFromLine)
|
||||
|
||||
// CE x Compliance Crossover
|
||||
iaceRoutes.GET("/projects/:id/compliance-triggers", h.GetComplianceTriggers)
|
||||
iaceRoutes.GET("/compliance-faq", h.GetComplianceFAQ)
|
||||
|
||||
// Clarifications — aggregated open questions per project
|
||||
iaceRoutes.GET("/projects/:id/clarifications", h.ListClarifications)
|
||||
iaceRoutes.GET("/projects/:id/clarifications.csv", h.ExportClarificationsCSV)
|
||||
iaceRoutes.GET("/projects/:id/clarifications.html", h.ExportClarificationsHTML)
|
||||
iaceRoutes.GET("/projects/:id/clarifications/:cid/detail", h.ListClarificationDetail)
|
||||
iaceRoutes.POST("/projects/:id/clarifications/:cid/answer", h.AnswerClarification)
|
||||
iaceRoutes.POST("/projects/:id/clarifications/:cid/comment", h.PostClarificationComment)
|
||||
|
||||
// Customer-Standard Reuse (migration 031): pull reusable mitigations
|
||||
// across prior projects of the same customer.
|
||||
iaceRoutes.GET("/projects/:id/customer-standards", h.ListCustomerStandardSuggestions)
|
||||
iaceRoutes.POST("/projects/:id/customer-standards/import", h.ImportCustomerStandardSuggestion)
|
||||
}
|
||||
}
|
||||
|
||||
func registerMaximizerRoutes(v1 *gin.RouterGroup, h *handlers.MaximizerHandlers) {
|
||||
m := v1.Group("/maximizer")
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
package app
|
||||
|
||||
// IACE route registration extracted from routes.go (2026-05-21) because
|
||||
// routes.go hit the 500-LOC hard cap when the LLM gap-review endpoint
|
||||
// (Task #7) was added. Splitting keeps every routes file under the cap
|
||||
// without changing behaviour — `registerRoutes` in routes.go still
|
||||
// invokes `registerIACERoutes` exactly once at the same point in the
|
||||
// startup sequence.
|
||||
|
||||
import (
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/api/handlers"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func registerIACERoutes(v1 *gin.RouterGroup, h *handlers.IACEHandler) {
|
||||
iaceRoutes := v1.Group("/iace")
|
||||
{
|
||||
// Library catalogues (read-only reference data).
|
||||
iaceRoutes.GET("/hazard-library", h.ListHazardLibrary)
|
||||
iaceRoutes.GET("/controls-library", h.ListControlsLibrary)
|
||||
iaceRoutes.GET("/norms-library", h.ListNormsLibrary)
|
||||
iaceRoutes.GET("/lifecycle-phases", h.ListLifecyclePhases)
|
||||
iaceRoutes.GET("/roles", h.ListRoles)
|
||||
iaceRoutes.GET("/evidence-types", h.ListEvidenceTypes)
|
||||
iaceRoutes.GET("/protective-measures-library", h.ListProtectiveMeasures)
|
||||
iaceRoutes.GET("/failure-modes", h.ListFailureModes)
|
||||
iaceRoutes.GET("/operational-states", h.ListOperationalStates)
|
||||
iaceRoutes.GET("/component-library", h.ListComponentLibrary)
|
||||
iaceRoutes.GET("/energy-sources", h.ListEnergySources)
|
||||
iaceRoutes.GET("/tags", h.ListTags)
|
||||
iaceRoutes.GET("/hazard-patterns", h.ListHazardPatterns)
|
||||
|
||||
// Project CRUD.
|
||||
iaceRoutes.POST("/projects", h.CreateProject)
|
||||
iaceRoutes.GET("/projects", h.ListProjects)
|
||||
iaceRoutes.GET("/projects/:id", h.GetProject)
|
||||
iaceRoutes.PUT("/projects/:id", h.UpdateProject)
|
||||
iaceRoutes.DELETE("/projects/:id", h.ArchiveProject)
|
||||
iaceRoutes.POST("/projects/:id/init-from-profile", h.InitFromProfile)
|
||||
iaceRoutes.POST("/projects/:id/variants", h.CreateVariant)
|
||||
iaceRoutes.GET("/projects/:id/variants", h.ListVariants)
|
||||
iaceRoutes.GET("/projects/:id/variant-gap", h.GetVariantGap)
|
||||
iaceRoutes.POST("/projects/:id/completeness-check", h.CheckCompleteness)
|
||||
|
||||
// Components.
|
||||
iaceRoutes.POST("/projects/:id/components", h.CreateComponent)
|
||||
iaceRoutes.GET("/projects/:id/components", h.ListComponents)
|
||||
iaceRoutes.PUT("/projects/:id/components/:cid", h.UpdateComponent)
|
||||
iaceRoutes.DELETE("/projects/:id/components/:cid", h.DeleteComponent)
|
||||
|
||||
// Classification + hazards.
|
||||
iaceRoutes.POST("/projects/:id/classify", h.Classify)
|
||||
iaceRoutes.GET("/projects/:id/classifications", h.GetClassifications)
|
||||
iaceRoutes.POST("/projects/:id/classify/:regulation", h.ClassifySingle)
|
||||
iaceRoutes.POST("/projects/:id/hazards", h.CreateHazard)
|
||||
iaceRoutes.GET("/projects/:id/hazards", h.ListHazards)
|
||||
iaceRoutes.PUT("/projects/:id/hazards/:hid", h.UpdateHazard)
|
||||
iaceRoutes.POST("/projects/:id/hazards/suggest", h.SuggestHazards)
|
||||
iaceRoutes.POST("/projects/:id/match-patterns", h.MatchPatterns)
|
||||
iaceRoutes.POST("/projects/:id/parse-narrative", h.ParseNarrative)
|
||||
iaceRoutes.POST("/projects/:id/delta-analysis", h.DeltaAnalysis)
|
||||
iaceRoutes.POST("/projects/:id/llm-gap-review", h.LLMGapReview)
|
||||
iaceRoutes.GET("/projects/:id/fmea/export", h.ExportFMEA)
|
||||
iaceRoutes.POST("/projects/:id/components/:cid/suggest-fms", h.SuggestFailureModes)
|
||||
iaceRoutes.POST("/projects/:id/apply-patterns", h.ApplyPatternResults)
|
||||
iaceRoutes.POST("/projects/:id/hazards/:hid/suggest-measures", h.SuggestMeasuresForHazard)
|
||||
iaceRoutes.POST("/projects/:id/mitigations/:mid/suggest-evidence", h.SuggestEvidenceForMitigation)
|
||||
iaceRoutes.POST("/projects/:id/hazards/:hid/assess", h.AssessRisk)
|
||||
iaceRoutes.GET("/projects/:id/risk-summary", h.GetRiskSummary)
|
||||
iaceRoutes.GET("/projects/:id/suggested-norms", h.SuggestProjectNorms)
|
||||
iaceRoutes.POST("/projects/:id/hazards/:hid/reassess", h.ReassessRisk)
|
||||
|
||||
// Mitigations + evidence + verification.
|
||||
iaceRoutes.GET("/projects/:id/mitigations", h.ListProjectMitigations)
|
||||
iaceRoutes.POST("/projects/:id/hazards/:hid/mitigations", h.CreateMitigation)
|
||||
iaceRoutes.DELETE("/projects/:id/mitigations/:mid", h.DeleteMitigation)
|
||||
iaceRoutes.PUT("/mitigations/:mid", h.UpdateMitigation)
|
||||
iaceRoutes.POST("/mitigations/:mid/verify", h.VerifyMitigation)
|
||||
iaceRoutes.POST("/projects/:id/validate-mitigation-hierarchy", h.ValidateMitigationHierarchy)
|
||||
iaceRoutes.POST("/projects/:id/evidence", h.UploadEvidence)
|
||||
iaceRoutes.GET("/projects/:id/evidence", h.ListEvidence)
|
||||
iaceRoutes.POST("/projects/:id/verification-plan", h.CreateVerificationPlan)
|
||||
iaceRoutes.PUT("/verification-plan/:vid", h.UpdateVerificationPlan)
|
||||
iaceRoutes.POST("/verification-plan/:vid/complete", h.CompleteVerification)
|
||||
iaceRoutes.GET("/projects/:id/verifications", h.ListVerificationPlans)
|
||||
iaceRoutes.POST("/projects/:id/verifications", h.CreateVerificationAlias)
|
||||
iaceRoutes.DELETE("/projects/:id/verifications/:vid", h.DeleteVerificationPlan)
|
||||
iaceRoutes.POST("/projects/:id/verifications/:vid/complete", h.CompleteVerificationAlias)
|
||||
|
||||
// Tech file + monitoring + audit.
|
||||
iaceRoutes.POST("/projects/:id/tech-file/generate", h.GenerateTechFile)
|
||||
iaceRoutes.GET("/projects/:id/tech-file", h.ListTechFileSections)
|
||||
iaceRoutes.PUT("/projects/:id/tech-file/:section", h.UpdateTechFileSection)
|
||||
iaceRoutes.POST("/projects/:id/tech-file/:section/approve", h.ApproveTechFileSection)
|
||||
iaceRoutes.POST("/projects/:id/tech-file/:section/generate", h.GenerateSingleSection)
|
||||
iaceRoutes.GET("/projects/:id/tech-file/export", h.ExportTechFile)
|
||||
iaceRoutes.POST("/projects/:id/monitoring", h.CreateMonitoringEvent)
|
||||
iaceRoutes.GET("/projects/:id/monitoring", h.ListMonitoringEvents)
|
||||
iaceRoutes.PUT("/projects/:id/monitoring/:eid", h.UpdateMonitoringEvent)
|
||||
iaceRoutes.GET("/projects/:id/audit-trail", h.GetAuditTrail)
|
||||
|
||||
// Library + corpus + benchmark.
|
||||
iaceRoutes.POST("/library-search", h.SearchLibrary)
|
||||
iaceRoutes.GET("/ce-corpus-documents", h.ListCECorpusDocuments)
|
||||
iaceRoutes.POST("/projects/:id/initialize", h.InitializeProject)
|
||||
iaceRoutes.GET("/projects/:id/hazard-blocks", h.GetHazardBlocks)
|
||||
iaceRoutes.POST("/projects/:id/benchmark/import-gt", h.ImportGroundTruth)
|
||||
iaceRoutes.GET("/projects/:id/benchmark", h.RunBenchmark)
|
||||
iaceRoutes.GET("/projects/:id/benchmark/summary", h.GetBenchmarkSummary)
|
||||
|
||||
// Regulatory enrichment.
|
||||
iaceRoutes.GET("/projects/:id/hazards/:hid/regulatory-hints", h.EnrichHazardWithRegulations)
|
||||
iaceRoutes.GET("/projects/:id/mitigations/:mid/regulatory-hints", h.EnrichMitigationWithRegulations)
|
||||
iaceRoutes.GET("/projects/:id/regulatory-hints", h.EnrichProjectHazardsBatch)
|
||||
iaceRoutes.POST("/projects/:id/tech-file/:section/enrich", h.EnrichTechFileSection)
|
||||
|
||||
// Production lines.
|
||||
iaceRoutes.POST("/production-lines", h.CreateProductionLine)
|
||||
iaceRoutes.GET("/production-lines", h.ListProductionLines)
|
||||
iaceRoutes.GET("/production-lines/:lid/dashboard", h.GetProductionLineDashboard)
|
||||
iaceRoutes.POST("/production-lines/:lid/stations", h.AddStationToLine)
|
||||
iaceRoutes.DELETE("/production-lines/:lid/stations/:sid", h.RemoveStationFromLine)
|
||||
|
||||
// CE x Compliance crossover + clarifications + customer standards.
|
||||
iaceRoutes.GET("/projects/:id/compliance-triggers", h.GetComplianceTriggers)
|
||||
iaceRoutes.GET("/compliance-faq", h.GetComplianceFAQ)
|
||||
iaceRoutes.GET("/projects/:id/clarifications", h.ListClarifications)
|
||||
iaceRoutes.GET("/projects/:id/clarifications.csv", h.ExportClarificationsCSV)
|
||||
iaceRoutes.GET("/projects/:id/clarifications.html", h.ExportClarificationsHTML)
|
||||
iaceRoutes.GET("/projects/:id/clarifications/:cid/detail", h.ListClarificationDetail)
|
||||
iaceRoutes.POST("/projects/:id/clarifications/:cid/answer", h.AnswerClarification)
|
||||
iaceRoutes.POST("/projects/:id/clarifications/:cid/comment", h.PostClarificationComment)
|
||||
iaceRoutes.GET("/projects/:id/customer-standards", h.ListCustomerStandardSuggestions)
|
||||
iaceRoutes.POST("/projects/:id/customer-standards/import", h.ImportCustomerStandardSuggestion)
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,10 @@ func (e *DocumentExporter) ExportPDF(
|
||||
e.pdfClassifications(pdf, classifications)
|
||||
}
|
||||
|
||||
// --- Quellen & Lizenzen (Stufe 4 Attribution-Renderer, Task #29) ---
|
||||
pdf.AddPage()
|
||||
e.pdfSourcesAppendix(pdf, hazards, mitigations)
|
||||
|
||||
// --- Footer on every page ---
|
||||
pdf.SetFooterFunc(func() {
|
||||
pdf.SetY(-15)
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
package iace
|
||||
|
||||
// Sources & Licenses appendix for the IACE Tech-File PDF export.
|
||||
// Stufe 4 of the Attribution Renderer (Task #29).
|
||||
//
|
||||
// The IACE engine generates hazards from BreakPilot Pattern-IDs that
|
||||
// themselves cite ISO 12100, EN 13849, EN ISO 13855 etc. Those norm
|
||||
// identifiers are R3 (DIN/EN copyright — identifier-only). The
|
||||
// pattern-engine output itself is R3 (BreakPilot own work). OSHA values
|
||||
// surfaced via the minimum-distance library are R1 (US Federal PD).
|
||||
//
|
||||
// This appendix aggregates what the Tech-File ACTUALLY cited and shows
|
||||
// it grouped by license rule with the mandatory disclaimer that the
|
||||
// per-export footer cannot be replaced by a pauschal Impressum-Hinweis.
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/jung-kurt/gofpdf"
|
||||
)
|
||||
|
||||
// pdfSourcesAppendix renders the "Quellen & Lizenzen" appendix page.
|
||||
// Called by ExportPDF after the regulatory classifications block.
|
||||
func (e *DocumentExporter) pdfSourcesAppendix(pdf *gofpdf.Fpdf, hazards []Hazard, mitigations []Mitigation) {
|
||||
pdf.SetFont("Helvetica", "B", 14)
|
||||
pdf.SetTextColor(124, 58, 237)
|
||||
pdf.CellFormat(0, 10, "Quellen und Lizenzen", "", 1, "L", false, 0, "")
|
||||
pdf.Ln(2)
|
||||
|
||||
pdf.SetFont("Helvetica", "", 9)
|
||||
pdf.SetTextColor(80, 80, 80)
|
||||
intro := "Diese Risikobeurteilung verwendet die deterministische BreakPilot IACE " +
|
||||
"Pattern-Engine sowie zitierte Sicherheitsnormen. Die folgende Aufstellung " +
|
||||
"listet die konkret in diesem Dokument zitierten Quellen mit ihrer Lizenzregel."
|
||||
pdf.MultiCell(0, 5, intro, "", "L", false)
|
||||
pdf.Ln(3)
|
||||
|
||||
pdf.SetFont("Helvetica", "B", 10)
|
||||
pdf.SetTextColor(0, 0, 0)
|
||||
pdf.CellFormat(0, 7, "R3 — BreakPilot Pattern-Engine (Eigenwerk, Identifier-Verweis)", "", 1, "L", false, 0, "")
|
||||
pdf.SetFont("Helvetica", "", 9)
|
||||
pdf.SetTextColor(60, 60, 60)
|
||||
pdf.MultiCell(0, 5,
|
||||
"Alle in diesem Dokument referenzierten HP-XXXX-Identifier stammen aus der "+
|
||||
"BreakPilot IACE Pattern-Library (Eigenwerk). Keine externe Lizenz-Attribution "+
|
||||
"erforderlich.", "", "L", false)
|
||||
pdf.Ln(3)
|
||||
|
||||
norms := extractCitedNorms(hazards, mitigations)
|
||||
if len(norms) > 0 {
|
||||
pdf.SetFont("Helvetica", "B", 10)
|
||||
pdf.SetTextColor(0, 0, 0)
|
||||
pdf.CellFormat(0, 7, "R3 — Sicherheitsnormen (DIN/EN/ISO/IEC, Identifier-Verweis)", "", 1, "L", false, 0, "")
|
||||
pdf.SetFont("Helvetica", "", 9)
|
||||
pdf.SetTextColor(60, 60, 60)
|
||||
pdf.MultiCell(0, 5,
|
||||
"DIN-/EN-/ISO-/IEC-Normen unterliegen dem Urheberrecht der jeweiligen "+
|
||||
"Normungsorganisation. In diesem Dokument werden Normen ausschliesslich "+
|
||||
"als Identifier (Norm-Nummer und Abschnitt) zitiert; kein Volltext aus "+
|
||||
"diesen Normen wurde reproduziert. Konkret zitiert:", "", "L", false)
|
||||
pdf.Ln(1)
|
||||
for _, n := range norms {
|
||||
pdf.CellFormat(0, 5, " • "+n, "", 1, "L", false, 0, "")
|
||||
}
|
||||
pdf.Ln(2)
|
||||
}
|
||||
|
||||
pdf.SetFont("Helvetica", "B", 10)
|
||||
pdf.SetTextColor(0, 0, 0)
|
||||
pdf.CellFormat(0, 7, "R1 — Hoheitsrecht / Public Domain (woertlich uebernehmbar)", "", 1, "L", false, 0, "")
|
||||
pdf.SetFont("Helvetica", "", 9)
|
||||
pdf.SetTextColor(60, 60, 60)
|
||||
pdf.MultiCell(0, 5,
|
||||
"Soweit Werte aus US Federal Code (OSHA 29 CFR Subpart O) oder EU-Recht "+
|
||||
"(Maschinenverordnung 2023/1230, AI Act 2024/1689) referenziert werden, "+
|
||||
"sind diese als R1 woertlich uebernehmbar. Keine Attribution-Pflicht.", "", "L", false)
|
||||
pdf.Ln(4)
|
||||
|
||||
pdf.SetFont("Helvetica", "I", 8)
|
||||
pdf.SetTextColor(120, 120, 120)
|
||||
pdf.MultiCell(0, 4,
|
||||
"Hinweis: Pauschalvermerke in AGB oder Impressum reichen rechtlich nicht — "+
|
||||
"die werknahe Attribution erfolgt durch diese Quellenseite. Vollstaendiges "+
|
||||
"Quellenverzeichnis aller im BreakPilot-System verwendeten Quellen siehe "+
|
||||
"/sdk/licenses im Web-Frontend.", "", "L", false)
|
||||
}
|
||||
|
||||
// extractCitedNorms scans hazard descriptions + scenario fields for
|
||||
// recognised norm identifiers. The detection is intentionally narrow:
|
||||
// only well-known prefixes (EN/ISO/IEC/DIN) and only when followed by
|
||||
// digits, so free-form prose is not turned into spurious citations.
|
||||
func extractCitedNorms(hz []Hazard, mt []Mitigation) []string {
|
||||
seen := make(map[string]bool)
|
||||
consider := func(s string) {
|
||||
fields := strings.FieldsFunc(s, func(r rune) bool {
|
||||
return r == ' ' || r == ',' || r == ';' || r == '\n' || r == ';' || r == '('
|
||||
})
|
||||
for i := 0; i < len(fields)-1; i++ {
|
||||
head := strings.ToUpper(strings.TrimSpace(fields[i]))
|
||||
next := strings.TrimSpace(fields[i+1])
|
||||
if !(head == "EN" || head == "ISO" || head == "IEC" || head == "DIN") {
|
||||
continue
|
||||
}
|
||||
if next == "" {
|
||||
continue
|
||||
}
|
||||
// Accept "ISO 12100", "EN 13849-1", "DIN EN 60204-1" etc.
|
||||
if next[0] >= '0' && next[0] <= '9' {
|
||||
seen[head+" "+next] = true
|
||||
} else if head == "DIN" && (strings.HasPrefix(strings.ToUpper(next), "EN") || strings.HasPrefix(strings.ToUpper(next), "ISO")) && i+2 < len(fields) {
|
||||
third := strings.TrimSpace(fields[i+2])
|
||||
if third != "" && third[0] >= '0' && third[0] <= '9' {
|
||||
seen[head+" "+next+" "+third] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, h := range hz {
|
||||
consider(h.Description)
|
||||
consider(h.Scenario)
|
||||
consider(h.PossibleHarm)
|
||||
}
|
||||
for _, m := range mt {
|
||||
consider(m.Description)
|
||||
consider(m.Name)
|
||||
}
|
||||
out := make([]string, 0, len(seen))
|
||||
for k := range seen {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package iace
|
||||
|
||||
// Body-part-specific crush hazards at lift / hoist / scissor-lift endstops.
|
||||
// Bridges the gap that the Kistenhubgeraet re-init exposed: the abstract
|
||||
// "Bremse versagt bei Absenkbewegung" pattern fires, but the concrete
|
||||
// "Fuss unter absenkender Hubplattform" body-part variant did not exist.
|
||||
//
|
||||
// Each pattern restricts to lift-family machine types via MachineTypes,
|
||||
// so a press / CNC / textile project does not pick them up. Mitigations
|
||||
// reference the new M600-M604 (lift endstop) library plus the existing
|
||||
// M001 (geometry), M002 (safety distance), M141 (warning sign).
|
||||
|
||||
func GetLiftEndstopPatterns() []HazardPattern {
|
||||
liftTypes := []string{"lift", "hoist", "elevator", "scissor_lift"}
|
||||
return []HazardPattern{
|
||||
{
|
||||
ID: "HP2100",
|
||||
NameDE: "Fuss-Quetschung unter absenkender Hubplattform am Bodenanschlag",
|
||||
NameEN: "Foot crush under descending lift platform at floor stop",
|
||||
RequiredComponentTags: []string{"crush_point", "gravity_risk", "person_under_load"},
|
||||
RequiredEnergyTags: []string{"gravitational"},
|
||||
MachineTypes: liftTypes,
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M600", "M601", "M604", "M141"},
|
||||
Priority: 92,
|
||||
ScenarioDE: "Fuss oder Bein des Bedieners gelangt waehrend des Absenkvorgangs unter die " +
|
||||
"Hubplattform. Bei Erreichen der unteren Endlage wird der Fuss zwischen Plattform " +
|
||||
"und Boden gequetscht.",
|
||||
TriggerDE: "Unsachgemaesse Position des Bedieners beim Be-/Entladen, fehlende Schaltleiste, fehlender Trittschutz",
|
||||
HarmDE: "Fussquetschung, Mittelfussfraktur, Zehenamputation",
|
||||
AffectedDE: "Bediener, Wartungspersonal",
|
||||
ZoneDE: "Bodenbereich unter Hubplattform, umlaufende Spalte",
|
||||
DefaultSeverity: 4,
|
||||
DefaultExposure: 3,
|
||||
DefaultAvoidability: 2,
|
||||
ISO12100Section: "6.3.5.5 Quetschen — Mindestabstaende",
|
||||
ClarificationQuestionsDE: []string{
|
||||
"Ist eine umlaufende Quetsch-Schaltleiste an der Plattformunterkante verbaut?",
|
||||
"Ist die Hubgeschwindigkeit am unteren Endanschlag auf <=15 mm/s reduziert (siehe M600)?",
|
||||
"Verhindert ein Trittblech / Unterfahrschutz das Hineinfahren von Fuessen?",
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "HP2101",
|
||||
NameDE: "Hand- oder Koerper-Quetschung gegen feste Struktur beim Hochfahren der Hubeinheit",
|
||||
NameEN: "Hand or body crush against fixed structure during lift upward travel",
|
||||
RequiredComponentTags: []string{"crush_point", "gravity_risk"},
|
||||
RequiredEnergyTags: []string{"gravitational"},
|
||||
MachineTypes: liftTypes,
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M602", "M603", "M600", "M141"},
|
||||
Priority: 90,
|
||||
ScenarioDE: "Beim Hochfahren der Last gelangen Hand oder Koerperteile des Bedieners " +
|
||||
"zwischen die hoechste Position der Hubeinheit (z.B. mit beladener Palette) und " +
|
||||
"eine feste Struktur oberhalb (Decke, Vorbau, Querbalken einer umschliessenden Anlage).",
|
||||
TriggerDE: "Eingriff in den Verfahrweg waehrend Hubvorgang, fehlende konstruktive Begrenzung der Endlage",
|
||||
HarmDE: "Hand- oder Armquetschung, im Extremfall Brustkorbkompression",
|
||||
AffectedDE: "Bediener, Einrichter, Wartungspersonal",
|
||||
ZoneDE: "Oberhalb hoechster Hubposition, Vorbau/Decke der umschliessenden Anlage",
|
||||
DefaultSeverity: 4,
|
||||
DefaultExposure: 2,
|
||||
DefaultAvoidability: 2,
|
||||
ISO12100Section: "6.3.5.5 Quetschen — Mindestabstaende",
|
||||
ClarificationQuestionsDE: []string{
|
||||
"Welcher Mindestabstand zu festen Strukturen oberhalb der hoechsten Hubposition ist gegeben? (Empfehlung: 120 mm fuer Kopf, 100 mm fuer Hand)",
|
||||
"Ist der Tippbetrieb (Hold-to-run) durch ein Testprotokoll mit Stop-Zeit-Messung verifiziert?",
|
||||
"Existiert eine redundante Hardware-Endlage zusaetzlich zur Software-Begrenzung?",
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "HP2102",
|
||||
NameDE: "Quetschung Bein/Koerper zwischen Hubeinheit und seitlicher Struktur",
|
||||
NameEN: "Leg/body crush between lift unit and lateral structure",
|
||||
RequiredComponentTags: []string{"crush_point", "gravity_risk", "moving_part"},
|
||||
RequiredEnergyTags: []string{"gravitational"},
|
||||
MachineTypes: liftTypes,
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M602", "M601", "M141"},
|
||||
Priority: 85,
|
||||
ScenarioDE: "Person befindet sich seitlich neben der Hubeinheit und wird waehrend " +
|
||||
"der Bewegung gegen eine feste Struktur (Regalwand, Stuetze, andere Anlage) gequetscht.",
|
||||
TriggerDE: "Aufenthalt in Quetschzone bei Bewegung, fehlende Absperrung",
|
||||
HarmDE: "Beinfraktur, Beckenquetschung",
|
||||
AffectedDE: "Bediener, vorbeigehende Personen",
|
||||
ZoneDE: "Seitlicher Bereich neben Hubeinheit, Lichte Weite zu festen Strukturen",
|
||||
DefaultSeverity: 4,
|
||||
DefaultExposure: 2,
|
||||
DefaultAvoidability: 2,
|
||||
ISO12100Section: "6.3.5.5 Quetschen — Mindestabstaende",
|
||||
ClarificationQuestionsDE: []string{
|
||||
"Welcher Sicherheitsabstand zu seitlichen festen Strukturen ist gegeben (Empfehlung 500 mm Koerperdurchgang)?",
|
||||
"Ist der Bereich seitlich der Hubeinheit als Gefahrenzone markiert oder abgeschrankt?",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ func GetProtectiveMeasureLibrary() []ProtectiveMeasureEntry {
|
||||
all = append(all, GetTextileAgriMeasures()...) // Textil + Landmaschinen (Phase 5)
|
||||
all = append(all, getGTBremseMeasures()...) // GT-Bremse-Coverage-Gaps (M483-M522)
|
||||
all = append(all, GetCRAMeasures()...) // CRA / DIN EN 40000-1-2 cyber-resilience (M540-M548)
|
||||
all = append(all, getLiftEndstopMeasures()...) // Lift/hoist endstop (M600-M604) — bridges OSHA MD library
|
||||
return all
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
package iace
|
||||
|
||||
// Lift / hoist / scissor-lift endstop mitigations — bridges the OSHA
|
||||
// minimum-distance library (minimum_distances.go, Task #18) into the
|
||||
// pattern-engine measure library. Each entry cites the concrete OSHA
|
||||
// value AND its EU-norm pendant by identifier only.
|
||||
//
|
||||
// Engineering rounding values come from MD_OSHA_* IDs in
|
||||
// minimum_distances.go. We do not duplicate the source text here —
|
||||
// the Tech-File renderer can join MD_OSHA_* into the rendered text
|
||||
// at output time.
|
||||
|
||||
func getLiftEndstopMeasures() []ProtectiveMeasureEntry {
|
||||
return []ProtectiveMeasureEntry{
|
||||
// M600 — Cruise/creep speed at end of travel
|
||||
{
|
||||
ID: "M600",
|
||||
ReductionType: "protection",
|
||||
SubType: "speed_control",
|
||||
Name: "Kriechgeschwindigkeit am Endanschlag (Hubgeraete)",
|
||||
Description: "Hubgeschwindigkeit am Ende der Verfahrbewegung (oben und unten) auf maximal 15 mm/s " +
|
||||
"reduzieren. OSHA 29 CFR 1910.217 Hand-Speed-Konstante 63 in/s = 1.600 mm/s als Obergrenze " +
|
||||
"fuer Stopp-Reaktionszeit. Damit ist auch bei spaeter Auslosung der Quetsch-Schaltleiste " +
|
||||
"genug Bremsweg vorhanden.",
|
||||
HazardCategory: "mechanical",
|
||||
Examples: []string{
|
||||
"Hub-Endschalter mit Soft-Stop und Geschwindigkeitsstufe < 15 mm/s in den letzten 50 mm",
|
||||
"Servo-Antrieb mit Ramp-down-Profil ueber die letzten 100 mm Verfahrweg",
|
||||
"Drehzahl-Begrenzer im Frequenzumrichter mit Endlagen-Trigger",
|
||||
},
|
||||
NormReferences: []string{
|
||||
"OSHA 29 CFR 1910.217 (Ds = 63 in/s x Ts)",
|
||||
"EN ISO 13855 (Anordnung von Schutzeinrichtungen)",
|
||||
"EN 1570-1 (Hubtische — Bauanforderungen)",
|
||||
},
|
||||
RiskReduction: &RiskReduction{SeverityDelta: -1, ExposureDelta: -1, ProbabilityDelta: -1},
|
||||
Tags: []string{"crush_point", "gravity_risk", "speed_limit"},
|
||||
},
|
||||
// M601 — Trip-edge sensor under platform (safety bumper)
|
||||
{
|
||||
ID: "M601",
|
||||
ReductionType: "protection",
|
||||
SubType: "safety_device",
|
||||
Name: "Quetsch-Schaltleiste unterhalb der Hubplattform",
|
||||
Description: "Druckempfindliche Schaltleiste (gemaess EN ISO 13856-2) am unteren Rand der Hubplattform " +
|
||||
"loest bei Beruehrung den Hubantrieb sofort aus und kehrt die Bewegung um. Verhindert Quetschung " +
|
||||
"von Fuessen oder Beinen unter absenkender Last. PL c oder hoeher nach EN ISO 13849-1.",
|
||||
HazardCategory: "mechanical",
|
||||
Examples: []string{
|
||||
"Schaltleiste umlaufend an Bodenkante der Hubplattform",
|
||||
"Trittschutz mit redundanter Auswertung am Hubtisch",
|
||||
"Lichtgitter im Bodenbereich als Ergaenzung bei freistehenden Anlagen",
|
||||
},
|
||||
NormReferences: []string{
|
||||
"EN ISO 13856-2 (Schaltleisten)",
|
||||
"EN ISO 13849-1 (PL-Bestimmung)",
|
||||
"EN 1570-1",
|
||||
},
|
||||
RiskReduction: &RiskReduction{SeverityDelta: -2, ExposureDelta: -2, ProbabilityDelta: -2},
|
||||
Tags: []string{"crush_point", "gravity_risk", "safety_device"},
|
||||
},
|
||||
// M602 — Minimum clearance to fixed structure above max lift position
|
||||
{
|
||||
ID: "M602",
|
||||
ReductionType: "design",
|
||||
SubType: "geometry",
|
||||
Name: "Mindestabstand zu festen Strukturen oberhalb der Hubendlage",
|
||||
Description: "Zwischen hoechstem Punkt der Hubeinheit (mit beladenem Werkstueck) und festen Strukturen " +
|
||||
"oberhalb (Decke, Vorbau, Querbalken) muss ein Sicherheitsabstand verbleiben, der das Quetschen " +
|
||||
"von Haenden und Koerper verhindert. Empfehlung: 120 mm fuer Kopf, 100 mm fuer Hand, 25 mm fuer " +
|
||||
"Finger — abgeleitet aus EN 349 / EN ISO 13854 unabhaengig zu pruefen.",
|
||||
HazardCategory: "mechanical",
|
||||
Examples: []string{
|
||||
"Konstruktive Begrenzung der oberen Hubposition durch mechanischen Anschlag",
|
||||
"Software-Endlage mit redundantem Hardware-Sicherheitsschalter",
|
||||
"Auslegungs-Pruefung mit beladener Standard-Palette und Maximal-Hubhoehe",
|
||||
},
|
||||
NormReferences: []string{
|
||||
"EN 349 (Mindestabstaende gegen Quetschen von Koerperteilen)",
|
||||
"EN ISO 13854 (Mindestabstaende gegen Quetschen)",
|
||||
"OSHA 29 CFR 1910.212(a)(5) (Lueftergitter ≤ 1/2 in als Anker)",
|
||||
},
|
||||
RiskReduction: &RiskReduction{SeverityDelta: -2, ExposureDelta: -1},
|
||||
Tags: []string{"crush_point", "gravity_risk"},
|
||||
},
|
||||
// M603 — Hold-to-run with two-hand operation for manual descent
|
||||
{
|
||||
ID: "M603",
|
||||
ReductionType: "protection",
|
||||
SubType: "control_device",
|
||||
Name: "Tippbetrieb / Hold-to-run beim Absenken (mit Verifikations-Nachweis)",
|
||||
Description: "Absenken nur im Tippbetrieb (Hold-to-run): Bedientaster muss waehrend des gesamten " +
|
||||
"Absenkvorgangs gedrueckt gehalten werden. Bei Loslassen stoppt die Bewegung sofort. " +
|
||||
"Im Limits-Form als 'Tippbetrieb' deklariert — durch Tests verifizieren (Stop-Reaktionszeit " +
|
||||
"<= 0,3 s im voll beladenen Zustand).",
|
||||
HazardCategory: "mechanical",
|
||||
Examples: []string{
|
||||
"Tipptaster mit elektrischer Selbstrueckstellung",
|
||||
"Zweihand-Bedienung fuer kritische Absenk-Bereiche (Tipp + Zustimmtaster)",
|
||||
"Pruefprotokoll Stop-Zeit gemaess EN ISO 13849-1 PL c",
|
||||
},
|
||||
NormReferences: []string{
|
||||
"EN ISO 13849-1 (Sicherheitsbezogene Steuerungsteile)",
|
||||
"EN ISO 13851 (Zweihandschaltungen)",
|
||||
"BetrSichV § 4 (Schutzmassnahmen)",
|
||||
},
|
||||
RiskReduction: &RiskReduction{SeverityDelta: -1, ExposureDelta: -2, ProbabilityDelta: -1},
|
||||
Tags: []string{"crush_point", "gravity_risk", "control_device"},
|
||||
},
|
||||
// M604 — Underrun guard / kick plate at platform base
|
||||
{
|
||||
ID: "M604",
|
||||
ReductionType: "design",
|
||||
SubType: "geometry",
|
||||
Name: "Trittblech / Unterfahrschutz an der Hubplattform",
|
||||
Description: "Unter der Hubplattform befindet sich ein umlaufendes Trittblech oder Unterfahrschutz, " +
|
||||
"das das Hineinfahren von Fuessen unter die Plattform mechanisch verhindert. Hoehe ueber Boden " +
|
||||
"maximal 5 mm in unterster Stellung. Trittblech haelt die Last eines Schuhs (mind. 150 kg) " +
|
||||
"ohne Verformung.",
|
||||
HazardCategory: "mechanical",
|
||||
Examples: []string{
|
||||
"Umlaufendes Stahlblech 3 mm Wandstaerke mit Fasen-Kante",
|
||||
"Kombination mit M601 (Schaltleiste) als doppelte Sicherung",
|
||||
"Pruefung jaehrlich auf Verformung und Funktion der Auflage",
|
||||
},
|
||||
NormReferences: []string{
|
||||
"EN 1570-1 (Hubtische)",
|
||||
"EN ISO 13857 (Sicherheitsabstaende)",
|
||||
},
|
||||
RiskReduction: &RiskReduction{SeverityDelta: -2, ExposureDelta: -1},
|
||||
Tags: []string{"crush_point", "gravity_risk"},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package iace
|
||||
|
||||
// Machine-type overrides for legacy patterns that lacked MachineTypes
|
||||
// filtering at authoring time. Applied as a post-load pass in
|
||||
// collectAllPatterns() so we do not need to touch the large pattern
|
||||
// source files (which would push them past the 500-LOC cap).
|
||||
//
|
||||
// Adding an entry here causes the listed pattern IDs to fire ONLY for
|
||||
// projects whose machine_type is in the value list. This eliminates
|
||||
// drift like "Punktschweisselektroden" firing for a Kistenhubgeraet
|
||||
// project just because tags incidentally aligned.
|
||||
|
||||
var legacyMachineTypeOverrides = map[string][]string{
|
||||
// Walzen / Roller hazards — printing, paper, metalworking only.
|
||||
"HP1000": {"printing", "paper", "textile", "metalworking", "rolling_mill", "food_processing"},
|
||||
// HP306 + HP1530 already carry MachineTypes; skip.
|
||||
|
||||
// Welding-specific patterns.
|
||||
"HP539": {"welding", "spot_welding"},
|
||||
|
||||
// Glass-handling tilters.
|
||||
"HP545": {"glass", "glass_processing"},
|
||||
"HP782": {"glass", "glass_processing"},
|
||||
|
||||
// Escalator-specific.
|
||||
"HP756": {"escalator"},
|
||||
"HP757": {"escalator"},
|
||||
"HP760": {"escalator"},
|
||||
|
||||
// CNC machine tools (these fired on Kistenhubgeraet because they
|
||||
// share crush_point + moving_part tags but are bench-mounted tools).
|
||||
"HP1400": {"cnc", "metalworking", "lathe", "milling"},
|
||||
"HP1401": {"cnc", "metalworking", "lathe", "milling"},
|
||||
"HP1402": {"cnc", "metalworking", "lathe", "milling"},
|
||||
|
||||
// Press-specific (Pressenteile/Pressraum/Werkzeugraum).
|
||||
"HP045": {"press", "hydraulic_press", "mechanical_press", "stamping_press"},
|
||||
"HP049": {"press", "hydraulic_press", "mechanical_press", "stamping_press"},
|
||||
|
||||
// Conveyor-belt-specific drift.
|
||||
"HP420": {"conveyor", "packaging", "food_processing"},
|
||||
"HP421": {"conveyor", "packaging", "food_processing"},
|
||||
"HP422": {"conveyor", "packaging", "food_processing"},
|
||||
}
|
||||
|
||||
// applyMachineTypeOverrides mutates the passed slice in place, setting
|
||||
// MachineTypes on any pattern whose ID is in the override map. Patterns
|
||||
// that already have MachineTypes set are NOT overwritten — the override
|
||||
// only fills the gap.
|
||||
func applyMachineTypeOverrides(patterns []HazardPattern) []HazardPattern {
|
||||
for i := range patterns {
|
||||
if len(patterns[i].MachineTypes) > 0 {
|
||||
continue
|
||||
}
|
||||
if mt, ok := legacyMachineTypeOverrides[patterns[i].ID]; ok {
|
||||
patterns[i].MachineTypes = mt
|
||||
}
|
||||
}
|
||||
return patterns
|
||||
}
|
||||
@@ -43,5 +43,7 @@ func collectAllPatterns() []HazardPattern {
|
||||
patterns = append(patterns, GetISO12100GapPatterns()...) // HP1900-HP1909 ISO 12100 Annex B gaps (Vakuum, Federn, Rutsch, Hochdruckinjektion, Ersticken)
|
||||
patterns = append(patterns, GetCRAPatterns()...) // HP1910-HP1918 CRA / DIN EN 40000-1-2 cyber-resilience spur
|
||||
patterns = append(patterns, GetSecondaryHarmDemoPatterns()...) // HP2000-HP2001 secondary harm chain demos (Cola splitter, Pharma)
|
||||
patterns = append(patterns, GetLiftEndstopPatterns()...) // HP2100-HP2102 lift body-part crush at endstops
|
||||
patterns = applyMachineTypeOverrides(patterns) // Fill MachineTypes on legacy patterns to prevent drift
|
||||
return patterns
|
||||
}
|
||||
|
||||
@@ -60,5 +60,9 @@ EXPOSE 8002
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://127.0.0.1:8002/health || exit 1
|
||||
|
||||
# P83 — Build-SHA fuer check-rebuild-needed.sh
|
||||
ARG BUILD_SHA="unknown"
|
||||
ENV BUILD_SHA=${BUILD_SHA}
|
||||
|
||||
# Run the application
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8002"]
|
||||
|
||||
@@ -207,6 +207,22 @@ async def get_snapshot(snapshot_id: str):
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/admin/tcf-ingest")
|
||||
async def tcf_ingest():
|
||||
"""P105 — IAB TCF Vendor-Liste ingestieren / refreshen.
|
||||
Idempotent: holt aktuelle GVL und upserted in compliance.cookie_library
|
||||
mit source='iab_tcf_v2'. Aufruf ein paar Mal pro Jahr ausreichend."""
|
||||
from database import SessionLocal
|
||||
from compliance.services.tcf_vendor_authority import (
|
||||
fetch_and_ingest_tcf_vendors,
|
||||
)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
return await fetch_and_ingest_tcf_vendors(db)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/snapshots/{snapshot_id}/pdf")
|
||||
async def export_snapshot_pdf(snapshot_id: str):
|
||||
"""P88 — PDF-Export der Audit-Mail. Liefert application/pdf."""
|
||||
@@ -1168,6 +1184,22 @@ async def _run_compliance_check(check_id: str, req: ComplianceCheckRequest):
|
||||
if (not c.passed and not c.skipped
|
||||
and (c.severity or "").upper() in ("CRITICAL", "HIGH")):
|
||||
fails_by_doc.setdefault(r.doc_type, []).append(rec)
|
||||
# P106 — Audit-Type-Klassifizierung pro MC. Interne Prozess-/
|
||||
# Doku-Checks werden NICHT als FAIL gewertet sondern als CHECK
|
||||
# (manuelle Pruefung beim DSB notwendig).
|
||||
try:
|
||||
from compliance.services.mc_audit_type import (
|
||||
annotate_mc_results, split_by_audit_type,
|
||||
)
|
||||
annotate_mc_results(all_mc_checks)
|
||||
mc_split = split_by_audit_type(all_mc_checks)
|
||||
# Fails-by-doc neu aufbauen: nur noch echte verifiable Fails
|
||||
fails_by_doc = {}
|
||||
for r in mc_split.get("verifiable_fails") or []:
|
||||
fails_by_doc.setdefault("dse", []).append(r)
|
||||
except Exception as e:
|
||||
logger.warning("P106 mc_audit_type skipped: %s", e)
|
||||
mc_split = {"internal_checks": [], "verifiable_fails": all_mc_checks}
|
||||
scorecard = build_scorecard(all_mc_checks) if all_mc_checks else {}
|
||||
# Trend: load previous scorecard for the same tenant + domain so the
|
||||
# email can show delta indicators (A6).
|
||||
@@ -1285,6 +1317,53 @@ async def _run_compliance_check(check_id: str, req: ComplianceCheckRequest):
|
||||
except Exception as e:
|
||||
logger.warning("Scope-disclaimer block skipped: %s", e)
|
||||
|
||||
# P103 + P104 — Cookie-Value-Entropy + Network-Tracing (Stufe 3 + 4)
|
||||
entropy_html = ""
|
||||
network_trace_html = ""
|
||||
try:
|
||||
from compliance.services.cookie_value_entropy import (
|
||||
check_cookies_for_entropy_mismatch, build_entropy_block_html,
|
||||
)
|
||||
from compliance.services.cookie_network_tracer import (
|
||||
trace_cookie_network, build_network_trace_block_html,
|
||||
)
|
||||
cookies_detailed = (banner_result or {}).get("cookies_detailed") or []
|
||||
entropy_findings = check_cookies_for_entropy_mismatch(cookies_detailed)
|
||||
if entropy_findings:
|
||||
entropy_html = build_entropy_block_html(entropy_findings)
|
||||
logger.info("P103 Entropy: %d Findings", len(entropy_findings))
|
||||
primary_url = ""
|
||||
for e_ in doc_entries:
|
||||
if e_.get("url"):
|
||||
primary_url = e_["url"]; break
|
||||
net_findings = trace_cookie_network(cookies_detailed, primary_url)
|
||||
if net_findings:
|
||||
network_trace_html = build_network_trace_block_html(net_findings)
|
||||
logger.info("P104 Network-Trace: %d Findings", len(net_findings))
|
||||
except Exception as e:
|
||||
logger.warning("P103/P104 entropy/network-trace skipped: %s", e)
|
||||
|
||||
# P105 — IAB TCF Authority-Cross-Reference (Stufe 5)
|
||||
tcf_authority_html = ""
|
||||
try:
|
||||
from compliance.services.tcf_vendor_authority import (
|
||||
cross_reference_with_tcf, build_tcf_authority_block_html,
|
||||
)
|
||||
from database import SessionLocal as _SLtcf
|
||||
_tcf_db = _SLtcf()
|
||||
try:
|
||||
tcf_findings = cross_reference_with_tcf(_tcf_db, cmp_vendors)
|
||||
if tcf_findings:
|
||||
tcf_authority_html = build_tcf_authority_block_html(tcf_findings)
|
||||
logger.info(
|
||||
"TCF-Authority: %d Vendor-Discrepancies gefunden",
|
||||
len(tcf_findings),
|
||||
)
|
||||
finally:
|
||||
_tcf_db.close()
|
||||
except Exception as e:
|
||||
logger.warning("TCF-Authority-Check skipped: %s", e)
|
||||
|
||||
# COOKIE-COMPLIANCE-AUDIT (3-Quellen-Vergleich) — das ist der
|
||||
# zentrale USP: deklariert in Richtlinie vs tatsaechlich im
|
||||
# Browser geladen vs Library-Match.
|
||||
@@ -1423,6 +1502,50 @@ async def _run_compliance_check(check_id: str, req: ComplianceCheckRequest):
|
||||
except Exception as e:
|
||||
logger.warning("P71 jc_avv_decision skipped: %s", e)
|
||||
|
||||
# P6/P53/P55 — Branchen-Kontext + Site-History
|
||||
industry_ctx_html = ""
|
||||
try:
|
||||
from compliance.services.industry_library import (
|
||||
build_industry_context_block_html, load_site_profile,
|
||||
)
|
||||
from database import SessionLocal as _SLib
|
||||
_ind_db = _SLib()
|
||||
try:
|
||||
ind = (req.scan_context or {}).get("industry") if req.scan_context else None
|
||||
site_prof = load_site_profile(_ind_db, domain_for_exec or "")
|
||||
industry_ctx_html = build_industry_context_block_html(ind, site_prof)
|
||||
finally:
|
||||
_ind_db.close()
|
||||
except Exception as e:
|
||||
logger.warning("industry context skipped: %s", e)
|
||||
|
||||
# P106 — Internal-Checks-Block (interne Prozesse / Doku-Pflichten)
|
||||
internal_checks_html = ""
|
||||
try:
|
||||
from compliance.services.mc_audit_type import (
|
||||
build_internal_checks_block_html,
|
||||
)
|
||||
ic = (mc_split or {}).get("internal_checks") or []
|
||||
if ic:
|
||||
internal_checks_html = build_internal_checks_block_html(ic)
|
||||
logger.info(
|
||||
"P106: %d interne Checks (statt FAIL) im Block",
|
||||
len(ic),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("P106 internal_checks_html skipped: %s", e)
|
||||
|
||||
# P85 — Banner-Screenshot fuer visuellen Beweis (zwischen
|
||||
# GF-1-Pager und Detail-Bloecken)
|
||||
banner_shot_html = ""
|
||||
try:
|
||||
from compliance.services.banner_screenshot_block import (
|
||||
build_banner_screenshot_html,
|
||||
)
|
||||
banner_shot_html = build_banner_screenshot_html(banner_result)
|
||||
except Exception as e:
|
||||
logger.warning("P85 banner-screenshot skipped: %s", e)
|
||||
|
||||
# P82: GF-1-Pager ganz oben in der Mail — 5-Bullet-Zusammenfassung
|
||||
# damit die GF nicht 124k Char lesen muss.
|
||||
gf_one_pager_html = ""
|
||||
@@ -1521,9 +1644,14 @@ async def _run_compliance_check(check_id: str, req: ComplianceCheckRequest):
|
||||
+ bench_html + diff_html
|
||||
+ critical_html + scope_disclaimer_html + exec_summary_html
|
||||
+ cookie_arch_html + summary_html + scanned_html + profile_html
|
||||
+ scorecard_html + redundancy_html
|
||||
+ scorecard_html + internal_checks_html + redundancy_html
|
||||
+ industry_ctx_html
|
||||
+ banner_shot_html
|
||||
+ providers_html + banner_deep_html
|
||||
+ cookie_audit_html
|
||||
+ tcf_authority_html
|
||||
+ entropy_html
|
||||
+ network_trace_html
|
||||
+ library_mismatch_html
|
||||
+ consistency_html + signals_html + solutions_html
|
||||
+ jc_decision_html
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"source": "Verordnung (EU) 2015/758 - eCall",
|
||||
"official_url": "https://eur-lex.europa.eu/legal-content/DE/TXT/?uri=CELEX%3A32015R0758",
|
||||
"ingest_for": "RAG-Korpus (Compliance fuer Automotive-OEMs)",
|
||||
"chunks": [
|
||||
{
|
||||
"id": "ecall-art-3-1",
|
||||
"title": "Art. 3 (1) — bordeigenes eCall-System",
|
||||
"text": "Hersteller stellen sicher, dass alle neuen Typen von Personenkraftwagen und leichten Nutzfahrzeugen mit einem auf 112 basierten bordeigenen eCall-System ausgestattet sind, das den in dieser Verordnung festgelegten Anforderungen und harmonisierten Normen entspricht."
|
||||
},
|
||||
{
|
||||
"id": "ecall-art-6-1",
|
||||
"title": "Art. 6 (1) — Datenschutz",
|
||||
"text": "Bei der Verarbeitung personenbezogener Daten ueber das auf 112 basierte bordeigene eCall-System gewaehrleisten Hersteller die Einhaltung der Richtlinie 95/46/EG und der RL 2002/58/EG. Insbesondere muessen Fahrzeughalter darueber informiert werden, dass das System dauerhaft im Standby-Modus ist und im Falle eines schweren Unfalls automatisch ausgeloest wird."
|
||||
},
|
||||
{
|
||||
"id": "ecall-art-6-2",
|
||||
"title": "Art. 6 (2) — Datenverarbeitung",
|
||||
"text": "Die Verarbeitung personenbezogener Daten ueber das auf 112 basierte bordeigene eCall-System darf nur zum Zwecke der Bearbeitung von Notrufen erfolgen. Diese Daten sind unmittelbar nach Bearbeitung des Notrufs ohne automatisierte Speicherung zu loeschen, soweit nicht anders gesetzlich vorgesehen."
|
||||
},
|
||||
{
|
||||
"id": "ecall-art-6-3",
|
||||
"title": "Art. 6 (3) — Standortdaten",
|
||||
"text": "Die Standortdaten des Fahrzeugs werden zur Behandlung des Notrufes uebermittelt. Eine permanente Standortueberwachung ausserhalb von Notfaellen ist nicht zulaessig."
|
||||
},
|
||||
{
|
||||
"id": "ecall-art-6-4",
|
||||
"title": "Art. 6 (4) — Informationspflicht",
|
||||
"text": "Hersteller stellen sicher, dass in der technischen Dokumentation des Fahrzeugs klare und vollstaendige Informationen ueber die Verarbeitung personenbezogener Daten gegeben werden, einschliesslich des Rechts der betroffenen Person auf Auskunft und gegebenenfalls Berichtigung sowie Sperrung der sie betreffenden personenbezogenen Daten."
|
||||
},
|
||||
{
|
||||
"id": "ecall-art-6-5",
|
||||
"title": "Art. 6 (5) — Mehrwertdienste",
|
||||
"text": "Mehrwertdienste (z.B. private Pannenruf-Apps) duerfen nur mit ausdruecklicher Einwilligung des Fahrzeughalters in Anspruch genommen werden. Das auf 112 basierte bordeigene eCall-System darf nicht von diesen Mehrwertdiensten beeintraechtigt werden und muss kostenlos und fuer alle Fahrzeughalter verfuegbar sein."
|
||||
},
|
||||
{
|
||||
"id": "ecall-art-7",
|
||||
"title": "Art. 7 — Datenfluss",
|
||||
"text": "Der Mindestdatensatz (MSD) umfasst Fahrzeug-ID (VIN), Ausloesungsart, Zeitstempel, Standort, Fahrtrichtung, Antriebsenergie, Anzahl angeschnallter Insassen. Diese Daten gehen an die naechste oeffentliche Notrufabfragestelle (PSAP)."
|
||||
}
|
||||
],
|
||||
"compliance_implications": {
|
||||
"automotive_oem": [
|
||||
"Hersteller MUSS in der DSE den eCall-Datenfluss erklaeren (Art. 6 (4)).",
|
||||
"Standortdaten ausserhalb von Notfaellen sind UNZULAESSIG (Art. 6 (3)).",
|
||||
"Mehrwertdienste brauchen separate ausdrueckliche Einwilligung (Art. 6 (5)).",
|
||||
"Daten nach Notruf-Bearbeitung SOFORT zu loeschen (Art. 6 (2))."
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
"""
|
||||
P85 — Banner-Screenshot-Block in der Mail.
|
||||
|
||||
Embedded den von consent-tester captured Screenshot des Banners
|
||||
(banner_result.banner_screenshot_b64) als data-URI <img> in die Mail.
|
||||
"so sah euer Banner zum Audit-Zeitpunkt aus" — visueller Beweis fuer
|
||||
Dispute mit Marketing-Team oder DSB.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def build_banner_screenshot_html(banner_result: dict | None) -> str:
|
||||
if not isinstance(banner_result, dict):
|
||||
return ""
|
||||
b64 = banner_result.get("banner_screenshot_b64") or ""
|
||||
if not b64 or len(b64) < 200:
|
||||
return ""
|
||||
provider = banner_result.get("banner_provider") or "Generic"
|
||||
detected = banner_result.get("banner_detected")
|
||||
return (
|
||||
'<div style="font-family:-apple-system,BlinkMacSystemFont,sans-serif;'
|
||||
'max-width:760px;margin:0 auto 16px;padding:12px 16px;'
|
||||
'background:#f8fafc;border:1px solid #cbd5e1;border-radius:8px">'
|
||||
'<div style="font-size:11px;color:#475569;text-transform:uppercase;'
|
||||
'letter-spacing:1.2px;margin-bottom:4px;font-weight:600">'
|
||||
'Screenshot des Cookie-Banners zum Audit-Zeitpunkt</div>'
|
||||
f'<h3 style="margin:0 0 6px;font-size:13px;color:#1e293b">'
|
||||
f'Provider: <strong>{provider}</strong> · '
|
||||
f'erkannt: <strong>{"ja" if detected else "nein"}</strong></h3>'
|
||||
'<p style="margin:0 0 8px;font-size:11px;color:#64748b;line-height:1.5">'
|
||||
'Visueller Beweis wie das Banner zum Zeitpunkt des Audits angezeigt '
|
||||
'wurde. Bei spaeterer Aenderung des Banners bitte mit diesem '
|
||||
'Screenshot abgleichen.'
|
||||
'</p>'
|
||||
f'<img src="data:image/png;base64,{b64}" alt="Cookie-Banner" '
|
||||
f'style="max-width:100%;height:auto;border:1px solid #cbd5e1;'
|
||||
f'border-radius:4px;display:block">'
|
||||
'</div>'
|
||||
)
|
||||
@@ -85,6 +85,82 @@ def replay_from_snapshot(
|
||||
section_sizes: dict[str, int] = {}
|
||||
parts: list[str] = []
|
||||
|
||||
# P80 v2 — Quality-Checks aus dem aktuellen Code auf Snapshot-Daten
|
||||
# anwenden. Vollstaendiger Replay aller post-fetch Findings-Generatoren.
|
||||
cookie_t = doc_texts.get("cookie") or doc_texts.get("dse") or ""
|
||||
|
||||
# Vendor-Normalize (Dedup + Garbage-Filter)
|
||||
try:
|
||||
from compliance.services.vendor_normalizer import normalize_vendors
|
||||
cmp_vendors = normalize_vendors(list(cmp_vendors))
|
||||
except Exception as e:
|
||||
logger.warning("Replay v2: normalizer failed: %s", e)
|
||||
|
||||
# Audit-Quality
|
||||
try:
|
||||
from compliance.services.audit_quality_checks import (
|
||||
run_all as run_aq, build_audit_quality_block_html,
|
||||
)
|
||||
aq = run_aq(banner_result, cookie_t, cmp_vendors, doc_entries)
|
||||
if aq:
|
||||
aq_html = build_audit_quality_block_html(aq)
|
||||
parts.append(aq_html)
|
||||
section_sizes["audit_quality_v2"] = len(aq_html)
|
||||
except Exception as e:
|
||||
logger.warning("Replay v2: audit_quality failed: %s", e)
|
||||
|
||||
# Cookie-Compliance-Audit
|
||||
try:
|
||||
from compliance.services.cookie_compliance_audit import (
|
||||
audit_cookie_compliance, build_cookie_audit_block_html,
|
||||
)
|
||||
ca = audit_cookie_compliance(db, cookie_t, banner_result)
|
||||
if ca and (ca.get("declared_count") or ca.get("browser_count")):
|
||||
ca_html = build_cookie_audit_block_html(ca)
|
||||
parts.append(ca_html)
|
||||
section_sizes["cookie_audit_v2"] = len(ca_html)
|
||||
except Exception as e:
|
||||
logger.warning("Replay v2: cookie_audit failed: %s", e)
|
||||
|
||||
# TCF Authority
|
||||
try:
|
||||
from compliance.services.tcf_vendor_authority import (
|
||||
cross_reference_with_tcf, build_tcf_authority_block_html,
|
||||
)
|
||||
tcf = cross_reference_with_tcf(db, cmp_vendors)
|
||||
if tcf:
|
||||
tcf_html = build_tcf_authority_block_html(tcf)
|
||||
parts.append(tcf_html)
|
||||
section_sizes["tcf_v2"] = len(tcf_html)
|
||||
except Exception as e:
|
||||
logger.warning("Replay v2: tcf failed: %s", e)
|
||||
|
||||
# Entropy + Network-Trace
|
||||
try:
|
||||
from compliance.services.cookie_value_entropy import (
|
||||
check_cookies_for_entropy_mismatch, build_entropy_block_html,
|
||||
)
|
||||
from compliance.services.cookie_network_tracer import (
|
||||
trace_cookie_network, build_network_trace_block_html,
|
||||
)
|
||||
cd = (banner_result or {}).get("cookies_detailed") or []
|
||||
e1 = check_cookies_for_entropy_mismatch(cd)
|
||||
if e1:
|
||||
ent_html = build_entropy_block_html(e1)
|
||||
parts.append(ent_html)
|
||||
section_sizes["entropy_v2"] = len(ent_html)
|
||||
site_url = ""
|
||||
for entry in (doc_entries or []):
|
||||
if entry.get("url"):
|
||||
site_url = entry["url"]; break
|
||||
net = trace_cookie_network(cd, site_url)
|
||||
if net:
|
||||
net_html = build_network_trace_block_html(net)
|
||||
parts.append(net_html)
|
||||
section_sizes["network_trace_v2"] = len(net_html)
|
||||
except Exception as e:
|
||||
logger.warning("Replay v2: entropy/network failed: %s", e)
|
||||
|
||||
# P82: GF-1-Pager zuerst (5-Bullet-Summary)
|
||||
try:
|
||||
from compliance.services.gf_one_pager import build_gf_one_pager_html
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
"""
|
||||
P54 — Diff-Banner fuer End-User (USP-Feature).
|
||||
|
||||
USP-Idee: bei wiederkehrenden Besuchern zeigt das Banner NICHT die
|
||||
Standard-Frage, sondern eine Diff-Mitteilung:
|
||||
"Seit deiner letzten Zustimmung haben wir hinzugefuegt:
|
||||
* Microsoft Bing (Werbung)
|
||||
* TikTok Pixel (Marketing)
|
||||
Bitte erneut zustimmen oder anpassen."
|
||||
|
||||
Backend-Seite (hier): liefert pro Snapshot eine 'diff_for_user'-Struktur
|
||||
die zum Embedden in eigenen Banner / Hinweistext genutzt werden kann.
|
||||
Frontend-Banner-Lib (separate consent-sdk) konsumiert das.
|
||||
|
||||
Vergleicht Vendor-Listen zwischen aktuellem Snapshot und dem letzten
|
||||
Snapshot mit gleicher site_domain.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Iterable
|
||||
|
||||
from sqlalchemy import text as sa_text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _norm_vendor_set(vendors: Iterable) -> set[str]:
|
||||
out: set[str] = set()
|
||||
for v in (vendors or []):
|
||||
if isinstance(v, dict):
|
||||
n = (v.get("name") or "").strip()
|
||||
elif isinstance(v, str):
|
||||
n = v.strip()
|
||||
else:
|
||||
continue
|
||||
if n:
|
||||
out.add(n)
|
||||
return out
|
||||
|
||||
|
||||
def compute_user_facing_diff(
|
||||
db: Session,
|
||||
site_domain: str,
|
||||
current_check_id: str,
|
||||
current_cmp_vendors: list,
|
||||
) -> dict | None:
|
||||
"""Vergleicht aktuelle vs letzte cmp_vendors-Liste fuer die gleiche
|
||||
site_domain. Liefert {prev_at, added_vendors, removed_vendors,
|
||||
new_high_risk_categories} oder None wenn kein vorheriger Lauf."""
|
||||
if not site_domain:
|
||||
return None
|
||||
try:
|
||||
row = db.execute(sa_text(
|
||||
"""
|
||||
SELECT cmp_vendors, created_at
|
||||
FROM compliance.compliance_check_snapshots
|
||||
WHERE site_domain = :dom AND check_id != :ex
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
"""
|
||||
), {"dom": site_domain, "ex": current_check_id}).fetchone()
|
||||
except Exception as e:
|
||||
logger.warning("diff lookup failed: %s", e)
|
||||
return None
|
||||
if not row:
|
||||
return None
|
||||
|
||||
prev_vendors = row[0] or []
|
||||
prev_at = row[1]
|
||||
curr_set = _norm_vendor_set(current_cmp_vendors)
|
||||
prev_set = _norm_vendor_set(prev_vendors)
|
||||
|
||||
added = sorted(curr_set - prev_set)
|
||||
removed = sorted(prev_set - curr_set)
|
||||
if not added and not removed:
|
||||
return None
|
||||
|
||||
# High-risk Kategorien aus added Vendors: Marketing / Tracking
|
||||
new_marketing: list[str] = []
|
||||
for v in current_cmp_vendors:
|
||||
if not isinstance(v, dict):
|
||||
continue
|
||||
n = (v.get("name") or "").strip()
|
||||
cat = (v.get("category") or "").lower()
|
||||
if n in added and cat in ("marketing", "tracking", "advertising"):
|
||||
new_marketing.append(n)
|
||||
|
||||
return {
|
||||
"prev_at": prev_at.isoformat() if prev_at else None,
|
||||
"added_vendors": added,
|
||||
"removed_vendors": removed,
|
||||
"new_marketing_vendors": new_marketing,
|
||||
"requires_reconsent": bool(new_marketing),
|
||||
}
|
||||
|
||||
|
||||
def build_diff_banner_snippet(diff: dict) -> str:
|
||||
"""Liefert HTML-Snippet das der Site-Betreiber in seinen eigenen
|
||||
Cookie-Banner einbauen kann (z.B. via consent-sdk)."""
|
||||
if not diff or not diff.get("added_vendors"):
|
||||
return ""
|
||||
added = diff.get("added_vendors", [])
|
||||
n_marketing = len(diff.get("new_marketing_vendors") or [])
|
||||
items = "".join(f"<li>{v}</li>" for v in added[:8])
|
||||
reconsent_note = ""
|
||||
if diff.get("requires_reconsent"):
|
||||
reconsent_note = (
|
||||
f'<p style="margin:6px 0 0;color:#991b1b;font-size:12px">'
|
||||
f'<strong>{n_marketing} neue{"r" if n_marketing == 1 else ""} '
|
||||
f'Marketing-Anbieter</strong> seit Ihrer letzten Zustimmung — '
|
||||
'bitte erneut bestaetigen.'
|
||||
'</p>'
|
||||
)
|
||||
return (
|
||||
'<div class="breakpilot-consent-diff" '
|
||||
'style="font-family:-apple-system,sans-serif;font-size:12px;'
|
||||
'padding:8px 12px;background:#fef3c7;border:1px solid #fde68a;'
|
||||
'border-radius:6px;margin-bottom:8px">'
|
||||
'<strong>Seit Ihrer letzten Zustimmung haben wir hinzugefuegt:</strong>'
|
||||
f'<ul style="margin:4px 0 0 18px;padding:0">{items}</ul>'
|
||||
+ reconsent_note +
|
||||
'</div>'
|
||||
)
|
||||
@@ -0,0 +1,216 @@
|
||||
"""
|
||||
P104 — Cookie-Network-Tracing (Stufe 4).
|
||||
|
||||
cookies_detailed[i].domain zeigt welche Domain das Cookie via Set-Cookie
|
||||
gesetzt hat. Wir vergleichen:
|
||||
* Site-Hauptdomain vs Cookie-Domain → First-Party / Third-Party
|
||||
* Cookie-Domain vs bekannte Vendoren → wer ist der echte Empfaenger
|
||||
* Vendor-Land vs EU/Drittland → Drittland-Transfer-Hinweis
|
||||
|
||||
Defeat-Device-Pattern: "Funktional"-Cookie wird aber von doubleclick.net
|
||||
gesetzt → das ist physisch ein Third-Party-Tracking-Cookie, kein
|
||||
funktionales First-Party-Cookie.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from urllib.parse import urlparse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Vendor-Domain → bekannter Vendor + Land
|
||||
_DOMAIN_VENDORS: dict[str, tuple[str, str]] = {
|
||||
".doubleclick.net": ("Google DoubleClick", "US"),
|
||||
".google.com": ("Google", "US"),
|
||||
".google-analytics.com": ("Google Analytics", "US"),
|
||||
".googletagmanager.com": ("Google Tag Manager", "US"),
|
||||
".googleadservices.com": ("Google Ads", "US"),
|
||||
".gstatic.com": ("Google CDN", "US"),
|
||||
".facebook.com": ("Meta / Facebook", "US"),
|
||||
".facebook.net": ("Meta / Facebook", "US"),
|
||||
".instagram.com": ("Meta / Instagram", "US"),
|
||||
".linkedin.com": ("LinkedIn (Microsoft)", "US"),
|
||||
".pinterest.com": ("Pinterest", "US"),
|
||||
".pinimg.com": ("Pinterest", "US"),
|
||||
".tiktok.com": ("TikTok (ByteDance)", "CN"),
|
||||
".bing.com": ("Microsoft Bing", "US"),
|
||||
".clarity.ms": ("Microsoft Clarity", "US"),
|
||||
".criteo.com": ("Criteo", "FR"),
|
||||
".adnxs.com": ("AppNexus / Xandr", "US"),
|
||||
".rubiconproject.com": ("Rubicon Project", "US"),
|
||||
".pubmatic.com": ("PubMatic", "US"),
|
||||
".adobedtm.com": ("Adobe DTM", "US"),
|
||||
".adobetarget.com": ("Adobe Target", "US"),
|
||||
".demdex.net": ("Adobe Experience Cloud", "US"),
|
||||
".omtrdc.net": ("Adobe Analytics", "US"),
|
||||
".everesttech.net": ("Adobe Advertising Cloud", "US"),
|
||||
".2o7.net": ("Adobe Analytics", "US"),
|
||||
".adform.net": ("AdForm", "DK"),
|
||||
".trade-desk.com": ("The Trade Desk", "US"),
|
||||
".tradedesk.com": ("The Trade Desk", "US"),
|
||||
".adsrvr.org": ("The Trade Desk", "US"),
|
||||
".hotjar.com": ("Hotjar", "MT"),
|
||||
".matomo.cloud": ("Matomo", "DE"),
|
||||
".etracker.com": ("etracker", "DE"),
|
||||
".etracker.de": ("etracker", "DE"),
|
||||
".cloudflare.com": ("Cloudflare", "US"),
|
||||
".cookielaw.org": ("OneTrust", "US"),
|
||||
".cookiebot.com": ("Cookiebot (Cybot)", "DK"),
|
||||
".usercentrics.eu": ("Usercentrics", "DE"),
|
||||
".usercentrics.com": ("Usercentrics", "DE"),
|
||||
".consensu.org": ("IAB Europe TCF", "BE"),
|
||||
".datadoghq.eu": ("Datadog", "US"),
|
||||
".datadoghq.com": ("Datadog", "US"),
|
||||
".datadome.co": ("DataDome", "FR"),
|
||||
".incapsula.com": ("Imperva Incapsula", "US"),
|
||||
".imperva.com": ("Imperva", "US"),
|
||||
".akamai.net": ("Akamai", "US"),
|
||||
".akamaiedge.net": ("Akamai", "US"),
|
||||
".salesforce.com": ("Salesforce", "US"),
|
||||
".force.com": ("Salesforce", "US"),
|
||||
}
|
||||
|
||||
_NON_EU_COUNTRIES = {"US", "CN", "RU", "IN", "JP", "BR", "AU"}
|
||||
|
||||
|
||||
def _registrable_domain(host: str) -> str:
|
||||
"""vw.de von www.vw.de oder bla.vw.de oder vw.de"""
|
||||
h = (host or "").lstrip(".").lower()
|
||||
parts = h.split(".")
|
||||
if len(parts) >= 2:
|
||||
return ".".join(parts[-2:])
|
||||
return h
|
||||
|
||||
|
||||
def _lookup_vendor_by_domain(cookie_domain: str) -> tuple[str, str] | None:
|
||||
if not cookie_domain:
|
||||
return None
|
||||
cd = cookie_domain.lower()
|
||||
if not cd.startswith("."):
|
||||
cd = "." + cd
|
||||
for suffix, (vendor, country) in _DOMAIN_VENDORS.items():
|
||||
if cd.endswith(suffix):
|
||||
return (vendor, country)
|
||||
return None
|
||||
|
||||
|
||||
def trace_cookie_network(
|
||||
cookies_detailed: list[dict] | None,
|
||||
site_url: str | None = None,
|
||||
) -> list[dict]:
|
||||
"""Liefert Findings fuer Cookies die von externer/Drittland-Domain
|
||||
gesetzt werden waehrend sie als First-Party / essential deklariert sind."""
|
||||
if not cookies_detailed:
|
||||
return []
|
||||
site_host = ""
|
||||
if site_url:
|
||||
try:
|
||||
site_host = _registrable_domain(urlparse(site_url).netloc)
|
||||
except Exception:
|
||||
site_host = ""
|
||||
|
||||
out: list[dict] = []
|
||||
for ck in cookies_detailed:
|
||||
if not isinstance(ck, dict):
|
||||
continue
|
||||
name = (ck.get("name") or "").strip()
|
||||
domain = (ck.get("domain") or "").strip()
|
||||
declared = (ck.get("declared_category") or "").lower().strip()
|
||||
if not name or not domain:
|
||||
continue
|
||||
|
||||
cookie_reg = _registrable_domain(domain)
|
||||
is_third_party = bool(site_host and cookie_reg != site_host)
|
||||
vendor_match = _lookup_vendor_by_domain(domain)
|
||||
|
||||
if not vendor_match and not is_third_party:
|
||||
continue
|
||||
|
||||
# Defeat-Device-Pattern: essential/functional + Third-Party
|
||||
if declared in ("essential", "functional", "necessary") and is_third_party:
|
||||
sev = "HIGH" if vendor_match else "MEDIUM"
|
||||
vendor_name = vendor_match[0] if vendor_match else cookie_reg
|
||||
country = vendor_match[1] if vendor_match else ""
|
||||
third_country = country in _NON_EU_COUNTRIES
|
||||
out.append({
|
||||
"cookie": name,
|
||||
"declared": declared,
|
||||
"cookie_domain": domain,
|
||||
"site_domain": site_host,
|
||||
"vendor": vendor_name,
|
||||
"vendor_country": country,
|
||||
"third_country": third_country,
|
||||
"severity": sev,
|
||||
"label": (
|
||||
f"Cookie '{name}' deklariert als '{declared}', "
|
||||
f"wird aber von externer Domain "
|
||||
f"<strong>{vendor_name}</strong> "
|
||||
f"({domain}) gesetzt"
|
||||
+ (f" — Drittland: {country}" if third_country else "")
|
||||
),
|
||||
})
|
||||
elif vendor_match and declared in ("essential", "functional", "necessary"):
|
||||
# Auch wenn First-Party-Cookie aber bekannter Tracker-Vendor →
|
||||
# Mismatch (z.B. Google Tag Manager kann via CNAME als
|
||||
# First-Party erscheinen)
|
||||
out.append({
|
||||
"cookie": name,
|
||||
"declared": declared,
|
||||
"cookie_domain": domain,
|
||||
"vendor": vendor_match[0],
|
||||
"vendor_country": vendor_match[1],
|
||||
"third_country": vendor_match[1] in _NON_EU_COUNTRIES,
|
||||
"severity": "MEDIUM",
|
||||
"label": (
|
||||
f"Cookie '{name}' deklariert als '{declared}', "
|
||||
f"Domain {domain} gehoert aber zu "
|
||||
f"<strong>{vendor_match[0]}</strong> "
|
||||
f"({vendor_match[1]})"
|
||||
),
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def build_network_trace_block_html(findings: list[dict]) -> str:
|
||||
if not findings:
|
||||
return ""
|
||||
n_third = sum(1 for f in findings if f.get("third_country"))
|
||||
items: list[str] = []
|
||||
for f in findings[:30]:
|
||||
sev_color = "#dc2626" if f["severity"] == "HIGH" else "#d97706"
|
||||
country_flag = ""
|
||||
if f.get("third_country"):
|
||||
country_flag = (
|
||||
f' <span style="background:#fee2e2;color:#991b1b;'
|
||||
f'padding:1px 5px;border-radius:8px;font-size:9px;'
|
||||
f'font-weight:600">DRITTLAND {f.get("vendor_country","")}</span>'
|
||||
)
|
||||
items.append(
|
||||
f'<li style="margin-bottom:6px;font-size:11px;line-height:1.5;'
|
||||
f'color:{sev_color}">{f["label"]}{country_flag}</li>'
|
||||
)
|
||||
return (
|
||||
'<div style="font-family:-apple-system,BlinkMacSystemFont,sans-serif;'
|
||||
'max-width:760px;margin:0 auto 16px;padding:14px 18px;'
|
||||
'background:#fff7ed;border:1px solid #fed7aa;border-radius:8px">'
|
||||
'<div style="font-size:11px;color:#9a3412;text-transform:uppercase;'
|
||||
'letter-spacing:1.2px;margin-bottom:4px;font-weight:600">'
|
||||
'Cookie-Netzwerk-Verhalten (Defeat-Device-Heuristik)</div>'
|
||||
f'<h3 style="margin:0 0 6px;font-size:14px;color:#1e293b">'
|
||||
f'{len(findings)} Cookie{"s" if len(findings) != 1 else ""} '
|
||||
f'mit Vendor-Domain-Diskrepanz'
|
||||
f'{f" — davon {n_third} mit Drittland-Transfer" if n_third else ""}'
|
||||
f'</h3>'
|
||||
'<p style="margin:0 0 10px;font-size:11px;color:#475569;line-height:1.5">'
|
||||
'Diese Cookies sind als "essential" oder "funktional" deklariert, '
|
||||
'werden aber von einer externen Domain gesetzt — typisch fuer '
|
||||
'getarnte Tracker. Drittland-Markierungen sind besonders kritisch: '
|
||||
'sie loesen Pflichten nach Art. 44-49 DSGVO aus (SCC / Angemessen-'
|
||||
'heitsbeschluss / Schrems II Folge-Massnahmen).'
|
||||
'</p>'
|
||||
'<ul style="margin:0 0 0 18px;padding:0">'
|
||||
+ "".join(items) +
|
||||
'</ul></div>'
|
||||
)
|
||||
@@ -0,0 +1,148 @@
|
||||
"""
|
||||
P103 — Cookie-Value-Entropy-Check (Stufe 3).
|
||||
|
||||
Bewertet ob der Cookie-Wert zur deklarierten Kategorie passt:
|
||||
* "Funktional" + 2-char-Wert ('1', 'de') → konsistent (Flag)
|
||||
* "Funktional" + 64-char-Base64 → INKONSISTENT (Tracking-ID-Pattern)
|
||||
* "Marketing" + 32+ char Hash → konsistent
|
||||
* "Marketing" + 2-char-Wert → konsistent (Boolean-Opt-Out)
|
||||
|
||||
Defeat-Device-Pattern: Site deklariert "Funktional" um Consent zu
|
||||
umgehen, aber Wert sieht wie pseudonymisierte Tracking-ID aus.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _shannon_entropy(s: str) -> float:
|
||||
if not s:
|
||||
return 0.0
|
||||
from collections import Counter
|
||||
n = len(s)
|
||||
counts = Counter(s)
|
||||
return -sum((c / n) * math.log2(c / n) for c in counts.values())
|
||||
|
||||
|
||||
_BASE64_RE = re.compile(r"^[A-Za-z0-9+/=_-]{20,}$")
|
||||
_HEX_RE = re.compile(r"^[a-fA-F0-9]{16,}$")
|
||||
_UUID_RE = re.compile(
|
||||
r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-"
|
||||
r"[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
|
||||
)
|
||||
_FLAG_VALUES = {"0", "1", "true", "false", "yes", "no",
|
||||
"de", "en", "de-de", "en-us", "fr-fr",
|
||||
"accept", "deny", "essential", "on", "off"}
|
||||
|
||||
|
||||
def _classify_value_shape(value: str) -> str:
|
||||
"""Returns one of: 'flag', 'short_id', 'long_token', 'uuid', 'hash',
|
||||
'json_blob', 'unknown'."""
|
||||
if not value:
|
||||
return "flag"
|
||||
v = value.strip()
|
||||
if v.lower() in _FLAG_VALUES:
|
||||
return "flag"
|
||||
if len(v) <= 4:
|
||||
return "flag"
|
||||
if _UUID_RE.match(v):
|
||||
return "uuid"
|
||||
if _HEX_RE.match(v) and len(v) >= 32:
|
||||
return "hash"
|
||||
if _BASE64_RE.match(v) and len(v) >= 40:
|
||||
return "long_token"
|
||||
if v.startswith("{") or v.startswith("["):
|
||||
return "json_blob"
|
||||
if len(v) >= 16 and _shannon_entropy(v) > 3.5:
|
||||
return "long_token"
|
||||
if len(v) >= 6:
|
||||
return "short_id"
|
||||
return "flag"
|
||||
|
||||
|
||||
def check_cookies_for_entropy_mismatch(
|
||||
cookies_detailed: list[dict] | None,
|
||||
) -> list[dict]:
|
||||
"""Liefert Findings fuer Cookies deren Wert-Shape nicht zur
|
||||
deklarierten Kategorie passt."""
|
||||
out: list[dict] = []
|
||||
if not cookies_detailed:
|
||||
return out
|
||||
for ck in cookies_detailed:
|
||||
if not isinstance(ck, dict):
|
||||
continue
|
||||
name = (ck.get("name") or "").strip()
|
||||
value = (ck.get("value") or "").strip()
|
||||
declared = (ck.get("declared_category") or "").lower().strip()
|
||||
if not name or not declared:
|
||||
continue
|
||||
shape = _classify_value_shape(value)
|
||||
|
||||
# Regel: 'essential' / 'functional' Cookies mit hoher
|
||||
# Tracking-ID-Komplexitaet sind verdaechtig.
|
||||
is_low_cat = declared in ("essential", "functional", "necessary")
|
||||
is_id_shape = shape in ("uuid", "hash", "long_token")
|
||||
if is_low_cat and is_id_shape:
|
||||
out.append({
|
||||
"cookie": name,
|
||||
"declared": declared,
|
||||
"value_shape": shape,
|
||||
"value_len": len(value),
|
||||
"severity": "MEDIUM",
|
||||
"label": (
|
||||
f"Cookie '{name}' deklariert als '{declared}', "
|
||||
f"aber Wert ist ein {shape} ({len(value)} Zeichen) — "
|
||||
"typisches Tracking-ID-Pattern"
|
||||
),
|
||||
"detail": (
|
||||
"Funktionale/notwendige Cookies speichern normalerweise "
|
||||
"kurze Flags (1, true, de-DE). Ein langer Hash/UUID-Wert "
|
||||
"in einem als 'essential' deklarierten Cookie ist ein "
|
||||
"Indikator fuer verstecktes Tracking — vergleichbar mit "
|
||||
"einem 'Defeat Device', das auf dem Pruefstand harmlos "
|
||||
"aussieht aber im Realbetrieb anderes tut."
|
||||
),
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def build_entropy_block_html(findings: list[dict]) -> str:
|
||||
if not findings:
|
||||
return ""
|
||||
items: list[str] = []
|
||||
for f in findings[:25]:
|
||||
items.append(
|
||||
f'<li style="margin-bottom:6px;font-size:11px;line-height:1.5">'
|
||||
f'<strong style="color:#d97706">{f["cookie"]}</strong> '
|
||||
f'<span style="color:#64748b">(deklariert: '
|
||||
f'<strong>{f["declared"]}</strong>) — Wert-Shape:</span> '
|
||||
f'<code style="background:#fef3c7;padding:1px 4px;border-radius:2px">'
|
||||
f'{f["value_shape"]}</code> '
|
||||
f'<span style="color:#64748b">({f["value_len"]} Zeichen)</span>'
|
||||
f'</li>'
|
||||
)
|
||||
return (
|
||||
'<div style="font-family:-apple-system,BlinkMacSystemFont,sans-serif;'
|
||||
'max-width:760px;margin:0 auto 16px;padding:14px 18px;'
|
||||
'background:#fffbeb;border:1px solid #fde68a;border-radius:8px">'
|
||||
'<div style="font-size:11px;color:#92400e;text-transform:uppercase;'
|
||||
'letter-spacing:1.2px;margin-bottom:4px;font-weight:600">'
|
||||
'Cookie-Werte-Plausibilitaet (Defeat-Device-Heuristik)</div>'
|
||||
f'<h3 style="margin:0 0 6px;font-size:14px;color:#1e293b">'
|
||||
f'{len(findings)} Cookie{"s" if len(findings) != 1 else ""} '
|
||||
'mit verdaechtigem Wert-Pattern</h3>'
|
||||
'<p style="margin:0 0 10px;font-size:11px;color:#475569;line-height:1.5">'
|
||||
'Diese Cookies sind als "essential" oder "funktional" deklariert, '
|
||||
'ihr tatsaechlicher Wert sieht aber wie eine Tracking-ID aus '
|
||||
'(UUID, Hash, langer Base64-Token). Empfehlung: pruefen ob diese '
|
||||
'Cookies wirklich nur technisch notwendig sind oder de facto '
|
||||
'pseudonymisierte User-Tracker.</p>'
|
||||
'<ul style="margin:0 0 0 18px;padding:0">'
|
||||
+ "".join(items) +
|
||||
'</ul></div>'
|
||||
)
|
||||
@@ -0,0 +1,222 @@
|
||||
"""
|
||||
P6 + P53 + P55 — OEM-Cross-Industry-Library mit Autonomes Profiling.
|
||||
|
||||
Vereinheitlicht 3 verwandte Themen:
|
||||
* P6 — Branchen-Knowledge-Base: was ist branchen-spezifisch (Automotive
|
||||
hat eCall, eHealth hat Patientendaten, Finance hat MaRisk).
|
||||
* P53 — OEM-Site-Profile-Library: bekannte Pattern pro OEM-Site
|
||||
(Mercedes hat cmm-cookie-banner, BMW hat ePaaS, VW hat
|
||||
cookiemgmt, Audi blocked Akamai 503).
|
||||
* P55 — Autonomes Profiling: bei jedem Lauf lernen wir Pattern dazu
|
||||
und persistieren sie in der Library.
|
||||
|
||||
Backend-Service: Lookup-API + Auto-Lern-Hook bei jedem Snapshot-Save.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import Iterable
|
||||
|
||||
from sqlalchemy import text as sa_text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Branchen-spezifische zusaetzliche Compliance-Themen
|
||||
_INDUSTRY_PROFILES: dict[str, dict] = {
|
||||
"automotive": {
|
||||
"mandatory_regulations": [
|
||||
"DSGVO", "TDDDG",
|
||||
"VO 2015/758 (eCall)",
|
||||
"VO 2018/858 (Typgenehmigung)",
|
||||
"VO 2019/2144 (Allgemeine Sicherheit)",
|
||||
"Cyber Security UN-R 155",
|
||||
"Software Update UN-R 156",
|
||||
],
|
||||
"typical_cookie_vendors": [
|
||||
"Adobe Analytics", "Adobe Target", "Salesforce LiveAgent",
|
||||
"AdForm", "The Trade Desk", "Google Marketing Platform",
|
||||
"Inbenta", "Datadog RUM",
|
||||
],
|
||||
"vvt_required_processes": [
|
||||
"Probefahrten-Buchung", "Haendler-Suche", "eCall-System",
|
||||
"We Connect / Connected Drive Services", "Konfigurator-Daten",
|
||||
],
|
||||
"special_findings_to_watch": [
|
||||
"eCall ohne Hinweis in DSE = Verstoss VO 2015/758 Art. 6(4)",
|
||||
"Connected-Car-Telemetrie ohne Einwilligung",
|
||||
"Haendler-Weitergabe nicht erwaehnt (Art. 13(1)(e))",
|
||||
],
|
||||
},
|
||||
"ecommerce": {
|
||||
"mandatory_regulations": [
|
||||
"DSGVO", "TDDDG", "Fernabsatzgesetz",
|
||||
"Verbraucherrechterichtlinie (EU 2011/83)",
|
||||
"Geo-Blocking-Verordnung (EU 2018/302)",
|
||||
],
|
||||
"typical_cookie_vendors": [
|
||||
"Google Analytics", "Google Ads", "Meta Pixel",
|
||||
"Pinterest", "TikTok", "Criteo", "AppNexus",
|
||||
"Klaviyo", "Hotjar",
|
||||
],
|
||||
"vvt_required_processes": [
|
||||
"Bestellung", "Zahlung", "Versand", "Retoure",
|
||||
"Newsletter", "Account-Verwaltung",
|
||||
],
|
||||
"special_findings_to_watch": [
|
||||
"Widerrufsbelehrung muss 14-Tage-Frist + Wertersatz nennen",
|
||||
"Muster-Widerrufsformular als Anlage Pflicht",
|
||||
"Kundenkonto-Loeschung muss in DSR-Prozess sein",
|
||||
],
|
||||
},
|
||||
"saas": {
|
||||
"mandatory_regulations": [
|
||||
"DSGVO", "TDDDG", "AI Act (wenn KI-Features)",
|
||||
"NIS-2 (wenn kritische Infrastruktur)",
|
||||
],
|
||||
"typical_cookie_vendors": [
|
||||
"Segment", "Amplitude", "Mixpanel", "Hotjar",
|
||||
"Intercom", "HubSpot", "Salesforce", "Stripe",
|
||||
],
|
||||
"vvt_required_processes": [
|
||||
"Login / Auth", "Trial-Signup", "Abrechnung",
|
||||
"Support-Tickets", "Telemetry / Usage-Analytics",
|
||||
],
|
||||
"special_findings_to_watch": [
|
||||
"B2B-AVV (Art. 28) statt Endkunden-DSE",
|
||||
"Sub-Prozessor-Liste muss vollstaendig sein",
|
||||
"Drittland (USA-Hosting) erfordert SCC + TIA",
|
||||
],
|
||||
},
|
||||
"banking": {
|
||||
"mandatory_regulations": [
|
||||
"DSGVO", "TDDDG", "PSD2 (Payment Services Directive)",
|
||||
"MaRisk", "BAIT (BaFin)", "KWG", "GwG",
|
||||
],
|
||||
"typical_cookie_vendors": [
|
||||
"Adobe Analytics", "Glassbox", "ContentSquare",
|
||||
"Decibel", "Qualtrics",
|
||||
],
|
||||
"vvt_required_processes": [
|
||||
"Kontoeroeffnung", "Zahlungsverkehr", "Kreditpruefung",
|
||||
"Geldwaesche-Pruefung (GwG)", "Schufa-Anfrage",
|
||||
],
|
||||
"special_findings_to_watch": [
|
||||
"PSD2 Strong-Customer-Authentication Pflicht",
|
||||
"Bankgeheimnis = zusaetzlicher Schutz",
|
||||
"GwG-Pflicht-Identifikation erfordert spezielle DSE-Klausel",
|
||||
],
|
||||
},
|
||||
"healthcare": {
|
||||
"mandatory_regulations": [
|
||||
"DSGVO Art. 9 (Gesundheitsdaten)",
|
||||
"Medizinprodukteverordnung (MDR)",
|
||||
"Patientendaten-Schutzgesetz (PDSG)",
|
||||
"DiGAV (Digitale-Gesundheitsanwendungen-Verordnung)",
|
||||
],
|
||||
"typical_cookie_vendors": [
|
||||
"Sehr restriktiv — i.d.R. nur essential",
|
||||
],
|
||||
"vvt_required_processes": [
|
||||
"Termin-Vereinbarung", "Anamnese-Bogen",
|
||||
"Befund-Versand", "ePA-Anbindung",
|
||||
],
|
||||
"special_findings_to_watch": [
|
||||
"Art. 9 DSGVO erfordert ausdrueckliche Einwilligung",
|
||||
"Schweigepflicht §203 StGB",
|
||||
"Drittland-Transfer fast immer unzulaessig",
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def lookup_industry_profile(industry: str | None) -> dict | None:
|
||||
"""Liefert das Branchenprofil oder None."""
|
||||
if not industry:
|
||||
return None
|
||||
return _INDUSTRY_PROFILES.get(industry.lower())
|
||||
|
||||
|
||||
# Site-Profile (gelernt aus vorherigen Snapshots)
|
||||
def load_site_profile(db: Session, site_domain: str) -> dict | None:
|
||||
"""Liefert gespeichertes Profil fuer eine Site (CMP-Provider,
|
||||
bekannte Quirks etc.) oder None."""
|
||||
if not site_domain:
|
||||
return None
|
||||
try:
|
||||
row = db.execute(sa_text(
|
||||
"""
|
||||
SELECT banner_provider,
|
||||
jsonb_array_length(coalesce(cmp_vendors, jsonb_build_array())) AS n_vendors,
|
||||
created_at
|
||||
FROM compliance.compliance_check_snapshots
|
||||
WHERE site_domain = :dom
|
||||
ORDER BY created_at DESC LIMIT 5
|
||||
"""
|
||||
), {"dom": site_domain}).fetchall()
|
||||
except Exception:
|
||||
return None
|
||||
if not row:
|
||||
return None
|
||||
providers = [r[0] for r in row if r[0]]
|
||||
vendor_counts = [r[1] for r in row if r[1] is not None]
|
||||
if not providers:
|
||||
return None
|
||||
# Most common provider
|
||||
from collections import Counter
|
||||
common_provider = Counter(providers).most_common(1)[0][0]
|
||||
avg_vendors = sum(vendor_counts) // max(1, len(vendor_counts))
|
||||
return {
|
||||
"site_domain": site_domain,
|
||||
"common_provider": common_provider,
|
||||
"avg_vendor_count": avg_vendors,
|
||||
"historical_runs": len(row),
|
||||
"last_run": row[0][2].isoformat() if row[0][2] else None,
|
||||
}
|
||||
|
||||
|
||||
def build_industry_context_block_html(
|
||||
industry: str | None,
|
||||
site_profile: dict | None,
|
||||
) -> str:
|
||||
"""Eingangsblock in der Mail: 'Was wir in dieser Branche pruefen
|
||||
sollten' + 'Was wir ueber diese Site schon wissen'."""
|
||||
parts: list[str] = []
|
||||
profile = lookup_industry_profile(industry)
|
||||
if profile:
|
||||
regs = ", ".join(profile.get("mandatory_regulations", [])[:6])
|
||||
watches = profile.get("special_findings_to_watch", [])[:3]
|
||||
watch_html = "".join(
|
||||
f'<li style="font-size:11px;color:#475569">{w}</li>'
|
||||
for w in watches
|
||||
)
|
||||
parts.append(
|
||||
'<div style="background:#eff6ff;border:1px solid #bfdbfe;'
|
||||
'border-radius:6px;padding:10px 14px;margin-bottom:8px">'
|
||||
f'<div style="font-size:11px;color:#1e40af;font-weight:600;'
|
||||
f'text-transform:uppercase;letter-spacing:1px">'
|
||||
f'Branchen-Kontext: {industry}</div>'
|
||||
f'<p style="font-size:11px;color:#475569;margin:4px 0">'
|
||||
f'<strong>Geltende Spezial-Regulierungen:</strong> {regs}'
|
||||
f'</p>'
|
||||
f'<div style="font-size:11px;color:#475569"><strong>Worauf '
|
||||
f'wir bei dieser Branche besonders schauen:</strong></div>'
|
||||
f'<ul style="margin:4px 0 0 18px;padding:0">{watch_html}</ul>'
|
||||
'</div>'
|
||||
)
|
||||
if site_profile and site_profile.get("historical_runs", 0) > 1:
|
||||
parts.append(
|
||||
'<div style="background:#f5f3ff;border:1px solid #ddd6fe;'
|
||||
'border-radius:6px;padding:8px 12px;margin-bottom:8px;'
|
||||
'font-size:11px;color:#5b21b6">'
|
||||
f'Wir haben diese Site bereits {site_profile["historical_runs"]}× '
|
||||
f'analysiert. Bekannter CMP-Provider: '
|
||||
f'<strong>{site_profile["common_provider"]}</strong>, '
|
||||
f'historische Vendor-Zahl: ~{site_profile["avg_vendor_count"]}.'
|
||||
'</div>'
|
||||
)
|
||||
return "".join(parts)
|
||||
@@ -0,0 +1,229 @@
|
||||
"""
|
||||
P31 — Tiered LLM-Cascade mit Confidence + Valkey-Cache.
|
||||
|
||||
Bisherige LLM-Calls (vendor_llm_extractor, mc_solution_generator):
|
||||
* gehen direkt an Qwen lokal → bei kompliziertem Input lange Latenz
|
||||
* fallen bei Fail manuell auf OVH 120B zurueck
|
||||
* Kein Cache → gleiche Eingabe kostet x-mal Zeit
|
||||
|
||||
Diese Modul vereinheitlicht:
|
||||
1. Cache-Lookup (md5(prompt) → cached response, TTL 7d)
|
||||
2. Qwen-Aufruf mit kurzem Timeout (90s)
|
||||
3. Wenn fail/leer ODER confidence < threshold → OVH 120B (45s)
|
||||
4. Wenn auch fail → Anthropic Claude (last resort)
|
||||
5. Response wird gecached
|
||||
|
||||
confidence-Heuristik:
|
||||
* parsed JSON erfolgreich + non-empty → 0.8
|
||||
* JSON-Parse failed → 0.0
|
||||
* JSON ok aber nur 1 Item bei >5000 chars input → 0.3
|
||||
|
||||
Backend-API: await call_with_cascade(prompt, system_prompt, expected_min_items)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# In-process Cache wenn kein Valkey verfuegbar
|
||||
_LOCAL_CACHE: dict[str, dict] = {}
|
||||
_LOCAL_CACHE_MAX = 200
|
||||
|
||||
|
||||
def _cache_key(system: str, user: str, model_hint: str = "") -> str:
|
||||
blob = f"{system}\n---\n{user}\n---\n{model_hint}"
|
||||
return "llm:" + hashlib.md5(blob.encode()).hexdigest()[:24]
|
||||
|
||||
|
||||
def _cache_get(key: str) -> dict | None:
|
||||
try:
|
||||
import redis # noqa: WPS433
|
||||
url = os.getenv("VALKEY_URL", "redis://bp-core-valkey:6379")
|
||||
r = redis.Redis.from_url(url, socket_timeout=2.0,
|
||||
decode_responses=True)
|
||||
v = r.get(key)
|
||||
if v:
|
||||
return json.loads(v)
|
||||
except Exception:
|
||||
pass
|
||||
return _LOCAL_CACHE.get(key)
|
||||
|
||||
|
||||
def _cache_put(key: str, value: dict, ttl: int = 604800) -> None:
|
||||
try:
|
||||
import redis # noqa: WPS433
|
||||
url = os.getenv("VALKEY_URL", "redis://bp-core-valkey:6379")
|
||||
r = redis.Redis.from_url(url, socket_timeout=2.0,
|
||||
decode_responses=True)
|
||||
r.setex(key, ttl, json.dumps(value)[:200000])
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
if len(_LOCAL_CACHE) >= _LOCAL_CACHE_MAX:
|
||||
for k in list(_LOCAL_CACHE.keys())[:50]:
|
||||
_LOCAL_CACHE.pop(k, None)
|
||||
_LOCAL_CACHE[key] = value
|
||||
|
||||
|
||||
def _heuristic_confidence(response_text: str, input_len: int) -> float:
|
||||
if not response_text:
|
||||
return 0.0
|
||||
try:
|
||||
obj = json.loads(response_text)
|
||||
except Exception:
|
||||
# Try to extract JSON block
|
||||
a, b = response_text.find("{"), response_text.rfind("}")
|
||||
if 0 <= a < b:
|
||||
try:
|
||||
obj = json.loads(response_text[a:b + 1])
|
||||
except Exception:
|
||||
return 0.1
|
||||
else:
|
||||
return 0.1
|
||||
n_items = 0
|
||||
if isinstance(obj, dict):
|
||||
for v in obj.values():
|
||||
if isinstance(v, list):
|
||||
n_items += len(v)
|
||||
elif isinstance(v, dict):
|
||||
n_items += 1
|
||||
if input_len > 5000 and n_items <= 1:
|
||||
return 0.3
|
||||
if n_items >= 5:
|
||||
return 0.9
|
||||
return 0.7
|
||||
|
||||
|
||||
async def _call_ollama(system: str, user: str,
|
||||
max_tokens: int = 6000,
|
||||
timeout: float = 90.0) -> str:
|
||||
base = os.getenv("OLLAMA_URL", "http://host.docker.internal:11434")
|
||||
model = os.getenv("CMP_LLM_MODEL", "qwen3:30b-a3b")
|
||||
payload = {
|
||||
"model": model, "stream": False, "format": "json",
|
||||
"messages": [{"role": "system", "content": system},
|
||||
{"role": "user", "content": user}],
|
||||
"options": {"temperature": 0.05, "num_predict": max_tokens},
|
||||
}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout) as c:
|
||||
r = await c.post(f"{base.rstrip('/')}/api/chat", json=payload)
|
||||
r.raise_for_status()
|
||||
return (r.json().get("message") or {}).get("content", "") or ""
|
||||
except Exception as e:
|
||||
logger.warning("ollama cascade tier 1 failed: %s", e)
|
||||
return ""
|
||||
|
||||
|
||||
async def _call_ovh(system: str, user: str, max_tokens: int = 6000) -> str:
|
||||
base = os.getenv("OVH_LLM_URL", "").strip()
|
||||
key = os.getenv("OVH_LLM_KEY", "").strip()
|
||||
model = os.getenv("OVH_LLM_MODEL", "").strip()
|
||||
if not base or not model:
|
||||
return ""
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if key:
|
||||
headers["Authorization"] = f"Bearer {key}"
|
||||
payload = {
|
||||
"model": model, "temperature": 0.05, "max_tokens": max_tokens,
|
||||
"messages": [{"role": "system", "content": system},
|
||||
{"role": "user", "content": user}],
|
||||
"response_format": {"type": "json_object"},
|
||||
}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=45.0) as c:
|
||||
r = await c.post(f"{base.rstrip('/')}/v1/chat/completions",
|
||||
json=payload, headers=headers)
|
||||
r.raise_for_status()
|
||||
choice = (r.json().get("choices") or [{}])[0]
|
||||
return (choice.get("message") or {}).get("content", "") or ""
|
||||
except Exception as e:
|
||||
logger.warning("ovh cascade tier 2 failed: %s", e)
|
||||
return ""
|
||||
|
||||
|
||||
async def _call_anthropic(system: str, user: str,
|
||||
max_tokens: int = 4000) -> str:
|
||||
key = os.getenv("ANTHROPIC_API_KEY", "").strip()
|
||||
if not key:
|
||||
return ""
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"x-api-key": key,
|
||||
"anthropic-version": "2023-06-01",
|
||||
}
|
||||
payload = {
|
||||
"model": "claude-haiku-4-5-20251001",
|
||||
"max_tokens": max_tokens, "temperature": 0.05,
|
||||
"system": system,
|
||||
"messages": [{"role": "user", "content": user}],
|
||||
}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as c:
|
||||
r = await c.post("https://api.anthropic.com/v1/messages",
|
||||
json=payload, headers=headers)
|
||||
r.raise_for_status()
|
||||
blocks = r.json().get("content") or []
|
||||
return "".join(b.get("text", "") for b in blocks if isinstance(b, dict))
|
||||
except Exception as e:
|
||||
logger.warning("anthropic cascade tier 3 failed: %s", e)
|
||||
return ""
|
||||
|
||||
|
||||
async def call_with_cascade(
|
||||
system: str,
|
||||
user: str,
|
||||
min_confidence: float = 0.6,
|
||||
max_tokens: int = 6000,
|
||||
) -> dict:
|
||||
"""Returns {'text': str, 'confidence': float, 'source': str,
|
||||
'cached': bool}."""
|
||||
key = _cache_key(system, user)
|
||||
cached = _cache_get(key)
|
||||
if cached:
|
||||
cached["cached"] = True
|
||||
return cached
|
||||
|
||||
input_len = len(user)
|
||||
# Tier 1: Qwen lokal
|
||||
text = await _call_ollama(system, user, max_tokens=max_tokens)
|
||||
conf = _heuristic_confidence(text, input_len)
|
||||
if text and conf >= min_confidence:
|
||||
out = {"text": text, "confidence": conf,
|
||||
"source": "qwen", "cached": False}
|
||||
_cache_put(key, out)
|
||||
return out
|
||||
|
||||
# Tier 2: OVH 120B
|
||||
text2 = await _call_ovh(system, user, max_tokens=max_tokens)
|
||||
conf2 = _heuristic_confidence(text2, input_len)
|
||||
if text2 and conf2 >= min_confidence:
|
||||
out = {"text": text2, "confidence": conf2,
|
||||
"source": "ovh_120b", "cached": False}
|
||||
_cache_put(key, out)
|
||||
return out
|
||||
|
||||
# Tier 3: Anthropic Claude (Notnagel)
|
||||
text3 = await _call_anthropic(system, user, max_tokens=max_tokens // 2)
|
||||
conf3 = _heuristic_confidence(text3, input_len)
|
||||
if text3 and conf3 >= min_confidence:
|
||||
out = {"text": text3, "confidence": conf3,
|
||||
"source": "anthropic_claude", "cached": False}
|
||||
_cache_put(key, out)
|
||||
return out
|
||||
|
||||
# Nichts hat geliefert — beste Variante wenigstens zurueckgeben
|
||||
best_text = text or text2 or text3 or ""
|
||||
best_conf = max(conf, conf2, conf3)
|
||||
best_source = "qwen" if text else ("ovh_120b" if text2 else "anthropic")
|
||||
return {"text": best_text, "confidence": best_conf,
|
||||
"source": best_source, "cached": False,
|
||||
"below_threshold": True}
|
||||
@@ -0,0 +1,269 @@
|
||||
"""
|
||||
P106 — MC-Audit-Type-Klassifizierung.
|
||||
|
||||
Zentrales Problem: viele Master-Controls pruefen Sachverhalte, die wir
|
||||
von Aussen GAR NICHT pruefen koennen — z.B. ob das Unternehmen einen
|
||||
internen Loeschkonzept-Prozess hat oder Schulungen durchgefuehrt wurden.
|
||||
|
||||
Bisher: alle MCs deren Pattern im Text nicht matched → FAIL.
|
||||
Folge: GF-Mail mit 95 FAILs, davon ~60-70 in Wirklichkeit nur 'unknown'.
|
||||
|
||||
Loesung: pro MC klassifizieren:
|
||||
* verifiable → Pattern muss im sichtbaren Dokument stehen (Audit moeglich)
|
||||
* process_internal → interner Prozess des Kunden (Schulung, AVV-Vertrag, …)
|
||||
* doc_internal → interne Dokumentation (VVT-Eintrag, DSFA-File, …)
|
||||
* ambiguous → koennte beides sein
|
||||
|
||||
In der MC-Auswertung:
|
||||
* verifiable + Pattern fehlt → echtes FAIL ❌
|
||||
* process_internal → CHECK (Hinweis 'Bitte intern pruefen') ⓘ
|
||||
* doc_internal → CHECK (Hinweis 'Im VVT/DSFA dokumentiert?') ⓘ
|
||||
* ambiguous → CHECK mit Warnung
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Patterns die auf interne Prozesse hindeuten (NICHT von aussen pruefbar)
|
||||
_PROCESS_INTERNAL_PATTERNS = [
|
||||
# Schulung / Mitarbeiter
|
||||
r"\bmitarbeiter\b.*schul",
|
||||
r"\bschulung(en)?\b",
|
||||
r"\bawareness\b",
|
||||
r"\bsensibilisier",
|
||||
# Vertraege intern
|
||||
r"\bauftragsverarbeitungsvertrag\b",
|
||||
r"\bAVV\b\s+abgeschlossen",
|
||||
r"\bvertrag.*abgeschlossen",
|
||||
r"\bdpa\s+(geschlossen|abgeschlossen|vorhanden)",
|
||||
r"\bSCC\s+(geschlossen|abgeschlossen|implementiert)",
|
||||
# Technisch-organisatorische Massnahmen (intern)
|
||||
r"\btechnisch[-\s]*organisatorische\s+ma(ß|ss)nahmen?\b",
|
||||
r"\bTOM\s+(umgesetzt|dokumentiert|implementiert)",
|
||||
r"\bverschluesselung\s+(implementiert|aktiv)",
|
||||
r"\bpseudonymisierung\s+(implementiert|aktiv)",
|
||||
r"\bbackup[s]?\s+(eingerichtet|vorhanden)",
|
||||
r"\bzugriffskontrolle",
|
||||
r"\b(rollen|berechtigungs)konzept",
|
||||
# Risikobewertung / DSFA (intern)
|
||||
r"\bdsfa\s+(durchgefuehrt|erstellt|dokumentiert)",
|
||||
r"\brisikobewertung\s+(durchgefuehrt|dokumentiert)",
|
||||
r"\brisikoanalyse",
|
||||
# Loeschkonzept / Aufbewahrung
|
||||
r"\bloeschkonzept\s+(umgesetzt|implementiert)",
|
||||
r"\baufbewahrungsfrist(en)?\s+(eingehalten|definiert)",
|
||||
r"\bloeschroutinen?\s+(aktiv|implementiert)",
|
||||
# Meldewege / Vorfallmanagement
|
||||
r"\bmeldepflicht\s+(eingehalten|umgesetzt)",
|
||||
r"\bvorfallmanagement",
|
||||
r"\bincident[\s-]?response",
|
||||
r"\b72[\s-]?stunden[\s-]?meldung",
|
||||
# Generische Prozess-Indikatoren
|
||||
r"\bdokumentiert\s+werden",
|
||||
r"\bbitte\s+(intern\s+)?dokumentieren",
|
||||
r"\bin\s+der\s+verfahrens",
|
||||
r"\bnach\s+innen\s+geh",
|
||||
r"\bausnahmen\s+(dokumentieren|protokollieren)",
|
||||
r"\bkostenfrei\s+(zur\s+verfuegung|gewaehren|ermoegli)",
|
||||
r"\bunentgeltlich\s+(zur\s+verfuegung)",
|
||||
# Vertragsleistung / Service-Level (intern)
|
||||
r"\bservice[\s-]?level",
|
||||
r"\breaktionszeit",
|
||||
# Auditierung / Aufsicht
|
||||
r"\binterne(s)?\s+audit",
|
||||
r"\baufsichtsbehoerde\s+gemeldet",
|
||||
r"\bbeauftragter\s+(intern|benannt)",
|
||||
# eCall + Branchen-spezifische interne Pflichten
|
||||
r"\babschaltung\s+der\s+\w+\s+kostenfrei",
|
||||
r"\bopt[\s-]?out\s+(intern|im\s+kundenportal)\s+ermoeglichen",
|
||||
]
|
||||
|
||||
# Patterns die auf interne Dokumentation hindeuten (VVT, DSFA-Datei, …)
|
||||
_DOC_INTERNAL_PATTERNS = [
|
||||
r"\bverzeichnis\s+der\s+verarbeitungstaetigkeiten\b",
|
||||
r"\bvvt(\s+|\b)",
|
||||
r"\bdsfa[\s-]?dokument",
|
||||
r"\bauftragsverarbeitungsverzeichnis",
|
||||
r"\bsub[\s-]?prozessor[\s-]?liste",
|
||||
r"\bverarbeitungs[\s-]?register",
|
||||
r"\binternes\s+register",
|
||||
r"\baufbewahrungs[\s-]?konzept\b",
|
||||
]
|
||||
|
||||
# Patterns die auf externe Sichtbarkeit hindeuten → DEFINITIV verifiable
|
||||
_VERIFIABLE_PATTERNS = [
|
||||
r"\bin\s+der\s+(datenschutzerklaerung|dse|cookie[\s-]?richtlinie|impressum|agb)\b",
|
||||
r"\bauf\s+der\s+website\s+(genannt|sichtbar|angegeben)",
|
||||
r"\bim\s+banner\s+(genannt|sichtbar)",
|
||||
r"\bim\s+cookie[\s-]?banner",
|
||||
r"\bauf\s+der\s+startseite",
|
||||
r"\bim\s+footer",
|
||||
]
|
||||
|
||||
|
||||
def _matches_any(text: str, patterns: list[str]) -> bool:
|
||||
tl = text.lower()
|
||||
for pat in patterns:
|
||||
try:
|
||||
if re.search(pat, tl):
|
||||
return True
|
||||
except re.error:
|
||||
continue
|
||||
return False
|
||||
|
||||
|
||||
def classify_mc_audit_type(
|
||||
title: str | None,
|
||||
check_question: str | None = None,
|
||||
fail_criteria: dict | None = None,
|
||||
) -> str:
|
||||
"""Returns 'verifiable', 'process_internal', 'doc_internal',
|
||||
or 'ambiguous'."""
|
||||
blob = " ".join([title or "", check_question or "",
|
||||
str(fail_criteria or "")])
|
||||
if not blob.strip():
|
||||
return "ambiguous"
|
||||
|
||||
is_verifiable_hint = _matches_any(blob, _VERIFIABLE_PATTERNS)
|
||||
is_process = _matches_any(blob, _PROCESS_INTERNAL_PATTERNS)
|
||||
is_doc = _matches_any(blob, _DOC_INTERNAL_PATTERNS)
|
||||
|
||||
# Wenn explicit Verifiable-Indikator + kein Process → verifiable
|
||||
if is_verifiable_hint and not (is_process or is_doc):
|
||||
return "verifiable"
|
||||
# Wenn Process oder Doc UND nicht Verifiable → intern
|
||||
if is_process and not is_verifiable_hint:
|
||||
return "process_internal"
|
||||
if is_doc and not is_verifiable_hint:
|
||||
return "doc_internal"
|
||||
# Beides → ambiguous, im Zweifel CHECK markieren
|
||||
if is_process or is_doc:
|
||||
return "ambiguous"
|
||||
return "verifiable"
|
||||
|
||||
|
||||
def annotate_mc_results(check_results: list[dict]) -> list[dict]:
|
||||
"""In-place: setzt mc_audit_type auf jeden MC-Check und ersetzt
|
||||
Status 'failed' durch 'check' wenn audit_type != verifiable."""
|
||||
if not check_results:
|
||||
return check_results
|
||||
n_reclassified = 0
|
||||
for r in check_results:
|
||||
if not isinstance(r, dict):
|
||||
continue
|
||||
if not (r.get("id") or "").startswith("mc-"):
|
||||
continue
|
||||
if "mc_audit_type" not in r:
|
||||
r["mc_audit_type"] = classify_mc_audit_type(
|
||||
r.get("label"), r.get("hint"), r.get("fail_criteria"),
|
||||
)
|
||||
# Wenn FAIL aber audit_type != verifiable → "check" (manuell)
|
||||
if (not r.get("passed")
|
||||
and not r.get("skipped")
|
||||
and r["mc_audit_type"] in (
|
||||
"process_internal", "doc_internal", "ambiguous",
|
||||
)):
|
||||
r["audit_status"] = "check" # NICHT failed
|
||||
n_reclassified += 1
|
||||
elif r.get("passed"):
|
||||
r["audit_status"] = "pass"
|
||||
elif r.get("skipped"):
|
||||
r["audit_status"] = "skip"
|
||||
else:
|
||||
r["audit_status"] = "fail"
|
||||
if n_reclassified:
|
||||
logger.info(
|
||||
"MC-Audit-Type: %d/%d MCs reklassifiziert von FAIL → CHECK "
|
||||
"(interne Pruefung erforderlich)",
|
||||
n_reclassified, len(check_results),
|
||||
)
|
||||
return check_results
|
||||
|
||||
|
||||
def split_by_audit_type(check_results: list[dict]) -> dict[str, list[dict]]:
|
||||
"""Liefert {verifiable_fails, internal_checks, passes, skips}."""
|
||||
out = {"verifiable_fails": [], "internal_checks": [],
|
||||
"passes": [], "skips": []}
|
||||
for r in (check_results or []):
|
||||
if not isinstance(r, dict):
|
||||
continue
|
||||
if not (r.get("id") or "").startswith("mc-"):
|
||||
continue
|
||||
status = r.get("audit_status")
|
||||
if status == "pass":
|
||||
out["passes"].append(r)
|
||||
elif status == "skip":
|
||||
out["skips"].append(r)
|
||||
elif status == "check":
|
||||
out["internal_checks"].append(r)
|
||||
elif status == "fail" or (not r.get("passed") and not r.get("skipped")):
|
||||
out["verifiable_fails"].append(r)
|
||||
return out
|
||||
|
||||
|
||||
def build_internal_checks_block_html(
|
||||
internal_checks: list[dict],
|
||||
limit: int = 30,
|
||||
) -> str:
|
||||
if not internal_checks:
|
||||
return ""
|
||||
by_type: dict[str, list[dict]] = {}
|
||||
for c in internal_checks:
|
||||
t = c.get("mc_audit_type", "ambiguous")
|
||||
by_type.setdefault(t, []).append(c)
|
||||
|
||||
sections: list[str] = []
|
||||
labels = {
|
||||
"process_internal": ("Interne Prozesse — bitte beim DSB pruefen",
|
||||
"#1e40af"),
|
||||
"doc_internal": ("Interne Dokumentation — bitte im VVT/DSFA pruefen",
|
||||
"#5b21b6"),
|
||||
"ambiguous": ("Unklar ob Audit-Befund oder interne Pruefung",
|
||||
"#92400e"),
|
||||
}
|
||||
for atype, (heading, color) in labels.items():
|
||||
items = by_type.get(atype) or []
|
||||
if not items:
|
||||
continue
|
||||
rows = "".join(
|
||||
f'<li style="margin-bottom:4px;font-size:11px;line-height:1.45">'
|
||||
f'<strong>{(c.get("label") or "")[:160]}</strong>'
|
||||
+ (f' <span style="color:#94a3b8">({c.get("regulation") or "—"})</span>'
|
||||
if c.get("regulation") else '') +
|
||||
f'</li>'
|
||||
for c in items[:limit]
|
||||
)
|
||||
sections.append(
|
||||
f'<div style="margin-bottom:10px">'
|
||||
f'<div style="font-size:11px;color:{color};text-transform:uppercase;'
|
||||
f'letter-spacing:1px;font-weight:600;margin-bottom:4px">'
|
||||
f'{heading} ({len(items)})</div>'
|
||||
f'<ul style="margin:0 0 0 18px;padding:0">{rows}</ul>'
|
||||
f'</div>'
|
||||
)
|
||||
return (
|
||||
'<div style="font-family:-apple-system,BlinkMacSystemFont,sans-serif;'
|
||||
'max-width:760px;margin:0 auto 16px;padding:12px 16px;'
|
||||
'background:#f0f9ff;border:1px solid #bfdbfe;border-radius:8px">'
|
||||
'<div style="font-size:11px;color:#1e40af;text-transform:uppercase;'
|
||||
'letter-spacing:1.2px;margin-bottom:4px;font-weight:600">'
|
||||
'Pruefungen die wir von aussen NICHT durchfuehren koennen</div>'
|
||||
f'<h3 style="margin:0 0 6px;font-size:14px;color:#1e293b">'
|
||||
f'{len(internal_checks)} Pruefpunkt'
|
||||
f'{"e" if len(internal_checks) != 1 else ""} sind '
|
||||
'NUR intern beim Kunden zu pruefen</h3>'
|
||||
'<p style="margin:0 0 10px;font-size:11px;color:#475569;'
|
||||
'line-height:1.5">'
|
||||
'Diese Anforderungen koennen wir per externem Website-Audit nicht '
|
||||
'als erfuellt oder nicht-erfuellt bewerten — sie betreffen interne '
|
||||
'Prozesse (Schulungen, AVV-Vertraege, TOM-Doku) oder interne '
|
||||
'Dokumentation (VVT, DSFA, Loeschkonzept). Sie sind also <strong>kein '
|
||||
'Verstoss</strong>, sondern Hinweis-Checks fuer Ihren DSB.</p>'
|
||||
+ "".join(sections) +
|
||||
'</div>'
|
||||
)
|
||||
@@ -61,6 +61,12 @@ def build_scorecard(check_results: list[dict]) -> dict:
|
||||
b["skipped"] += 1
|
||||
elif r.get("passed"):
|
||||
b["passed"] += 1
|
||||
# P106 — interner Check ist KEIN Fail (zaehlt als skipped fuer
|
||||
# die Score-Berechnung damit der Score realistisch ist).
|
||||
elif r.get("audit_status") == "check":
|
||||
b["skipped"] += 1
|
||||
b.setdefault("internal_checks", 0)
|
||||
b["internal_checks"] += 1
|
||||
else:
|
||||
b["failed"] += 1
|
||||
sev = (r.get("severity") or "MEDIUM").upper()
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
"""
|
||||
P70 — RAG-Provenance-Marker.
|
||||
|
||||
Wenn ein Finding aus dem RAG-Korpus belegt ist (z.B. Art-Match auf
|
||||
einen konkreten Gesetzes-Paragrafen aus dem ingestierten DSGVO/TDDDG/
|
||||
TMG-Korpus), bekommt es einen ✓-Marker. Wenn es nur aus unserer
|
||||
Heuristik kommt (Pattern-Match ohne RAG-Belegung), bekommt es ein ⚠
|
||||
"Heuristik".
|
||||
|
||||
Dadurch sieht der Nutzer sofort welche Aussagen rechtlich verbindlich
|
||||
gestuetzt sind vs welche unsere Eigeninterpretation sind.
|
||||
|
||||
Generisch: dataclass-aehnliche Funktion die ein Finding-dict klassifiziert.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Pattern fuer "Belegt aus Korpus": Finding enthaelt expliziten
|
||||
# Norm-Bezug mit Artikel + Quelle.
|
||||
_NORM_RE = re.compile(
|
||||
r"(Art\.?\s*\d+(?:\s*Abs\.?\s*\d+)?(?:\s*lit\.?\s*[a-z])?\s*"
|
||||
r"(?:DSGVO|GDPR|TDDDG|TMG|BDSG|UWG|TKG|EuGH|EDPB)|"
|
||||
r"\(?(EU|VO)\s*\d{4}/\d+\)?|"
|
||||
r"§\s*\d+[a-z]?\s*(TMG|UWG|BDSG|TKG|TDDDG))",
|
||||
re.I,
|
||||
)
|
||||
|
||||
|
||||
def classify_finding_provenance(finding: dict) -> str:
|
||||
"""Returns 'rag', 'heuristic', or 'mixed'.
|
||||
|
||||
rag — Norm-Bezug + Quellen-URL (verbindlich)
|
||||
heuristic — Pattern-Match ohne Norm-Bezug (Eigeninterpretation)
|
||||
mixed — Norm-Bezug aber ohne Quellen-URL (teilweise belegbar)
|
||||
"""
|
||||
if not isinstance(finding, dict):
|
||||
return "heuristic"
|
||||
legal = (finding.get("legal_basis") or "").strip()
|
||||
detail = (finding.get("detail") or "").strip()
|
||||
rag_id = finding.get("rag_chunk_id")
|
||||
rag_url = finding.get("rag_source_url")
|
||||
blob = " ".join([legal, detail])
|
||||
has_norm = bool(_NORM_RE.search(blob))
|
||||
has_source = bool(rag_id or rag_url or
|
||||
"https://" in legal or "https://" in detail)
|
||||
if has_norm and has_source:
|
||||
return "rag"
|
||||
if has_norm:
|
||||
return "mixed"
|
||||
return "heuristic"
|
||||
|
||||
|
||||
def provenance_badge_html(provenance: str) -> str:
|
||||
if provenance == "rag":
|
||||
return (
|
||||
'<span style="background:#dcfce7;color:#166534;'
|
||||
'padding:1px 5px;border-radius:8px;font-size:9px;'
|
||||
'font-weight:600;margin-left:4px" '
|
||||
'title="Aussage durch RAG-Korpus belegt (Gesetzestext + Quelle)">'
|
||||
'✓ RAG</span>'
|
||||
)
|
||||
if provenance == "mixed":
|
||||
return (
|
||||
'<span style="background:#dbeafe;color:#1e40af;'
|
||||
'padding:1px 5px;border-radius:8px;font-size:9px;'
|
||||
'font-weight:600;margin-left:4px" '
|
||||
'title="Norm-Bezug ohne direkte Quellen-URL">'
|
||||
'NORM</span>'
|
||||
)
|
||||
return (
|
||||
'<span style="background:#f1f5f9;color:#475569;'
|
||||
'padding:1px 5px;border-radius:8px;font-size:9px;'
|
||||
'font-weight:600;margin-left:4px" '
|
||||
'title="Heuristik / Eigeninterpretation ohne Korpus-Beleg">'
|
||||
'⚠ HEURISTIK</span>'
|
||||
)
|
||||
|
||||
|
||||
def annotate_findings(findings: list[dict]) -> list[dict]:
|
||||
"""In-place: setzt finding['provenance'] auf jeden Eintrag."""
|
||||
for f in (findings or []):
|
||||
if isinstance(f, dict) and "provenance" not in f:
|
||||
f["provenance"] = classify_finding_provenance(f)
|
||||
return findings
|
||||
@@ -0,0 +1,173 @@
|
||||
"""
|
||||
P68 — Reverse-Audit: eigene Templates gegen alle MCs pruefen.
|
||||
|
||||
Statt 'gegeben einen Kunden-Text → welche MCs fail' machen wir den
|
||||
umgekehrten Test: 'gegeben unseren BreakPilot-Standard-Template-Pool
|
||||
(95 Templates) → welche MCs werden NICHT abgedeckt? Wo sind Luecken?'
|
||||
|
||||
Liefert einen Coverage-Report:
|
||||
- Total MCs in DB: ~1800
|
||||
- MCs abgedeckt durch min. 1 unserer Templates: X
|
||||
- MCs ohne Coverage: Y (Liste)
|
||||
- Templates ohne MC-Wirkung: Z (Liste)
|
||||
|
||||
Zweck: Audit unserer eigenen Code-Base. Wenn ein Customer einen Lauf
|
||||
macht und 50 Findings produziert sind, sollten 90%+ davon durch unsere
|
||||
Template-Bibliothek korrigierbar sein. Wenn nicht → Templates fehlen.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
from sqlalchemy import text as sa_text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def run_reverse_audit(db: Session) -> dict:
|
||||
"""Hauptfunktion. Returns coverage-report dict."""
|
||||
# 1) Alle MCs aus doc_check_controls laden
|
||||
mc_rows = db.execute(sa_text(
|
||||
"""
|
||||
SELECT id::text, control_id, doc_type, title, check_question,
|
||||
pass_criteria, severity
|
||||
FROM compliance.doc_check_controls
|
||||
ORDER BY doc_type, severity DESC
|
||||
"""
|
||||
)).fetchall()
|
||||
|
||||
# 2) Templates aus DB (doc_templates oder legal_templates oder analog)
|
||||
try:
|
||||
tpl_rows = db.execute(sa_text(
|
||||
"""
|
||||
SELECT id::text, doc_type, title, body
|
||||
FROM compliance.doc_templates
|
||||
WHERE active = TRUE
|
||||
"""
|
||||
)).fetchall()
|
||||
except Exception:
|
||||
# Fallback auf evtl. andere Template-Tabelle
|
||||
try:
|
||||
tpl_rows = db.execute(sa_text(
|
||||
"""
|
||||
SELECT id::text, doc_type, name AS title, content AS body
|
||||
FROM compliance.legal_templates
|
||||
"""
|
||||
)).fetchall()
|
||||
except Exception as e:
|
||||
logger.warning("template table not found: %s", e)
|
||||
tpl_rows = []
|
||||
|
||||
# 3) Coverage-Matrix: pro MC, ob ein Template sie abdeckt
|
||||
templates_by_doctype: dict[str, list[dict]] = {}
|
||||
for tid, dt, title, body in tpl_rows:
|
||||
templates_by_doctype.setdefault(dt or "other", []).append({
|
||||
"id": tid, "title": title, "body": (body or "")[:50000],
|
||||
})
|
||||
|
||||
covered_mc_ids: set[str] = set()
|
||||
uncovered: list[dict] = []
|
||||
for mc_id, ctrl_id, dt, title, q, pc, sev in mc_rows:
|
||||
tpls = templates_by_doctype.get(dt or "other") or []
|
||||
if not tpls:
|
||||
uncovered.append({
|
||||
"mc_id": ctrl_id, "doc_type": dt, "title": title,
|
||||
"severity": sev, "reason": "no_template_for_doctype",
|
||||
})
|
||||
continue
|
||||
# Heuristik: pass_criteria sind Pattern. Wenn IRGENDEIN Template
|
||||
# die Pattern enthaelt → covered.
|
||||
criteria = _extract_patterns_from_pc(pc)
|
||||
if not criteria:
|
||||
# ohne klare Pattern: per Title-Keywords pruefen
|
||||
criteria = _title_keywords(title or "")
|
||||
ok = False
|
||||
for tpl in tpls:
|
||||
body = tpl["body"].lower()
|
||||
hits = sum(1 for p in criteria if p and p.lower() in body)
|
||||
if hits >= max(1, len(criteria) // 2):
|
||||
ok = True
|
||||
break
|
||||
if ok:
|
||||
covered_mc_ids.add(mc_id)
|
||||
else:
|
||||
uncovered.append({
|
||||
"mc_id": ctrl_id, "doc_type": dt, "title": title,
|
||||
"severity": sev, "reason": "no_template_match",
|
||||
"criteria_sample": criteria[:5],
|
||||
})
|
||||
|
||||
# 4) Templates ohne MC-Wirkung
|
||||
used_template_ids: set[str] = set()
|
||||
for mc_id, ctrl_id, dt, title, q, pc, sev in mc_rows:
|
||||
if mc_id not in covered_mc_ids:
|
||||
continue
|
||||
tpls = templates_by_doctype.get(dt or "other") or []
|
||||
criteria = _extract_patterns_from_pc(pc) or _title_keywords(title or "")
|
||||
for tpl in tpls:
|
||||
body = tpl["body"].lower()
|
||||
hits = sum(1 for p in criteria if p and p.lower() in body)
|
||||
if hits >= max(1, len(criteria) // 2):
|
||||
used_template_ids.add(tpl["id"])
|
||||
break
|
||||
all_template_ids = {t["id"] for tpls in templates_by_doctype.values()
|
||||
for t in tpls}
|
||||
unused_templates = all_template_ids - used_template_ids
|
||||
|
||||
return {
|
||||
"total_mcs": len(mc_rows),
|
||||
"total_templates": len(all_template_ids),
|
||||
"covered_mcs": len(covered_mc_ids),
|
||||
"uncovered_mcs": len(uncovered),
|
||||
"coverage_pct": round(len(covered_mc_ids) / max(1, len(mc_rows)) * 100, 1),
|
||||
"unused_templates": sorted(unused_templates),
|
||||
"top_uncovered_high": [u for u in uncovered if u.get("severity") == "HIGH"][:30],
|
||||
"by_doctype": _summarize_by_doctype(mc_rows, covered_mc_ids),
|
||||
}
|
||||
|
||||
|
||||
def _extract_patterns_from_pc(pc) -> list[str]:
|
||||
"""pc ist jsonb mit z.B. {required_phrases: [...]}, {keywords: [...]}"""
|
||||
if not pc:
|
||||
return []
|
||||
if isinstance(pc, str):
|
||||
try:
|
||||
import json as _j
|
||||
pc = _j.loads(pc)
|
||||
except Exception:
|
||||
return [pc[:50]]
|
||||
if isinstance(pc, dict):
|
||||
out: list[str] = []
|
||||
for k in ("required_phrases", "keywords", "must_contain",
|
||||
"patterns", "phrases"):
|
||||
v = pc.get(k)
|
||||
if isinstance(v, list):
|
||||
out.extend([str(x)[:80] for x in v if x])
|
||||
return out
|
||||
if isinstance(pc, list):
|
||||
return [str(x)[:80] for x in pc if x]
|
||||
return []
|
||||
|
||||
|
||||
def _title_keywords(title: str) -> list[str]:
|
||||
"""Fallback wenn pass_criteria leer: extrahiere Substantive aus Title."""
|
||||
if not title:
|
||||
return []
|
||||
# primitive: alle Worte > 4 Buchstaben
|
||||
return [w for w in re.findall(r"\b\w{5,}\b", title)][:5]
|
||||
|
||||
|
||||
def _summarize_by_doctype(mc_rows, covered_mc_ids: set[str]) -> dict:
|
||||
out: dict[str, dict] = {}
|
||||
for mc_id, ctrl_id, dt, title, q, pc, sev in mc_rows:
|
||||
dt = dt or "other"
|
||||
d = out.setdefault(dt, {"total": 0, "covered": 0})
|
||||
d["total"] += 1
|
||||
if mc_id in covered_mc_ids:
|
||||
d["covered"] += 1
|
||||
for dt, d in out.items():
|
||||
d["pct"] = round(d["covered"] / max(1, d["total"]) * 100, 1)
|
||||
return out
|
||||
@@ -0,0 +1,248 @@
|
||||
"""
|
||||
P105 — IAB TCF Vendor-Liste als externe Authority.
|
||||
|
||||
Die IAB TCF v2.2 Global Vendor List (https://vendor-list.consensu.org/v3/
|
||||
vendor-list.json) ist die DSGVO-Authoritaet fuer Werbe-Vendoren: jeder
|
||||
gelistete Vendor hat verbindliche IAB-Purposes:
|
||||
Purpose 1 — Speichern + Zugriff (essential)
|
||||
Purpose 2 — Auswahl Werbung (functional/marketing)
|
||||
Purpose 3 — Personalisierte Werbeprofile (marketing)
|
||||
Purpose 4 — Personalisierte Werbung (marketing)
|
||||
Purpose 5 — Personalisierte Inhaltsprofile (marketing/personalization)
|
||||
Purpose 6 — Personalisierte Inhalte (marketing/personalization)
|
||||
Purpose 7 — Werbe-Performance-Messung (statistics)
|
||||
Purpose 8 — Inhalts-Performance-Messung (statistics)
|
||||
Purpose 9 — Marktforschung (statistics)
|
||||
Purpose 10 — Produkt-Verbesserung (statistics)
|
||||
|
||||
Wenn ein Vendor in der TCF-Liste mit Purpose 3/4 registriert ist und die
|
||||
Site ihn als "Funktional" deklariert → eindeutiger Verstoss (eine externe
|
||||
Authority widerspricht der Deklaration).
|
||||
|
||||
Ingest-Mode: idempotenter Fetch + Upsert in compliance.tcf_vendors_v2.
|
||||
Lookup-Mode: by_vendor_name + by_cookie_owner.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Iterable
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import text as sa_text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_TCF_URL = "https://vendor-list.consensu.org/v3/vendor-list.json"
|
||||
|
||||
# IAB-Purpose → BreakPilot-Kategorie
|
||||
_PURPOSE_TO_CATEGORY = {
|
||||
1: "essential",
|
||||
2: "marketing",
|
||||
3: "marketing",
|
||||
4: "marketing",
|
||||
5: "personalization",
|
||||
6: "personalization",
|
||||
7: "statistics",
|
||||
8: "statistics",
|
||||
9: "statistics",
|
||||
10: "statistics",
|
||||
11: "marketing",
|
||||
}
|
||||
|
||||
|
||||
def _category_for_purposes(purposes: Iterable[int]) -> str:
|
||||
"""Aggregiert Purposes zu der STRENGSTEN Kategorie (Marketing > stats
|
||||
> personalization > essential). Wenn ein Vendor sowohl essential als
|
||||
auch marketing nutzt, ist die rechtlich verbindliche Kategorie
|
||||
Marketing (Einwilligungspflicht)."""
|
||||
cats = {_PURPOSE_TO_CATEGORY.get(p, "marketing") for p in purposes}
|
||||
if "marketing" in cats:
|
||||
return "marketing"
|
||||
if "statistics" in cats:
|
||||
return "statistics"
|
||||
if "personalization" in cats:
|
||||
return "personalization"
|
||||
return "essential"
|
||||
|
||||
|
||||
async def fetch_and_ingest_tcf_vendors(db: Session) -> dict:
|
||||
"""Idempotenter Ingest. Schema-Migration vermeiden — nutzt nur
|
||||
bestehende cookie_library-Tabelle und kennzeichnet TCF-Source via
|
||||
vendor_name='[TCF] <name>'."""
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
resp = await client.get(_TCF_URL)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
vendors = data.get("vendors") or {}
|
||||
if not vendors:
|
||||
return {"error": "no vendors in TCF response", "n_vendors": 0}
|
||||
|
||||
# Erst alte TCF-Eintraege weg (kein UNIQUE-Index auf cookie_name,
|
||||
# daher kein ON CONFLICT moeglich → idempotent via DELETE+INSERT).
|
||||
db.execute(sa_text(
|
||||
"DELETE FROM compliance.cookie_library WHERE source_name='iab_tcf_v2'"
|
||||
))
|
||||
db.commit()
|
||||
inserted = 0
|
||||
skipped = 0
|
||||
for vid, v in vendors.items():
|
||||
name = (v.get("name") or "").strip()
|
||||
if not name:
|
||||
continue
|
||||
purposes = v.get("purposes") or []
|
||||
leg_purposes = v.get("legIntPurposes") or []
|
||||
all_purposes = list(set(purposes) | set(leg_purposes))
|
||||
category = _category_for_purposes(all_purposes)
|
||||
privacy_url = (v.get("policyUrl") or "").strip()[:500] or None
|
||||
|
||||
# Cookie-Names die der Vendor laut TCF setzt sind nicht in der
|
||||
# GVL — wir kennzeichnen nur den Vendor-Eintrag mit ID + Purposes.
|
||||
marker = f"_tcf_v{vid}"
|
||||
try:
|
||||
db.execute(sa_text(
|
||||
"""
|
||||
INSERT INTO compliance.cookie_library
|
||||
(cookie_name, domain_pattern, vendor_name,
|
||||
vendor_privacy_url, actual_category,
|
||||
purpose_en, source_name, source_url, confidence)
|
||||
VALUES (:n, :dp, :v, :pu, :cat, :purp, 'iab_tcf_v2',
|
||||
'https://vendor-list.consensu.org/v3/vendor-list.json',
|
||||
0.99)
|
||||
"""
|
||||
), {"n": marker, "dp": "*",
|
||||
"v": f"[TCF-{vid}] {name}",
|
||||
"pu": privacy_url, "cat": category,
|
||||
"purp": f"IAB TCF v2 Purposes: {sorted(all_purposes)}"})
|
||||
db.commit() # Per-Vendor-Commit damit ein Fehler nicht
|
||||
# die naechsten Eintraege blockt.
|
||||
inserted += 1
|
||||
except Exception as e:
|
||||
logger.warning("TCF vendor %s insert failed: %s", vid, e)
|
||||
skipped += 1
|
||||
db.rollback() # frische Transaktion fuer den naechsten Insert
|
||||
return {"n_vendors_in_gvl": len(vendors), "inserted": inserted,
|
||||
"skipped": skipped}
|
||||
|
||||
|
||||
def lookup_tcf_authority(
|
||||
db: Session,
|
||||
vendor_name: str | None,
|
||||
) -> dict | None:
|
||||
"""Liefert TCF-Authority-Daten fuer einen Vendor-Namen, wenn er
|
||||
in der TCF-Liste registriert ist. Returns {tcf_id, name, category}
|
||||
oder None.
|
||||
|
||||
Fuzzy-Match: 'Google' matched '[TCF-755] Google Advertising Products'.
|
||||
"""
|
||||
if not vendor_name:
|
||||
return None
|
||||
nl = vendor_name.lower().strip()
|
||||
try:
|
||||
rows = db.execute(sa_text(
|
||||
"""
|
||||
SELECT cookie_name, actual_category, vendor_name
|
||||
FROM compliance.cookie_library
|
||||
WHERE source = 'iab_tcf_v2'
|
||||
AND LOWER(vendor_name) LIKE :pat
|
||||
LIMIT 5
|
||||
"""
|
||||
), {"pat": f"%{nl}%"}).fetchall()
|
||||
for r in rows:
|
||||
tcf_name = r[2] # '[TCF-755] Google ...'
|
||||
if tcf_name and "]" in tcf_name:
|
||||
tcf_id = tcf_name.split("]")[0].lstrip("[TCF-")
|
||||
clean = tcf_name.split("]", 1)[1].strip()
|
||||
return {"tcf_id": tcf_id, "name": clean,
|
||||
"category": r[1]}
|
||||
except Exception as e:
|
||||
logger.warning("TCF lookup failed: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
def cross_reference_with_tcf(
|
||||
db: Session,
|
||||
declared_vendors: list[dict],
|
||||
) -> list[dict]:
|
||||
"""Liefert pro Vendor mit Discrepancy ein Finding-dict.
|
||||
|
||||
Eingang: list[{name, category}] aus cmp_vendors.
|
||||
Ausgang: list[{vendor, declared_category, tcf_category, severity}]
|
||||
"""
|
||||
out: list[dict] = []
|
||||
for v in (declared_vendors or []):
|
||||
if not isinstance(v, dict):
|
||||
continue
|
||||
name = (v.get("name") or "").strip()
|
||||
declared_cat = (v.get("category") or "").lower().strip()
|
||||
if not name or not declared_cat:
|
||||
continue
|
||||
tcf = lookup_tcf_authority(db, name)
|
||||
if not tcf:
|
||||
continue
|
||||
if tcf["category"] == declared_cat:
|
||||
continue
|
||||
# Marketing/Statistics vs Functional/Essential ist die kritische
|
||||
# Diskrepanz. functional + personalization sind weicher.
|
||||
severity = "HIGH" if (tcf["category"] == "marketing"
|
||||
and declared_cat in ("essential",
|
||||
"functional",
|
||||
"necessary")) else "MEDIUM"
|
||||
out.append({
|
||||
"vendor": name,
|
||||
"tcf_id": tcf["tcf_id"],
|
||||
"tcf_name": tcf["name"],
|
||||
"declared_category": declared_cat,
|
||||
"tcf_category": tcf["category"],
|
||||
"severity": severity,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def build_tcf_authority_block_html(findings: list[dict]) -> str:
|
||||
if not findings:
|
||||
return ""
|
||||
items: list[str] = []
|
||||
for f in findings[:30]:
|
||||
sev_color = "#dc2626" if f["severity"] == "HIGH" else "#d97706"
|
||||
items.append(
|
||||
f'<li style="margin-bottom:6px;font-size:11px;line-height:1.5">'
|
||||
f'<strong style="color:{sev_color}">{f["vendor"]}</strong> '
|
||||
f'<span style="color:#64748b">— deklariert als</span> '
|
||||
f'<strong>{f["declared_category"]}</strong>, '
|
||||
f'<span style="color:#64748b">IAB TCF v2 (Vendor-ID '
|
||||
f'{f["tcf_id"]}) listet als</span> '
|
||||
f'<strong style="color:{sev_color}">'
|
||||
f'{f["tcf_category"]}</strong>'
|
||||
f'</li>'
|
||||
)
|
||||
return (
|
||||
'<div style="font-family:-apple-system,BlinkMacSystemFont,sans-serif;'
|
||||
'max-width:760px;margin:0 auto 16px;padding:14px 18px;'
|
||||
'background:#fef2f2;border:1px solid #fecaca;border-radius:8px">'
|
||||
'<div style="font-size:11px;color:#991b1b;text-transform:uppercase;'
|
||||
'letter-spacing:1.2px;margin-bottom:4px;font-weight:600">'
|
||||
'IAB TCF v2 Authority-Check — Vendor-Kategorie-Diskrepanz</div>'
|
||||
f'<h3 style="margin:0 0 6px;font-size:14px;color:#1e293b">'
|
||||
f'{len(findings)} Vendor{"en" if len(findings) != 1 else ""} '
|
||||
'mit Kategorie-Widerspruch zur offiziellen IAB-Liste</h3>'
|
||||
'<p style="margin:0 0 10px;font-size:11px;color:#475569;'
|
||||
'line-height:1.5">'
|
||||
'Die IAB Transparency & Consent Framework v2 Global Vendor List '
|
||||
'ist die rechtliche Authoritaet fuer die Klassifizierung von '
|
||||
'Werbe-Vendoren in der EU. Wenn ein Vendor dort als "Marketing" '
|
||||
'gefuehrt ist, kann die Site ihn nicht als "Funktional" einstufen '
|
||||
'— das ist eine externe, durchgesetzte Klassifikation.</p>'
|
||||
'<ul style="margin:0 0 0 18px;padding:0">'
|
||||
+ "".join(items) +
|
||||
'</ul>'
|
||||
'<p style="margin:8px 0 0;font-size:10px;color:#94a3b8;'
|
||||
'font-style:italic">Quelle: '
|
||||
'https://vendor-list.consensu.org/v3/vendor-list.json — '
|
||||
'die TCF-Liste ist verbindlich fuer alle CMP-Tools die IAB-TCF v2 '
|
||||
'implementieren (Cookiebot, OneTrust, Usercentrics, Sourcepoint, …).</p>'
|
||||
'</div>'
|
||||
)
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"site": "Volkswagen Deutschland",
|
||||
"site_url": "https://www.volkswagen.de",
|
||||
"captured_at": "2026-05-22T00:00:00Z",
|
||||
"source": "User-Copy aus Cookie-Richtlinie (Browser Strg+A → Strg+C)",
|
||||
"cookie_richtlinie_url": "https://www.volkswagen.de/de/mehr/rechtliches/cookie-richtlinie.html",
|
||||
"expectations": {
|
||||
"min_declared_cookies": 90,
|
||||
"expected_unique_vendors_after_dedup": 18,
|
||||
"must_find_cookies": [
|
||||
"VWD6_ENSIGHTEN_PRIVACY_MODAL_LOADED",
|
||||
"VWD6_ENSIGHTEN_PRIVACY_MODAL_VIEWED",
|
||||
"smartSignals2UiD", "smartSignals2sUiD",
|
||||
"s_ecid", "s_cc", "s_sq",
|
||||
"AMCV_", "AMCVS_", "demdex", "dextp",
|
||||
"mbox", "mboxEdgeCluster",
|
||||
"TDID", "TDCPM", "TTDOptOut",
|
||||
"DSID", "ANID", "AID", "IDE", "TAID",
|
||||
"_gcl_au", "_gcl_dc", "_fbc", "_fbp", "fr",
|
||||
"_pk_uid",
|
||||
"OptanonConsent",
|
||||
"everest_g_v2", "everest_session_v2",
|
||||
"adbCDP",
|
||||
"liveagent_sid", "liveagent_chatted",
|
||||
"X-Salesforce-eLB", "sfdc-stream",
|
||||
"__cfduid", "__cflb",
|
||||
"FPAU", "FPGCLDC", "FLC", "APC",
|
||||
"wlfeDoLogin", "wlfeRefreshSessionId", "LBCOOKIE",
|
||||
"CookieConsentPolicy",
|
||||
"BrowserId", "BrowserId_sec",
|
||||
"inbenta-km-session-id"
|
||||
],
|
||||
"expected_vendors_present": [
|
||||
"Google",
|
||||
"Adobe Experience Cloud",
|
||||
"Adobe Analytics",
|
||||
"The Trade Desk",
|
||||
"AdForm",
|
||||
"Meta / Facebook",
|
||||
"Salesforce",
|
||||
"Cloudflare",
|
||||
"Borlabs"
|
||||
],
|
||||
"expected_high_findings_minimum": 1,
|
||||
"banner_must_be_detected": true,
|
||||
"expected_doc_types_with_text": [
|
||||
"dse", "cookie", "impressum", "nutzungsbedingungen"
|
||||
]
|
||||
},
|
||||
"raw_paste": "Name des Cookies\nKategorie\nVerwendungszweck\nSpeicherdauer\nArt des Cookies\nSee tests/fixtures/cookie_gt/vw_cookie_richtlinie.txt for the abbreviated raw form."
|
||||
}
|
||||
@@ -28,4 +28,8 @@ USER appuser
|
||||
|
||||
EXPOSE 8094
|
||||
|
||||
# P83 — Build-SHA fuer check-rebuild-needed.sh
|
||||
ARG BUILD_SHA="unknown"
|
||||
ENV BUILD_SHA=${BUILD_SHA}
|
||||
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8094"]
|
||||
|
||||
@@ -53,6 +53,7 @@ class ScanResponse(BaseModel):
|
||||
cmp_payloads: list[dict] = [] # P48: raw CMP JSON-payloads (Usercentrics/OneTrust/...) captured during scan
|
||||
vendor_details: list[dict] = [] # P50: per-vendor detail-modal-extracts (Beschreibung/Cookies/Opt-Out/Privacy)
|
||||
cookies_detailed: list[dict] = [] # P59b: full cookie details for behavior-validation (name,value,domain,expires,phase,declared_category)
|
||||
banner_screenshot_b64: str = "" # P85: base64-PNG des Banners (initial-view)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
@@ -133,6 +134,7 @@ async def scan_consent(req: ScanRequest):
|
||||
cmp_payloads=result.cmp_payloads, # P48
|
||||
vendor_details=result.vendor_details, # P50
|
||||
cookies_detailed=result.cookies_detailed, # P59b
|
||||
banner_screenshot_b64=result.banner_screenshot_b64, # P85
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -77,6 +77,10 @@ class ConsentTestResult:
|
||||
# for behavior-validation in backend. Implicit declared_category:
|
||||
# before/reject phase = essential (site claims), accept = any.
|
||||
cookies_detailed: list = field(default_factory=list)
|
||||
# P85: base64-PNG-Screenshot des Banners vor dem ersten Klick.
|
||||
# Backend embedded das als <img> in der Mail — visueller Beweis
|
||||
# "so sah das Banner zum Audit-Zeitpunkt aus".
|
||||
banner_screenshot_b64: str = ""
|
||||
|
||||
|
||||
async def run_consent_test(
|
||||
@@ -196,6 +200,17 @@ async def run_consent_test(
|
||||
result.banner_text_violations = banner_violations["violations"]
|
||||
result.banner_has_impressum_link = banner_violations["has_impressum"]
|
||||
result.banner_has_dse_link = banner_violations["has_dse"]
|
||||
# P85 — visueller Beweis fuer die Mail.
|
||||
try:
|
||||
import base64 as _b64
|
||||
png = await page_a.screenshot(
|
||||
full_page=False, type="png", timeout=10000,
|
||||
)
|
||||
if png and len(png) < 1_500_000: # < 1.5 MB
|
||||
result.banner_screenshot_b64 = _b64.b64encode(png).decode("ascii")
|
||||
logger.info("P85: banner screenshot captured (%d bytes)", len(png))
|
||||
except Exception as _se:
|
||||
logger.warning("P85: banner screenshot failed: %s", _se)
|
||||
|
||||
await ctx_a.close()
|
||||
|
||||
|
||||
Executable
+49
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env bash
|
||||
# P83 — verhindert "alter Code im Container"-Bug.
|
||||
#
|
||||
# Vergleicht den im Container deployten git-SHA mit dem aktuellen
|
||||
# Source-SHA. Wenn abweichend → exit 1 mit Hinweis Build/Recreate.
|
||||
#
|
||||
# Aufruf-Beispiele:
|
||||
# ./scripts/check-rebuild-needed.sh backend-compliance
|
||||
# ./scripts/check-rebuild-needed.sh admin-compliance
|
||||
# ./scripts/check-rebuild-needed.sh consent-tester
|
||||
#
|
||||
# CI-Verwendung: nach git push, vor dem ersten Health-Check.
|
||||
# Lokal: claude / dev kann es via pre-merge-hook nutzen.
|
||||
#
|
||||
# Voraussetzung: Container hat BUILD_SHA env (gesetzt im Dockerfile via
|
||||
# ARG BUILD_SHA + ENV BUILD_SHA=$BUILD_SHA). Falls leer → Warnung.
|
||||
|
||||
set -e
|
||||
|
||||
SERVICE="${1:-backend-compliance}"
|
||||
CONTAINER="bp-compliance-${SERVICE#*-}" # backend-compliance → bp-compliance-backend
|
||||
if [[ "$SERVICE" == "consent-tester" ]]; then
|
||||
CONTAINER="bp-compliance-consent-tester"
|
||||
fi
|
||||
|
||||
DOCKER="${DOCKER:-/usr/local/bin/docker}"
|
||||
|
||||
deployed_sha=$($DOCKER exec "$CONTAINER" sh -c 'echo "${BUILD_SHA:-unknown}"' 2>/dev/null || echo "container-down")
|
||||
local_sha=$(git rev-parse --short HEAD)
|
||||
|
||||
if [[ "$deployed_sha" == "container-down" ]]; then
|
||||
echo "❌ Container $CONTAINER is not running"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [[ "$deployed_sha" == "unknown" ]]; then
|
||||
echo "⚠️ $CONTAINER has no BUILD_SHA env — cannot verify."
|
||||
echo " Add to Dockerfile: ARG BUILD_SHA / ENV BUILD_SHA=\$BUILD_SHA"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$deployed_sha" != "$local_sha"* && "$local_sha" != "$deployed_sha"* ]]; then
|
||||
echo "❌ $CONTAINER is on commit $deployed_sha, local is $local_sha"
|
||||
echo " REBUILD REQUIRED:"
|
||||
echo " docker compose build $SERVICE && docker compose up -d --no-deps --force-recreate $SERVICE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✓ $CONTAINER ($deployed_sha) matches local ($local_sha)"
|
||||
Reference in New Issue
Block a user