feat(iace): benchmark system + erklaerteil + dedup-fix
Build + Deploy / build-backend-compliance (push) Successful in 3m34s
Build + Deploy / build-ai-sdk (push) Successful in 1m6s
Build + Deploy / build-developer-portal (push) Successful in 1m7s
Build + Deploy / build-tts (push) Successful in 1m58s
Build + Deploy / build-document-crawler (push) Successful in 57s
Build + Deploy / build-dsms-gateway (push) Successful in 34s
Build + Deploy / build-admin-compliance (push) Successful in 2m7s
Build + Deploy / build-dsms-node (push) Successful in 29s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / loc-budget (push) Failing after 17s
CI / secret-scan (push) Has been skipped
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Successful in 2m28s
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / test-go (push) Successful in 42s
CI / test-python-backend (push) Successful in 37s
CI / test-python-document-crawler (push) Successful in 27s
CI / test-python-dsms-gateway (push) Successful in 22s
CI / validate-canonical-controls (push) Successful in 15s
Build + Deploy / trigger-orca (push) Successful in 3m10s

- Erklaerteil-Template fuer Risikobeurteilungen (risk_assessment_template.go)
  in PDF-Export, Markdown-Export und Frontend ReportPrintView eingebaut
- Ground Truth Benchmark-System: Datenmodell, Fuzzy-Matching-Engine,
  3 API Endpoints (import-gt, benchmark, benchmark/summary)
- Frontend Benchmark-Tab mit Score-Cards, Kategorie-Breakdown,
  Hazard-Vergleichstabelle (Zugeordnet/Fehlend/Extra), Business Impact
- Erster Benchmark: 13.3% Coverage (Baseline) gegen 60 GT-Eintraege
- Dedup-Fix: seenCat[cat] -> seenCatZone[cat+zone] erlaubt mehrere
  Gefaehrdungen pro Kategorie an verschiedenen Gefahrenstellen
- Komponenten-spezifische Hazard-Namen und Zone-basierte Zuordnung

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-05-13 01:02:33 +02:00
parent 185d680669
commit 8bb90d73e5
18 changed files with 4029 additions and 5 deletions
@@ -0,0 +1,46 @@
'use client'
import React from 'react'
import type { CategoryScore } from '../_hooks/useBenchmark'
interface Props { breakdown: CategoryScore[] }
const CATEGORY_LABELS: Record<string, string> = {
'mechanische gefaehrdungen': 'Mechanisch',
'elektrische gefaehrdungen': 'Elektrisch',
'thermische gefaehrdungen': 'Thermisch',
'laerm': 'Laerm',
'vibration': 'Vibration',
'strahlung': 'Strahlung',
'materialien und substanzen': 'Materialien/Substanzen',
'ergonomische gefaehrdungen': 'Ergonomie',
'einsatzumgebung': 'Einsatzumgebung',
}
export function CategoryBreakdown({ breakdown }: Props) {
if (!breakdown || breakdown.length === 0) return null
return (
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">Coverage nach Gefaehrdungsgruppe</h3>
<div className="space-y-2">
{breakdown.map((cat) => {
const label = CATEGORY_LABELS[cat.category] || cat.category
const pct = Math.round(cat.coverage * 100)
const barColor = pct >= 80 ? 'bg-green-500' : pct >= 50 ? 'bg-yellow-500' : 'bg-red-500'
return (
<div key={cat.category}>
<div className="flex justify-between text-xs text-gray-600 dark:text-gray-400 mb-0.5">
<span>{label}</span>
<span>{cat.match_count}/{cat.gt_count} ({pct}%)</span>
</div>
<div className="h-2 bg-gray-100 dark:bg-gray-700 rounded-full overflow-hidden">
<div className={`h-full ${barColor} rounded-full transition-all`} style={{ width: `${pct}%` }} />
</div>
</div>
)
})}
</div>
</div>
)
}
@@ -0,0 +1,121 @@
'use client'
import React, { useState, useRef } from 'react'
import type { GroundTruthEntry } from '../_hooks/useBenchmark'
interface Props {
onImport: (gt: { entries: GroundTruthEntry[]; source_file?: string; description?: string }) => Promise<void>
loading: boolean
}
export function GTImportForm({ onImport, loading }: Props) {
const [jsonText, setJsonText] = useState('')
const [parseError, setParseError] = useState<string | null>(null)
const [preview, setPreview] = useState<{ count: number; groups: Record<string, number> } | null>(null)
const fileRef = useRef<HTMLInputElement>(null)
function tryParse(text: string) {
setJsonText(text)
setParseError(null)
setPreview(null)
if (!text.trim()) return
try {
const parsed = JSON.parse(text)
const entries: GroundTruthEntry[] = parsed.entries || parsed
if (!Array.isArray(entries) || entries.length === 0) {
setParseError('JSON muss ein Array "entries" enthalten')
return
}
// Validate first entry has required fields
const first = entries[0]
if (!first.hazard_type && !first.hazard_group) {
setParseError('Eintraege muessen hazard_type oder hazard_group enthalten')
return
}
// Build preview
const groups: Record<string, number> = {}
for (const e of entries) {
const g = e.hazard_group || 'Unbekannt'
groups[g] = (groups[g] || 0) + 1
}
setPreview({ count: entries.length, groups })
} catch (err) {
setParseError('Ungueltiges JSON: ' + (err instanceof Error ? err.message : String(err)))
}
}
async function handleImport() {
if (!jsonText.trim()) return
try {
const parsed = JSON.parse(jsonText)
const gt = parsed.entries ? parsed : { entries: parsed }
await onImport(gt)
setJsonText('')
setPreview(null)
} catch (err) {
setParseError(err instanceof Error ? err.message : 'Import fehlgeschlagen')
}
}
function handleFileUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = (ev) => {
const text = ev.target?.result as string
tryParse(text)
}
reader.readAsText(file)
}
return (
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">Ground Truth importieren</h3>
<p className="text-xs text-gray-500 mb-3">
JSON-Datei mit der professionellen Risikobeurteilung einfuegen oder hochladen.
</p>
<div className="flex gap-2 mb-3">
<button
onClick={() => fileRef.current?.click()}
className="px-3 py-1.5 text-xs bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 rounded-md transition-colors"
>
JSON-Datei waehlen
</button>
<input ref={fileRef} type="file" accept=".json" onChange={handleFileUpload} className="hidden" />
</div>
<textarea
value={jsonText}
onChange={(e) => tryParse(e.target.value)}
placeholder='{"entries": [...], "source_file": "...", "description": "..."}'
rows={6}
className="w-full text-xs font-mono border border-gray-300 dark:border-gray-600 rounded-md p-2 bg-gray-50 dark:bg-gray-900 text-gray-800 dark:text-gray-200 resize-y"
/>
{parseError && (
<div className="mt-2 px-3 py-2 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded text-xs text-red-600">
{parseError}
</div>
)}
{preview && (
<div className="mt-2 px-3 py-2 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded text-xs text-green-700 dark:text-green-400">
<strong>{preview.count} Eintraege</strong> erkannt:
{Object.entries(preview.groups).map(([g, c]) => (
<span key={g} className="ml-2">{g}: {c}</span>
))}
</div>
)}
<button
onClick={handleImport}
disabled={loading || !preview}
className="mt-3 w-full px-4 py-2 text-sm font-medium bg-purple-600 hover:bg-purple-700 disabled:bg-gray-300 text-white rounded-md transition-colors"
>
{loading ? 'Importiere...' : 'Ground Truth importieren'}
</button>
</div>
)
}
@@ -0,0 +1,158 @@
'use client'
import React, { useState } from 'react'
import type { HazardMatchPair, GroundTruthEntry, HazardSummary } from '../_hooks/useBenchmark'
interface Props {
matched: HazardMatchPair[]
missing: GroundTruthEntry[]
extra: HazardSummary[]
}
type TabType = 'matched' | 'missing' | 'extra'
export function HazardComparisonTable({ matched, missing, extra }: Props) {
const [tab, setTab] = useState<TabType>('matched')
const tabs: { id: TabType; label: string; count: number; color: string }[] = [
{ id: 'matched', label: 'Zugeordnet', count: matched.length, color: 'text-green-600' },
{ id: 'missing', label: 'Fehlend', count: missing.length, color: 'text-red-600' },
{ id: 'extra', label: 'Zusaetzlich', count: extra.length, color: 'text-gray-500' },
]
return (
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
{/* Tab bar */}
<div className="flex border-b border-gray-200 dark:border-gray-700">
{tabs.map((t) => (
<button
key={t.id}
onClick={() => setTab(t.id)}
className={`flex-1 px-4 py-2.5 text-xs font-medium transition-colors ${
tab === t.id
? 'border-b-2 border-purple-600 text-purple-700 dark:text-purple-400'
: 'text-gray-500 hover:text-gray-700'
}`}
>
{t.label} <span className={t.color}>({t.count})</span>
</button>
))}
</div>
<div className="overflow-x-auto">
{tab === 'matched' && <MatchedTable pairs={matched} />}
{tab === 'missing' && <MissingTable entries={missing} />}
{tab === 'extra' && <ExtraTable entries={extra} />}
</div>
</div>
)
}
function MatchedTable({ pairs }: { pairs: HazardMatchPair[] }) {
if (pairs.length === 0) return <EmptyState text="Keine Zuordnungen gefunden" />
return (
<table className="w-full text-xs">
<thead>
<tr className="bg-gray-50 dark:bg-gray-700/50">
<th className="px-3 py-2 text-left font-medium text-gray-500">Nr.</th>
<th className="px-3 py-2 text-left font-medium text-gray-500">Ground Truth</th>
<th className="px-3 py-2 text-left font-medium text-gray-500">R(GT)</th>
<th className="px-3 py-2 text-left font-medium text-gray-500">Engine</th>
<th className="px-3 py-2 text-center font-medium text-gray-500">Score</th>
<th className="px-3 py-2 text-left font-medium text-gray-500">Match</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
{pairs.map((p, i) => (
<tr key={i} className="hover:bg-gray-50 dark:hover:bg-gray-700/30">
<td className="px-3 py-2 text-gray-400">{p.gt_entry.nr}</td>
<td className="px-3 py-2">
<div className="font-medium text-gray-800 dark:text-gray-200">{p.gt_entry.hazard_type}</div>
<div className="text-gray-400 truncate max-w-[250px]">{p.gt_entry.component_zone}</div>
</td>
<td className="px-3 py-2 text-center">
<RiskBadge risk={p.gt_entry.risk_in.r} />
</td>
<td className="px-3 py-2">
<div className="font-medium text-gray-800 dark:text-gray-200">{p.engine_hazard.name}</div>
<div className="text-gray-400">{p.engine_hazard.category}</div>
</td>
<td className="px-3 py-2 text-center">
<ScoreBadge score={p.match_score} />
</td>
<td className="px-3 py-2 text-gray-400">{p.match_reason}</td>
</tr>
))}
</tbody>
</table>
)
}
function MissingTable({ entries }: { entries: GroundTruthEntry[] }) {
if (entries.length === 0) return <EmptyState text="Keine fehlenden Gefaehrdungen" />
return (
<table className="w-full text-xs">
<thead>
<tr className="bg-red-50 dark:bg-red-900/20">
<th className="px-3 py-2 text-left font-medium text-red-600">Nr.</th>
<th className="px-3 py-2 text-left font-medium text-red-600">Gefaehrdung</th>
<th className="px-3 py-2 text-left font-medium text-red-600">Ursache</th>
<th className="px-3 py-2 text-left font-medium text-red-600">Zone</th>
<th className="px-3 py-2 text-center font-medium text-red-600">R</th>
<th className="px-3 py-2 text-left font-medium text-red-600">Typ</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
{entries.map((e, i) => (
<tr key={i} className="hover:bg-red-50/50">
<td className="px-3 py-2 text-gray-400">{e.nr}</td>
<td className="px-3 py-2 font-medium text-gray-800 dark:text-gray-200">{e.hazard_type}</td>
<td className="px-3 py-2 text-gray-600 truncate max-w-[200px]">{e.hazard_cause}</td>
<td className="px-3 py-2 text-gray-500">{e.component_zone}</td>
<td className="px-3 py-2 text-center"><RiskBadge risk={e.risk_in.r} /></td>
<td className="px-3 py-2 text-gray-500">{e.measure_type}</td>
</tr>
))}
</tbody>
</table>
)
}
function ExtraTable({ entries }: { entries: HazardSummary[] }) {
if (entries.length === 0) return <EmptyState text="Keine zusaetzlichen Engine-Gefaehrdungen" />
return (
<table className="w-full text-xs">
<thead>
<tr className="bg-gray-50 dark:bg-gray-700/50">
<th className="px-3 py-2 text-left font-medium text-gray-500">Name</th>
<th className="px-3 py-2 text-left font-medium text-gray-500">Kategorie</th>
<th className="px-3 py-2 text-left font-medium text-gray-500">Zone</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
{entries.map((e, i) => (
<tr key={i} className="hover:bg-gray-50 dark:hover:bg-gray-700/30">
<td className="px-3 py-2 text-gray-800 dark:text-gray-200">{e.name}</td>
<td className="px-3 py-2 text-gray-500">{e.category}</td>
<td className="px-3 py-2 text-gray-400">{e.zone || '-'}</td>
</tr>
))}
</tbody>
</table>
)
}
function RiskBadge({ risk }: { risk: number }) {
const color = risk >= 30 ? 'bg-red-100 text-red-700' : risk >= 15 ? 'bg-yellow-100 text-yellow-700' : 'bg-green-100 text-green-700'
return <span className={`inline-block px-1.5 py-0.5 rounded text-[10px] font-bold ${color}`}>{risk}</span>
}
function ScoreBadge({ score }: { score: number }) {
const pct = Math.round(score * 100)
const color = pct >= 70 ? 'text-green-600' : pct >= 50 ? 'text-yellow-600' : 'text-red-600'
return <span className={`font-bold ${color}`}>{pct}%</span>
}
function EmptyState({ text }: { text: string }) {
return <div className="px-4 py-8 text-center text-sm text-gray-400">{text}</div>
}
@@ -0,0 +1,115 @@
'use client'
import { useState, useCallback } from 'react'
export interface GTRisk { f: number; w: number; p: number; s: number; r: number }
export interface GTPLr { s: string; f: string; p: string; ew?: string; plr: string }
export interface GroundTruthEntry {
nr: string
hazard_group: string
hazard_group_applicable: boolean
hazard_subgroup: string
hazard_type: string
hazard_cause: string
lifecycle_phases: string[]
component_zone: string
risk_in: GTRisk
plr?: GTPLr | null
measures: string[]
measure_type: string
risk_out: GTRisk
norm_references: string[]
sufficient: boolean
comment?: string
reduction_steps?: {
risk_in: GTRisk; measures: string[]; measure_type: string
risk_out: GTRisk; norm_references: string[]; sufficient: boolean
}[]
}
export interface HazardSummary {
id: string; name: string; category: string
component?: string; zone?: string; risk_level?: string
}
export interface HazardMatchPair {
gt_entry: GroundTruthEntry
engine_hazard: HazardSummary
match_score: number
match_reason: string
}
export interface CategoryScore {
category: string; gt_count: number; match_count: number; coverage: number
}
export interface BenchmarkResult {
coverage_score: number
measure_coverage: number
total_gt: number
total_engine: number
matched_pairs: HazardMatchPair[]
missing_from_engine: GroundTruthEntry[]
extra_in_engine: HazardSummary[]
category_breakdown: CategoryScore[]
risk_rank_pairs: { gt_rank: number; engine_rank: number; hazard_name: string; gt_risk_score: number }[]
}
interface UseBenchmarkReturn {
result: BenchmarkResult | null
gtLoaded: boolean
gtEntryCount: number
loading: boolean
error: string | null
importGT: (gt: { entries: GroundTruthEntry[]; source_file?: string; description?: string }) => Promise<void>
runBenchmark: (gtProjectId?: string) => Promise<void>
}
export function useBenchmark(projectId: string): UseBenchmarkReturn {
const [result, setResult] = useState<BenchmarkResult | null>(null)
const [gtLoaded, setGtLoaded] = useState(false)
const [gtEntryCount, setGtEntryCount] = useState(0)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const importGT = useCallback(async (gt: { entries: GroundTruthEntry[]; source_file?: string; description?: string }) => {
setLoading(true)
setError(null)
try {
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/benchmark/import-gt`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(gt),
})
if (!res.ok) throw new Error(await res.text())
const data = await res.json()
setGtLoaded(true)
setGtEntryCount(data.entry_count || gt.entries.length)
} catch (err) {
setError(err instanceof Error ? err.message : 'Import failed')
} finally {
setLoading(false)
}
}, [projectId])
const runBenchmark = useCallback(async (gtProjectId?: string) => {
setLoading(true)
setError(null)
try {
const params = gtProjectId ? `?gt_project_id=${gtProjectId}` : ''
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/benchmark${params}`)
if (!res.ok) throw new Error(await res.text())
const data: BenchmarkResult = await res.json()
setResult(data)
setGtLoaded(true)
setGtEntryCount(data.total_gt)
} catch (err) {
setError(err instanceof Error ? err.message : 'Benchmark failed')
} finally {
setLoading(false)
}
}, [projectId])
return { result, gtLoaded, gtEntryCount, loading, error, importGT, runBenchmark }
}
@@ -0,0 +1,160 @@
'use client'
import React, { useState } from 'react'
import { useParams } from 'next/navigation'
import { useBenchmark } from './_hooks/useBenchmark'
import { GTImportForm } from './_components/GTImportForm'
import { HazardComparisonTable } from './_components/HazardComparisonTable'
import { CategoryBreakdown } from './_components/CategoryBreakdown'
export default function BenchmarkPage() {
const { projectId } = useParams<{ projectId: string }>()
const { result, gtLoaded, gtEntryCount, loading, error, importGT, runBenchmark } = useBenchmark(projectId)
const [gtProjectId, setGtProjectId] = useState('')
const coveragePct = result ? Math.round(result.coverage_score * 100) : 0
const measurePct = result ? Math.round(result.measure_coverage * 100) : 0
return (
<div className="space-y-6 max-w-[1200px]">
{/* Header */}
<div>
<h1 className="text-lg font-bold text-gray-900 dark:text-white">Ground Truth Benchmark</h1>
<p className="text-sm text-gray-500 mt-1">
Vergleich der Engine-Ergebnisse mit einer professionellen Risikobeurteilung
</p>
</div>
{error && (
<div className="px-4 py-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg text-sm text-red-600">
{error}
</div>
)}
{/* GT Import or Cross-Project Reference */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<GTImportForm onImport={importGT} loading={loading} />
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">Benchmark ausfuehren</h3>
<p className="text-xs text-gray-500 mb-3">
GT aus diesem Projekt verwenden, oder eine Projekt-ID mit importierter GT angeben.
</p>
<div className="space-y-2">
<input
type="text"
value={gtProjectId}
onChange={(e) => setGtProjectId(e.target.value)}
placeholder="GT-Projekt-ID (optional — leer = dieses Projekt)"
className="w-full text-xs border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 bg-gray-50 dark:bg-gray-900"
/>
<button
onClick={() => runBenchmark(gtProjectId || undefined)}
disabled={loading}
className="w-full px-4 py-2 text-sm font-medium bg-purple-600 hover:bg-purple-700 disabled:bg-gray-300 text-white rounded-md transition-colors"
>
{loading ? 'Vergleiche...' : 'Benchmark starten'}
</button>
</div>
{gtLoaded && !result && (
<div className="mt-3 px-3 py-2 bg-blue-50 dark:bg-blue-900/20 rounded text-xs text-blue-600">
{gtEntryCount} GT-Eintraege geladen. Klicke &quot;Benchmark starten&quot;.
</div>
)}
</div>
</div>
{/* Results */}
{result && (
<>
{/* Score Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<ScoreCard
label="Hazard Coverage"
value={`${coveragePct}%`}
sub={`${result.matched_pairs?.length || 0} / ${result.total_gt} erkannt`}
color={coveragePct >= 80 ? 'green' : coveragePct >= 50 ? 'yellow' : 'red'}
/>
<ScoreCard
label="Massnahmen-Coverage"
value={`${measurePct}%`}
sub="der zugeordneten Gefaehrdungen"
color={measurePct >= 80 ? 'green' : measurePct >= 50 ? 'yellow' : 'red'}
/>
<ScoreCard
label="GT Eintraege"
value={String(result.total_gt)}
sub="professionelle Beurteilung"
color="gray"
/>
<ScoreCard
label="Engine Eintraege"
value={String(result.total_engine)}
sub={`${result.extra_in_engine?.length || 0} zusaetzlich`}
color="gray"
/>
</div>
{/* Category Breakdown */}
<CategoryBreakdown breakdown={result.category_breakdown || []} />
{/* Hazard Comparison Table */}
<HazardComparisonTable
matched={result.matched_pairs || []}
missing={result.missing_from_engine || []}
extra={result.extra_in_engine || []}
/>
{/* Business Impact */}
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">Business Impact</h3>
<div className="grid grid-cols-3 gap-4 text-center">
<div>
<div className="text-2xl font-bold text-gray-900 dark:text-white">2,5 Tage</div>
<div className="text-xs text-gray-500">Manueller Aufwand</div>
</div>
<div>
<div className="text-2xl font-bold text-purple-600">
{(coveragePct / 100 * 2.5).toFixed(1)} Tage
</div>
<div className="text-xs text-gray-500">Eingespart bei {coveragePct}% Coverage</div>
</div>
<div>
<div className="text-2xl font-bold text-green-600">
{Math.round(coveragePct / 100 * 2.5 * 8 * 100)} EUR
</div>
<div className="text-xs text-gray-500">Einsparung (100 EUR/h)</div>
</div>
</div>
</div>
</>
)}
</div>
)
}
function ScoreCard({ label, value, sub, color }: {
label: string; value: string; sub: string
color: 'green' | 'yellow' | 'red' | 'gray'
}) {
const colors = {
green: 'border-green-200 dark:border-green-800',
yellow: 'border-yellow-200 dark:border-yellow-800',
red: 'border-red-200 dark:border-red-800',
gray: 'border-gray-200 dark:border-gray-700',
}
const textColors = {
green: 'text-green-600', yellow: 'text-yellow-600',
red: 'text-red-600', gray: 'text-gray-900 dark:text-white',
}
return (
<div className={`bg-white dark:bg-gray-800 rounded-lg border-2 ${colors[color]} p-4 text-center`}>
<div className={`text-2xl font-bold ${textColors[color]}`}>{value}</div>
<div className="text-xs font-medium text-gray-700 dark:text-gray-300 mt-1">{label}</div>
<div className="text-[10px] text-gray-400 mt-0.5">{sub}</div>
</div>
)
}
@@ -119,10 +119,61 @@ export function ReportPrintView({ data }: ReportPrintViewProps) {
Herstellers nach EU Maschinenverordnung 2023/1230 Art. 10.
</div>
{/* 2. Inhaltsverzeichnis */}
{/* 2. Methodik der Risikobeurteilung (Erklaerteil) */}
<div className="section-break">
<h2>Methodik der Risikobeurteilung</h2>
<p>
Diese Risikobeurteilung orientiert sich an den Grundprinzipien der EN ISO 12100,
EN 62061 und EN ISO 13849-1. Bewertet werden Grenzen des Produkts, identifizierte
Gefaehrdungen, die jeweilige Risikohoehe sowie das Restrisiko nach Anwendung von
Schutzmassnahmen.
</p>
<p>
Der Prozess ist iterativ: Reicht eine Massnahme nicht aus, werden weitere ergriffen
und das Restrisiko erneut bewertet, bis ein akzeptables Niveau erreicht ist.
</p>
<h3>Risikoberechnung</h3>
<p>Das Ausgangsrisiko ergibt sich aus: <strong>R = S &times; F &times; P &times; A</strong></p>
<table>
<thead>
<tr><th>Faktor</th><th>Beschreibung</th><th>Skala</th></tr>
</thead>
<tbody>
<tr><td><strong>S</strong></td><td>Schadensschwere</td><td>1 (Erste Hilfe) 5 (toedlich)</td></tr>
<tr><td><strong>F</strong></td><td>Expositionshaeufigkeit</td><td>1 (selten/kurz) 5 (dauerhaft)</td></tr>
<tr><td><strong>P</strong></td><td>Eintrittswahrscheinlichkeit</td><td>1 (vernachlaessigbar) 5 (fast sicher)</td></tr>
<tr><td><strong>A</strong></td><td>Vermeidbarkeit</td><td>1 (leicht vermeidbar) 5 (unvermeidbar)</td></tr>
</tbody>
</table>
<p>
Bei Sicherheitskreisen wird der Performance Level (PLr) ueber einen Risikographen
abgeleitet und dem Safety Integrity Level (SIL) zugeordnet.
</p>
<h3>Dreistufenmethode</h3>
<p>Schutzmassnahmen werden priorisiert angewandt:</p>
<ol>
<li><strong>Konstruktive Massnahmen (KM)</strong> Inhaerent sichere Gestaltung</li>
<li><strong>Technische Schutzmassnahmen (TM)</strong> Schutzeinrichtungen, Sicherheitssteuerungen</li>
<li><strong>Benutzerinformationen (BI)</strong> Warnhinweise, Betriebsanleitung</li>
</ol>
<h3>Akzeptanz des Restrisikos</h3>
<p>
Ein Restrisiko gilt als hinreichend gemindert, wenn alle praktisch umsetzbaren Massnahmen
ausgeschoepft wurden und Anwender ueber verbleibende Restrisiken informiert sind.
Die Akzeptanz wird pro Gefaehrdung mit <strong>JA</strong> / <strong>NEIN</strong> dokumentiert.
</p>
<p style={{ fontStyle: 'italic', fontSize: '9pt', color: '#374151' }}>
&bdquo;Die Moeglichkeit, einen hoeheren Sicherheitsgrad zu erreichen, oder die Verfuegbarkeit
anderer Produkte, die ein geringeres Risiko darstellen, ist kein ausreichender Grund,
ein Produkt als gefaehrlich anzusehen.&ldquo; § 3 Abs. 2 ProdSG
</p>
</div>
{/* 3. Inhaltsverzeichnis */}
<div className="section-break">
<h2>Inhaltsverzeichnis</h2>
<ol className="toc">
<li>Methodik der Risikobeurteilung</li>
<li>Maschinenbeschreibung</li>
<li>Angewandte Normen</li>
<li>Gefaehrdungsliste</li>
+1
View File
@@ -24,6 +24,7 @@ const IACE_EXTRA_ITEMS = [
{ id: 'knowledge-graph', label: 'Knowledge Graph', href: '/knowledge-graph', icon: 'activity' },
{ id: 'classification', label: 'Klassifikation', href: '/classification', icon: 'tag' },
{ id: 'monitoring', label: 'Monitoring', href: '/monitoring', icon: 'activity' },
{ id: 'benchmark', label: 'Benchmark', href: '/benchmark', icon: 'check' },
]
function NavIcon({ icon, className }: { icon: string; className?: string }) {
@@ -0,0 +1,162 @@
package handlers
import (
"encoding/json"
"net/http"
"time"
"github.com/breakpilot/ai-compliance-sdk/internal/iace"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// ImportGroundTruth handles POST /projects/:id/benchmark/import-gt
// Stores Ground Truth data in project metadata.ground_truth.
func (h *IACEHandler) ImportGroundTruth(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 || project == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "project not found"})
return
}
var gt iace.GroundTruth
if err := c.ShouldBindJSON(&gt); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ground truth JSON: " + err.Error()})
return
}
if gt.ImportedAt == "" {
gt.ImportedAt = time.Now().Format("2006-01-02")
}
// Merge into existing metadata
meta := make(map[string]json.RawMessage)
if project.Metadata != nil {
_ = json.Unmarshal(project.Metadata, &meta)
}
gtJSON, _ := json.Marshal(gt)
meta["ground_truth"] = gtJSON
mergedMeta, _ := json.Marshal(meta)
err = h.store.UpdateProjectMetadata(ctx, projectID, mergedMeta)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to store ground truth"})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "ground truth imported",
"entry_count": len(gt.Entries),
"source_file": gt.SourceFile,
})
}
// RunBenchmark handles GET /projects/:id/benchmark?gt_project_id=:gtId
// Compares engine hazards from project :id against GT from project :gtId.
// If gt_project_id is omitted, looks for GT in the same project's metadata.
func (h *IACEHandler) RunBenchmark(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()
// Determine GT source
gtProjectID := projectID
if gtParam := c.Query("gt_project_id"); gtParam != "" {
parsed, err := uuid.Parse(gtParam)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid gt_project_id"})
return
}
gtProjectID = parsed
}
// Load GT
gtProject, err := h.store.GetProject(ctx, gtProjectID)
if err != nil || gtProject == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "GT project not found"})
return
}
gt, err := iace.ParseGroundTruth(gtProject.Metadata)
if err != nil || gt == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "no ground truth data in project metadata"})
return
}
// Load engine hazards + mitigations
hazards, err := h.store.ListHazards(ctx, projectID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load hazards"})
return
}
mitigations, err := h.store.ListMitigationsByProject(ctx, projectID)
if err != nil {
mitigations = nil
}
result := iace.CompareBenchmark(gt, hazards, mitigations)
c.JSON(http.StatusOK, result)
}
// GetBenchmarkSummary handles GET /projects/:id/benchmark/summary
// Returns lightweight coverage metrics without full match details.
func (h *IACEHandler) GetBenchmarkSummary(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()
gtProjectID := projectID
if gtParam := c.Query("gt_project_id"); gtParam != "" {
parsed, err := uuid.Parse(gtParam)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid gt_project_id"})
return
}
gtProjectID = parsed
}
gtProject, err := h.store.GetProject(ctx, gtProjectID)
if err != nil || gtProject == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "GT project not found"})
return
}
gt, err := iace.ParseGroundTruth(gtProject.Metadata)
if err != nil || gt == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "no ground truth data"})
return
}
hazards, err := h.store.ListHazards(ctx, projectID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load hazards"})
return
}
mitigations, _ := h.store.ListMitigationsByProject(ctx, projectID)
result := iace.CompareBenchmark(gt, hazards, mitigations)
c.JSON(http.StatusOK, gin.H{
"coverage_score": result.CoverageScore,
"measure_coverage": result.MeasureCoverage,
"total_gt": result.TotalGT,
"total_engine": result.TotalEngine,
"matched_count": len(result.MatchedPairs),
"missing_count": len(result.MissingFromEngine),
"extra_count": len(result.ExtraInEngine),
"category_breakdown": result.CategoryBreakdown,
})
}
@@ -143,26 +143,53 @@ func (h *IACEHandler) InitializeProject(c *gin.Context) {
if len(existingHazards) == 0 && len(matchOutput.MatchedPatterns) > 0 {
comps, _ := h.store.ListComponents(ctx, projectID)
var defaultCompID uuid.UUID
compByName := make(map[string]uuid.UUID)
if len(comps) > 0 {
defaultCompID = comps[0].ID
for _, c := range comps {
compByName[iace.NormalizeDEPublic(c.Name)] = c.ID
}
}
created := 0
seenCat := make(map[string]bool)
seenCatZone := make(map[string]bool)
for _, mp := range matchOutput.MatchedPatterns {
for _, cat := range mp.HazardCats {
if seenCat[cat] {
// Dedup by category + zone (allows multiple hazards per category at different zones)
zoneKey := mp.ZoneDE
if zoneKey == "" {
zoneKey = mp.PatternID
}
dedupKey := cat + ":" + zoneKey
if seenCatZone[dedupKey] {
continue
}
seenCat[cat] = true
seenCatZone[dedupKey] = true
name := mp.PatternName
if name == "" {
name = cat
}
// Append zone to name for specificity
if mp.ZoneDE != "" && !containsSubstring(name, mp.ZoneDE) {
name = name + " (" + mp.ZoneDE + ")"
}
// Find matching component by zone name
compID := defaultCompID
if mp.ZoneDE != "" {
zoneNorm := iace.NormalizeDEPublic(mp.ZoneDE)
for cName, cID := range compByName {
if containsSubstring(zoneNorm, cName) || containsSubstring(cName, zoneNorm) {
compID = cID
break
}
}
}
hz, cerr := h.store.CreateHazard(ctx, iace.CreateHazardRequest{
ProjectID: projectID,
ComponentID: defaultCompID,
ComponentID: compID,
Name: name,
Description: mp.ScenarioDE,
Category: cat,
@@ -2,6 +2,7 @@ package handlers
import (
"encoding/json"
"strings"
"github.com/breakpilot/ai-compliance-sdk/internal/iace"
"github.com/google/uuid"
@@ -190,6 +191,14 @@ func extractIndustrySectorsFromMetadata(metadata json.RawMessage) []string {
return result
}
// containsSubstring checks if haystack contains needle (case-insensitive, normalized).
func containsSubstring(haystack, needle string) bool {
return strings.Contains(
strings.ToLower(haystack),
strings.ToLower(needle),
)
}
// findHazardForMeasureByCategory finds a matching hazard for a measure.
func findHazardForMeasureByCategory(measureCat string, hazardsByCategory map[string]uuid.UUID) uuid.UUID {
if id, ok := hazardsByCategory[measureCat]; ok {
+3
View File
@@ -432,6 +432,9 @@ func registerIACERoutes(v1 *gin.RouterGroup, h *handlers.IACEHandler) {
iaceRoutes.POST("/library-search", h.SearchLibrary)
iaceRoutes.GET("/ce-corpus-documents", h.ListCECorpusDocuments)
iaceRoutes.POST("/projects/:id/initialize", h.InitializeProject)
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)
@@ -0,0 +1,365 @@
package iace
import (
"sort"
"strings"
)
// ============================================================================
// Fuzzy matching: Ground Truth entries ↔ Engine hazards
// ============================================================================
const matchThreshold = 0.35
// categoryMap maps GT hazard_group (German) to engine category prefixes.
var categoryMap = map[string][]string{
"mechanische gefaehrdungen": {"mechanical"},
"elektrische gefaehrdungen": {"electrical"},
"thermische gefaehrdungen": {"thermal"},
"gefaehrdungen durch laerm": {"noise", "ergonomic"},
"gefaehrdungen durch vibration": {"noise", "vibration"},
"gefaehrdungen durch strahlung": {"radiation", "emc"},
"gefaehrdungen durch materialien und substanzen": {"material", "environmental"},
"ergonomische gefaehrdungen": {"ergonomic"},
"gefaehrdungen im zusammenhang mit der einsatzumgebung": {"environmental"},
}
// synonymSets groups equivalent hazard terms for keyword matching.
var synonymSets = [][]string{
{"quetsch", "crush", "einklemm", "klemm"},
{"scher", "shear", "absch"},
{"schneid", "cut", "schnitt"},
{"stoss", "schlag", "impact", "treff", "aufprall"},
{"einzug", "fang", "erfass", "entangle", "wickel"},
{"elektrisch", "stromschlag", "electric", "beruehr", "spannungsfuehr"},
{"brand", "feuer", "fire", "kabelbrand", "kurzschluss"},
{"verbrenn", "burn", "heiss", "thermisch"},
{"laerm", "noise", "gehoer", "schall"},
{"vibration", "schwing"},
{"ergonom", "haltung", "handhabung", "bedien"},
{"kuehlschmierstoff", "kss", "aerosol", "coolant"},
{"pneumat", "druckluft", "compressed"},
{"hydraul", "druck", "pressure"},
{"roboter", "robot", "roboterarm"},
{"greifer", "gripper", "schunk"},
{"foerderband", "transport", "conveyor"},
{"schutzzaun", "schutzgitter", "fence", "guard"},
{"werkzeugmaschine", "robodrill", "bearbeitungszentrum", "wzm"},
{"stolper", "rutsch", "slip", "trip"},
{"leckage", "austreten", "leak"},
{"einstich", "puncture", "spritz"},
}
// CompareBenchmark runs the full comparison between Ground Truth and engine output.
func CompareBenchmark(gt *GroundTruth, hazards []Hazard, mitigations []Mitigation) *BenchmarkResult {
if gt == nil || len(gt.Entries) == 0 {
return &BenchmarkResult{}
}
engineSummaries := make([]HazardSummary, len(hazards))
for i, h := range hazards {
engineSummaries[i] = HazardSummary{
ID: h.ID.String(),
Name: h.Name,
Category: h.Category,
Zone: h.HazardousZone,
}
}
// Build score matrix: gt[i] × engine[j]
type scoredPair struct {
gtIdx, engIdx int
score float64
reason string
}
var pairs []scoredPair
for i := range gt.Entries {
for j := range hazards {
score, reason := fuzzyMatchScore(&gt.Entries[i], &hazards[j])
if score >= matchThreshold {
pairs = append(pairs, scoredPair{i, j, score, reason})
}
}
}
// Greedy best-first 1:1 assignment
sort.Slice(pairs, func(a, b int) bool { return pairs[a].score > pairs[b].score })
usedGT := make(map[int]bool)
usedEng := make(map[int]bool)
var matched []HazardMatchPair
for _, p := range pairs {
if usedGT[p.gtIdx] || usedEng[p.engIdx] {
continue
}
usedGT[p.gtIdx] = true
usedEng[p.engIdx] = true
matched = append(matched, HazardMatchPair{
GTEntry: gt.Entries[p.gtIdx],
EngineHazard: engineSummaries[p.engIdx],
MatchScore: p.score,
MatchReason: p.reason,
})
}
// Collect unmatched
var missing []GroundTruthEntry
for i, e := range gt.Entries {
if !usedGT[i] {
missing = append(missing, e)
}
}
var extra []HazardSummary
for i, s := range engineSummaries {
if !usedEng[i] {
extra = append(extra, s)
}
}
// Category breakdown
catGT := map[string]int{}
catMatch := map[string]int{}
for _, e := range gt.Entries {
cat := normalizeCategoryDE(e.HazardGroup)
catGT[cat]++
}
for _, m := range matched {
cat := normalizeCategoryDE(m.GTEntry.HazardGroup)
catMatch[cat]++
}
var breakdown []CategoryScore
for cat, total := range catGT {
cov := 0.0
if total > 0 {
cov = float64(catMatch[cat]) / float64(total)
}
breakdown = append(breakdown, CategoryScore{
Category: cat, GTCount: total, MatchCount: catMatch[cat], Coverage: cov,
})
}
sort.Slice(breakdown, func(i, j int) bool { return breakdown[i].GTCount > breakdown[j].GTCount })
// Measure coverage (simplified: count GT entries where at least 1 measure keyword matches)
measMatched := 0
for _, m := range matched {
if measureOverlap(m.GTEntry.Measures, mitigations) {
measMatched++
}
}
measCov := 0.0
if len(matched) > 0 {
measCov = float64(measMatched) / float64(len(matched))
}
// Risk rank comparison
rankPairs := buildRiskRankPairs(matched)
coverage := 0.0
if len(gt.Entries) > 0 {
coverage = float64(len(matched)) / float64(len(gt.Entries))
}
return &BenchmarkResult{
CoverageScore: coverage,
MeasureCoverage: measCov,
TotalGT: len(gt.Entries),
TotalEngine: len(hazards),
MatchedPairs: matched,
MissingFromEngine: missing,
ExtraInEngine: extra,
CategoryBreakdown: breakdown,
RiskRankPairs: rankPairs,
}
}
// fuzzyMatchScore computes a 0-1 similarity between a GT entry and an engine hazard.
func fuzzyMatchScore(gt *GroundTruthEntry, h *Hazard) (float64, string) {
var score float64
var reasons []string
// 1. Category match (weight 0.4)
catScore := categoryMatchScore(gt.HazardGroup, h.Category)
score += 0.4 * catScore
if catScore > 0 {
reasons = append(reasons, "Kategorie")
}
// 2. Keyword/synonym match (weight 0.3)
kwScore := keywordMatchScore(gt.HazardType, gt.HazardCause, h.Name, h.Description, h.Scenario)
score += 0.3 * kwScore
if kwScore > 0 {
reasons = append(reasons, "Keywords")
}
// 3. Component/zone match (weight 0.3)
zoneScore := zoneMatchScore(gt.ComponentZone, gt.HazardSubgroup, h.HazardousZone, h.MachineModule)
score += 0.3 * zoneScore
if zoneScore > 0 {
reasons = append(reasons, "Zone")
}
return score, strings.Join(reasons, "+")
}
func categoryMatchScore(gtGroup, engCategory string) float64 {
normalized := normalizeDE(gtGroup)
prefixes, ok := categoryMap[normalized]
if !ok {
return 0
}
engLower := strings.ToLower(engCategory)
for _, p := range prefixes {
if strings.Contains(engLower, p) {
return 1.0
}
}
return 0
}
func keywordMatchScore(gtType, gtCause, engName, engDesc, engScenario string) float64 {
gtText := normalizeDE(gtType + " " + gtCause)
engText := normalizeDE(engName + " " + engDesc + " " + engScenario)
matchedSets := 0
totalRelevant := 0
for _, synSet := range synonymSets {
gtHas := false
engHas := false
for _, syn := range synSet {
if strings.Contains(gtText, syn) {
gtHas = true
}
if strings.Contains(engText, syn) {
engHas = true
}
}
if gtHas {
totalRelevant++
if engHas {
matchedSets++
}
}
}
if totalRelevant == 0 {
return 0
}
return float64(matchedSets) / float64(totalRelevant)
}
func zoneMatchScore(gtZone, gtSubgroup, engZone, engModule string) float64 {
gtText := normalizeDE(gtZone + " " + gtSubgroup)
engText := normalizeDE(engZone + " " + engModule)
if gtText == "" || engText == "" {
return 0
}
// Check for significant word overlap
gtWords := extractSignificantWords(gtText)
engWords := extractSignificantWords(engText)
if len(gtWords) == 0 {
return 0
}
matched := 0
for _, gw := range gtWords {
for _, ew := range engWords {
if strings.Contains(ew, gw) || strings.Contains(gw, ew) {
matched++
break
}
}
}
return float64(matched) / float64(len(gtWords))
}
func extractSignificantWords(text string) []string {
stopWords := map[string]bool{
"der": true, "die": true, "das": true, "und": true, "oder": true,
"von": true, "in": true, "an": true, "am": true, "im": true,
"zu": true, "bei": true, "mit": true, "des": true, "den": true,
"dem": true, "ein": true, "eine": true, "einer": true, "einem": true,
"fuer": true, "auf": true, "aus": true, "um": true, "nach": true,
"ueber": true, "unter": true, "vor": true, "durch": true,
}
words := strings.Fields(text)
var sig []string
for _, w := range words {
if len(w) < 3 || stopWords[w] {
continue
}
sig = append(sig, w)
}
return sig
}
// NormalizeDEPublic is the exported version of normalizeDE for use outside this package.
func NormalizeDEPublic(s string) string { return normalizeDE(s) }
// normalizeDE lowercases and replaces umlauts (same as narrative_parser).
func normalizeDE(s string) string {
s = strings.ToLower(strings.TrimSpace(s))
s = strings.ReplaceAll(s, "ä", "ae")
s = strings.ReplaceAll(s, "ö", "oe")
s = strings.ReplaceAll(s, "ü", "ue")
s = strings.ReplaceAll(s, "ß", "ss")
return s
}
func normalizeCategoryDE(group string) string {
n := normalizeDE(group)
// Shorten for display
n = strings.TrimPrefix(n, "gefaehrdungen durch ")
n = strings.TrimPrefix(n, "gefaehrdungen im zusammenhang mit ")
return n
}
func measureOverlap(gtMeasures []string, mitigations []Mitigation) bool {
for _, gm := range gtMeasures {
gmNorm := normalizeDE(gm)
for _, m := range mitigations {
mNorm := normalizeDE(m.Name + " " + m.Description)
// Check if any significant word from GT measure appears in engine mitigation
words := extractSignificantWords(gmNorm)
for _, w := range words {
if strings.Contains(mNorm, w) {
return true
}
}
}
}
return false
}
func buildRiskRankPairs(matched []HazardMatchPair) []RiskRankPair {
if len(matched) == 0 {
return nil
}
// Sort by GT risk descending to get GT rank
type ranked struct {
idx int
gtRisk int
name string
}
items := make([]ranked, len(matched))
for i, m := range matched {
items[i] = ranked{i, m.GTEntry.RiskIn.R, m.GTEntry.HazardType}
}
sort.Slice(items, func(a, b int) bool { return items[a].gtRisk > items[b].gtRisk })
pairs := make([]RiskRankPair, len(items))
for rank, item := range items {
pairs[rank] = RiskRankPair{
GTRank: rank + 1,
EngineRank: 0, // Engine has no assessment yet for auto-generated hazards
HazardName: item.name,
GTRiskScore: item.gtRisk,
EngineRisk: 0,
}
}
return pairs
}
@@ -0,0 +1,135 @@
package iace
import "encoding/json"
// ============================================================================
// Ground Truth types — stores a professional risk assessment for benchmarking
// ============================================================================
// GroundTruth is the top-level container stored in project metadata.ground_truth.
type GroundTruth struct {
Entries []GroundTruthEntry `json:"entries"`
SourceFile string `json:"source_file,omitempty"`
ImportedAt string `json:"imported_at"`
Description string `json:"description,omitempty"`
}
// GroundTruthEntry represents a single hazard from a professional risk assessment.
type GroundTruthEntry struct {
Nr string `json:"nr"`
HazardGroup string `json:"hazard_group"`
HazardGroupApplicable bool `json:"hazard_group_applicable"`
HazardSubgroup string `json:"hazard_subgroup"`
HazardType string `json:"hazard_type"`
HazardCause string `json:"hazard_cause"`
LifecyclePhases []string `json:"lifecycle_phases"`
ComponentZone string `json:"component_zone"`
RiskIn GTRisk `json:"risk_in"`
PLr *GTPLr `json:"plr,omitempty"`
Measures []string `json:"measures"`
MeasureType string `json:"measure_type"`
RiskOut GTRisk `json:"risk_out"`
NormReferences []string `json:"norm_references"`
Sufficient bool `json:"sufficient"`
Comment string `json:"comment,omitempty"`
ReductionSteps []GTReductionStep `json:"reduction_steps,omitempty"`
}
// GTRisk represents the EN 62061 additive risk: R = (F + W + P) * S.
type GTRisk struct {
F int `json:"f"`
W int `json:"w"`
P int `json:"p"`
S int `json:"s"`
R int `json:"r"`
}
// GTPLr represents Performance Level required (EN ISO 13849-1).
type GTPLr struct {
S string `json:"s"`
F string `json:"f"`
P string `json:"p"`
EW string `json:"ew,omitempty"`
PLr string `json:"plr"`
}
// GTReductionStep represents an iterative risk reduction row.
type GTReductionStep struct {
RiskIn GTRisk `json:"risk_in"`
PLr *GTPLr `json:"plr,omitempty"`
Measures []string `json:"measures"`
MeasureType string `json:"measure_type"`
RiskOut GTRisk `json:"risk_out"`
NormReferences []string `json:"norm_references"`
Sufficient bool `json:"sufficient"`
Comment string `json:"comment,omitempty"`
}
// ============================================================================
// Benchmark result types — comparison output
// ============================================================================
// BenchmarkResult is the API response for the comparison endpoint.
type BenchmarkResult struct {
CoverageScore float64 `json:"coverage_score"`
MeasureCoverage float64 `json:"measure_coverage"`
TotalGT int `json:"total_gt"`
TotalEngine int `json:"total_engine"`
MatchedPairs []HazardMatchPair `json:"matched_pairs"`
MissingFromEngine []GroundTruthEntry `json:"missing_from_engine"`
ExtraInEngine []HazardSummary `json:"extra_in_engine"`
CategoryBreakdown []CategoryScore `json:"category_breakdown"`
RiskRankPairs []RiskRankPair `json:"risk_rank_pairs"`
}
// HazardMatchPair links a GT entry to an engine hazard.
type HazardMatchPair struct {
GTEntry GroundTruthEntry `json:"gt_entry"`
EngineHazard HazardSummary `json:"engine_hazard"`
MatchScore float64 `json:"match_score"`
MatchReason string `json:"match_reason"`
}
// HazardSummary is a lightweight hazard representation for benchmark results.
type HazardSummary struct {
ID string `json:"id"`
Name string `json:"name"`
Category string `json:"category"`
Component string `json:"component,omitempty"`
Zone string `json:"zone,omitempty"`
RiskLevel string `json:"risk_level,omitempty"`
}
// CategoryScore shows coverage per ISO 12100 hazard group.
type CategoryScore struct {
Category string `json:"category"`
GTCount int `json:"gt_count"`
MatchCount int `json:"match_count"`
Coverage float64 `json:"coverage"`
}
// RiskRankPair compares risk ordering between GT and engine.
type RiskRankPair struct {
GTRank int `json:"gt_rank"`
EngineRank int `json:"engine_rank"`
HazardName string `json:"hazard_name"`
GTRiskScore int `json:"gt_risk_score"`
EngineRisk float64 `json:"engine_risk"`
}
// ParseGroundTruth extracts GroundTruth from project metadata JSON.
func ParseGroundTruth(metadata json.RawMessage) (*GroundTruth, error) {
var m map[string]json.RawMessage
if err := json.Unmarshal(metadata, &m); err != nil {
return nil, err
}
raw, ok := m["ground_truth"]
if !ok {
return nil, nil
}
var gt GroundTruth
if err := json.Unmarshal(raw, &gt); err != nil {
return nil, err
}
return &gt, nil
}
@@ -50,6 +50,10 @@ func (e *DocumentExporter) ExportPDF(
pdf.AddPage()
e.pdfCoverPage(pdf, project)
// --- Methodology ("Erklaerteil") ---
pdf.AddPage()
e.pdfMethodologySection(pdf)
// --- Table of Contents ---
pdf.AddPage()
e.pdfTableOfContents(pdf, sections)
@@ -127,6 +131,11 @@ func (e *DocumentExporter) ExportMarkdown(
buf.WriteString(fmt.Sprintf("> %s\n\n", project.Description))
}
buf.WriteString("---\n\n")
buf.WriteString(fmt.Sprintf("## %s\n\n", RiskAssessmentMethodologySectionTitle))
buf.WriteString(RiskAssessmentMethodologyDE)
buf.WriteString("\n\n---\n\n")
for _, section := range sections {
buf.WriteString(fmt.Sprintf("## %s\n\n", section.Title))
buf.WriteString(fmt.Sprintf("*Typ: %s | Status: %s | Version: %d*\n\n",
@@ -2,6 +2,7 @@ package iace
import (
"fmt"
"strings"
"time"
"github.com/jung-kurt/gofpdf"
@@ -49,6 +50,31 @@ func (e *DocumentExporter) pdfCoverPage(pdf *gofpdf.Fpdf, project *Project) {
}
}
func (e *DocumentExporter) pdfMethodologySection(pdf *gofpdf.Fpdf) {
paragraphs := strings.Split(RiskAssessmentMethodologyDE, "\n\n")
for _, para := range paragraphs {
para = strings.TrimSpace(para)
if para == "" {
continue
}
// Headings: lines that are short and don't end with punctuation
if len(para) < 60 && !strings.HasSuffix(para, ".") && !strings.HasSuffix(para, ")") {
pdf.Ln(4)
pdf.SetFont("Helvetica", "B", 12)
pdf.SetTextColor(50, 50, 50)
pdf.CellFormat(0, 8, para, "", 1, "L", false, 0, "")
pdf.SetTextColor(0, 0, 0)
pdf.SetDrawColor(200, 200, 200)
pdf.Line(10, pdf.GetY(), 200, pdf.GetY())
pdf.Ln(3)
continue
}
pdf.SetFont("Helvetica", "", 10)
pdf.MultiCell(0, 5, para, "", "L", false)
pdf.Ln(2)
}
}
func (e *DocumentExporter) pdfTableOfContents(pdf *gofpdf.Fpdf, sections []TechFileSection) {
pdf.SetFont("Helvetica", "B", 16)
pdf.SetTextColor(50, 50, 50)
@@ -61,6 +87,7 @@ func (e *DocumentExporter) pdfTableOfContents(pdf *gofpdf.Fpdf, sections []TechF
pdf.SetFont("Helvetica", "", 11)
fixedEntries := []string{
RiskAssessmentMethodologySectionTitle,
"Gefaehrdungsprotokoll",
"Risikomatrix-Zusammenfassung",
"Massnahmen-Uebersicht",
@@ -0,0 +1,53 @@
package iace
// RiskAssessmentMethodologyDE contains the German-language methodology introduction
// ("Erklaerteil") that is prepended to every risk assessment export.
//
// This text is the single source of truth — it is used by:
// - PDF export (document_export_pdf.go)
// - Markdown export (document_export.go)
// - Frontend print view (ReportPrintView.tsx mirrors this content)
//
// The methodology is BreakPilot's own formulation, inspired by the general
// principles of EN ISO 12100, EN 62061, and EN ISO 13849-1.
// No normative text is reproduced.
const RiskAssessmentMethodologyDE = `Methodik der Risikobeurteilung
Diese Risikobeurteilung orientiert sich an den Grundprinzipien der EN ISO 12100, EN 62061 und EN ISO 13849-1. Bewertet werden Grenzen des Produkts, identifizierte Gefaehrdungen, die jeweilige Risikohoehe sowie das Restrisiko nach Anwendung von Schutzmassnahmen.
Der Prozess ist iterativ: Reicht eine Massnahme nicht aus, werden weitere ergriffen und das Restrisiko erneut bewertet, bis ein akzeptables Niveau erreicht ist. Werden mehrere Massnahmen gemeinsam umgesetzt, erfolgt eine Gesamtbewertung. Wurde die Wirksamkeit einzelner Massnahmen gesondert betrachtet, wird das Restrisiko stufenweise ausgewiesen.
Risikoberechnung
Das Ausgangsrisiko ergibt sich aus:
R = S x F x P x A
S = Schadensschwere (1-5): erwartbare Verletzungsschwere (Erste Hilfe bis toedlich)
F = Expositionshaeufigkeit (1-5): Haeufigkeit und Dauer der Exposition (selten/kurz bis dauerhaft)
P = Eintrittswahrscheinlichkeit (1-5): technische Ausfallwahrscheinlichkeit und menschliches Verhalten (vernachlaessigbar bis fast sicher)
A = Vermeidbarkeit (1-5): Erkennbarkeit, Reaktionszeit, raeumliche Ausweichmoeglichkeit (leicht vermeidbar bis unvermeidbar)
Das Restrisiko beruecksichtigt die Wirksamkeit umgesetzter Massnahmen (Reifegrad, Abdeckungsgrad, Verifikationsstand).
Bei sicherheitstechnischen Steuerungskreisen wird zusaetzlich der erforderliche Performance Level (PLr) ueber einen Risikographen abgeleitet und dem entsprechenden Safety Integrity Level (SIL) zugeordnet. Die Verifikation erfolgt durch den zustaendigen Functional-Safety-Ingenieur.
Massnahmen nach Dreistufenmethode
Schutzmassnahmen werden priorisiert angewandt:
1. Konstruktive Massnahmen (KM) Inhaerent sichere Gestaltung
2. Technische Schutzmassnahmen (TM) Schutzeinrichtungen, Sicherheitssteuerungen
3. Benutzerinformationen (BI) Warnhinweise, Betriebsanleitung
Benutzerinformationen allein sind keine ausreichende Primaermassnahme.
Akzeptanz des Restrisikos
Ein Restrisiko gilt als hinreichend gemindert, wenn alle praktisch umsetzbaren Massnahmen ausgeschoepft wurden, keine neuen Gefaehrdungen durch Schutzmassnahmen entstehen und Anwender ueber verbleibende Restrisiken informiert sind. Massgeblich ist die Verhaeltnismaessigkeit: Je hoeher das Restrisiko, desto hoeher der zumutbare Aufwand.
Die Akzeptanz wird pro Gefaehrdung mit JA / NEIN dokumentiert. Die Farbcodierung spiegelt den erforderlichen SIL wider ein rotes Restrisiko bedeutet nicht automatisch, dass weitere Massnahmen noetig sind.
"Die Moeglichkeit, einen hoeheren Sicherheitsgrad zu erreichen, oder die Verfuegbarkeit anderer Produkte, die ein geringeres Risiko darstellen, ist kein ausreichender Grund, ein Produkt als gefaehrlich anzusehen." (§ 3 Abs. 2 ProdSG)`
// RiskAssessmentMethodologySection is the section title for the TOC.
const RiskAssessmentMethodologySectionTitle = "Methodik der Risikobeurteilung"
@@ -233,6 +233,18 @@ func (s *Store) UpdateProjectCompleteness(ctx context.Context, id uuid.UUID, sco
return nil
}
// UpdateProjectMetadata replaces the metadata JSON for a project.
func (s *Store) UpdateProjectMetadata(ctx context.Context, id uuid.UUID, metadata json.RawMessage) error {
_, err := s.pool.Exec(ctx, `
UPDATE iace_projects SET metadata = $2, updated_at = NOW()
WHERE id = $1
`, id, metadata)
if err != nil {
return fmt.Errorf("update project metadata: %w", err)
}
return nil
}
// ListVariants returns all variant sub-projects for a given parent project
func (s *Store) ListVariants(ctx context.Context, parentID uuid.UUID) ([]Project, error) {
rows, err := s.pool.Query(ctx, `
File diff suppressed because it is too large Load Diff