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