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,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