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
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:
@@ -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>
|
||||
)
|
||||
}
|
||||
+158
@@ -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 "Benchmark starten".
|
||||
</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>
|
||||
)
|
||||
}
|
||||
+52
-1
@@ -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 × F × P × 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' }}>
|
||||
„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
|
||||
</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>
|
||||
|
||||
@@ -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(>); 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 {
|
||||
|
||||
@@ -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(>.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, >); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return >, 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
Reference in New Issue
Block a user