Files
breakpilot-compliance/ai-compliance-sdk/internal/api/handlers/iace_handler_init_helpers.go
T
Benjamin Admin 71d31c914b feat(iace): ISO 12100 Anhang B mapping — split noise/vibration + section identifier
Phase 16 of the Klaerungen / risk-assessment polish. Sources from
EN ISO 12100 Anhang B Tabelle B.1 are now first-class:

A) HazardPattern.ISO12100Section identifier (string), persisted only as
   the section number (e.g. "6.3.5.5") — not the norm text. Keeps the
   library urheberrechtlich neutral (DIN/Beuth license). 57 patterns
   labeled today; rest will follow on touch.

B) Category split per ISO 12100 Nr. 4 vs Nr. 5:
   - 16 patterns reclassified noise_vibration -> noise_hazard
   - 7  patterns reclassified noise_vibration -> vibration_hazard
   - 1  pattern (HP228 UV-/Laermexposition) kept multi-cat
   acceptableMeasureCategories now accepts both new aliases plus the
   legacy noise_vibration. Coverage test recognises both as valid.

C) 5 new ISO-12100-Annex-B gap patterns (HP1900-HP1904):
   - HP1900 Vakuum-Verletzung (6.3.5.5)
   - HP1901 Federenergie / elastische Elemente (6.2.10)
   - HP1902 Rutschen/Stolpern auf rauer Oberflaeche (6.3.5.6)
   - HP1903 Hochdruckinjektion (6.3.5.4) — includes clarifying
            "no hand-locating of leaks" question
   - HP1904 Ersticken durch Brustkorbquetschung (6.3.5.2)

The library now mirrors the ISO 12100 Annex B structure for the gaps
the Bremse benchmark surfaced.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 01:59:16 +02:00

484 lines
18 KiB
Go

package handlers
import (
"encoding/json"
"strings"
"github.com/breakpilot/ai-compliance-sdk/internal/iace"
"github.com/google/uuid"
)
// extractNarrativeFromMetadata builds a combined text from the limits_form.
func extractNarrativeFromMetadata(metadata json.RawMessage) string {
if metadata == nil {
return ""
}
var meta map[string]json.RawMessage
if err := json.Unmarshal(metadata, &meta); err != nil {
return ""
}
limitsRaw, ok := meta["limits_form"]
if !ok {
return ""
}
var limits map[string]interface{}
if err := json.Unmarshal(limitsRaw, &limits); err != nil {
return ""
}
textFields := []string{
"general_description", "intended_purpose", "foreseeable_misuse",
"space_limits", "time_limits", "environmental_conditions",
"energy_sources", "materials_processed", "operating_modes",
"maintenance_requirements", "personnel_requirements",
"interfaces_description", "control_system_description",
"safety_functions_description",
}
var result string
for _, field := range textFields {
if v, ok := limits[field]; ok {
if s, ok := v.(string); ok && s != "" {
result += s + "\n\n"
}
}
}
return result
}
// acceptableMeasureCategories returns the set of measure HazardCategory values
// that are semantically applicable to a hazard with the given pattern category.
// The mapping is a *set*, not a single value — many pattern categories accept
// measures from several measure-library categories that are conceptually
// related. E.g. a safety_function_failure hazard is sensibly mitigated by
// software_control measures like watchdogs, plausibility checks or self-tests,
// not just by the (almost empty) safety_function category.
//
// "general" is implicit — handled in isCategoryCompatible and not duplicated
// in every set below.
func acceptableMeasureCategories(patternCat string) map[string]bool {
sets := map[string][]string{
"mechanical_hazard": {"mechanical"},
"electrical_hazard": {"electrical"},
"thermal_hazard": {"thermal", "material_environmental"},
// ISO 12100 Anhang B splits Nr. 4 Laerm and Nr. 5 Vibration into
// two top-level groups. The legacy combined alias noise_vibration
// is kept for backwards compat — all three resolve to the same
// measure pool today (the library doesn't separate noise vs
// vibration measures), but the pattern category now matches the
// norm structure.
"noise_hazard": {"noise_vibration", "ergonomic"},
"vibration_hazard": {"noise_vibration", "ergonomic"},
"noise_vibration": {"noise_vibration", "ergonomic"},
"pneumatic_hydraulic": {"pneumatic_hydraulic", "mechanical"},
"material_environmental": {"material_environmental"},
"chemical_risk": {"material_environmental", "thermal"},
"ergonomic": {"ergonomic"},
"ergonomic_hazard": {"ergonomic"},
"fire_explosion": {"thermal", "material_environmental"},
"radiation_hazard": {"material_environmental"},
"emc_hazard": {"electrical", "software_control"},
"maintenance_hazard": {"mechanical"},
"safety_function_failure": {"safety_function", "software_control"},
"software_fault": {"software_control"},
"sensor_fault": {"software_control"},
"configuration_error": {"software_control"},
"update_failure": {"software_control"},
"hmi_error": {"software_control"},
"mode_confusion": {"software_control"},
"unauthorized_access": {"cyber_network", "software_control"},
"communication_failure": {"cyber_network", "software_control"},
"firmware_corruption": {"cyber_network", "software_control"},
"logging_audit_failure": {"cyber_network", "software_control"},
"ai_misclassification": {"ai_specific", "software_control"},
"false_classification": {"ai_specific", "software_control"},
"model_drift": {"ai_specific", "software_control"},
"data_poisoning": {"ai_specific", "software_control"},
"sensor_spoofing": {"ai_specific", "software_control"},
"unintended_bias": {"ai_specific", "software_control"},
// Edge-case pattern categories from legacy authors. Treated as
// synonyms of their primary hazard category so existing patterns
// keep matching the right measure pool.
"noise_source": {"noise_vibration", "ergonomic"},
"vibration_source": {"noise_vibration", "ergonomic"},
"high_temperature": {"thermal", "material_environmental"},
"material_environmental_hazard": {"material_environmental"},
}
out := map[string]bool{"general": true}
if list, ok := sets[patternCat]; ok {
for _, c := range list {
out[c] = true
}
}
return out
}
// isCategoryCompatible reports whether a measure with HazardCategory measureCat
// is semantically applicable to a hazard whose acceptable measure categories
// are listed in accepted. Empty measureCat is always allowed (legacy entries),
// "general" measures are pre-seeded into accepted by acceptableMeasureCategories.
//
// Without this guard, patterns silently inherit nonsense mitigations (e.g.
// HP1651 "robot restart while person in cell" inheriting M054 "Sichere
// thermische Auslegung" — a thermal-design measure used as generic default in
// ~100 mechanical patterns). The Fachmann benchmark rejects such mismatches.
func isCategoryCompatible(measureCat string, accepted map[string]bool) bool {
if measureCat == "" {
return true
}
return accepted[measureCat]
}
// keysOf returns the sorted keys of a string-bool set, used for diagnostic
// log messages that report which measure categories were accepted for a hazard.
func keysOf(s map[string]bool) []string {
out := make([]string, 0, len(s))
for k := range s {
out = append(out, k)
}
return out
}
// patternCatToMeasureCat maps pattern hazard categories to measure categories.
func patternCatToMeasureCat(patternCat string) string {
m := map[string]string{
"mechanical_hazard": "mechanical", "electrical_hazard": "electrical",
"thermal_hazard": "thermal", "noise_vibration": "noise_vibration",
"pneumatic_hydraulic": "pneumatic_hydraulic", "material_environmental": "material_environmental",
"ergonomic": "ergonomic", "ergonomic_hazard": "ergonomic",
"software_fault": "software_control", "safety_function_failure": "safety_function",
"fire_explosion": "thermal", "radiation_hazard": "material_environmental",
"unauthorized_access": "cyber_network", "communication_failure": "cyber_network",
"firmware_corruption": "cyber_network", "logging_audit_failure": "cyber_network",
"ai_misclassification": "ai_specific", "false_classification": "ai_specific",
"model_drift": "ai_specific", "data_poisoning": "ai_specific",
"sensor_spoofing": "ai_specific", "unintended_bias": "ai_specific",
"sensor_fault": "software_control", "configuration_error": "software_control",
"update_failure": "software_control", "hmi_error": "software_control",
"emc_hazard": "electrical", "maintenance_hazard": "mechanical",
"mode_confusion": "software_control", "chemical_risk": "material_environmental",
}
if cat, ok := m[patternCat]; ok {
return cat
}
return "general"
}
// deriveComponentType guesses the component type from its tags.
func deriveComponentType(tags []string) iace.ComponentType {
for _, t := range tags {
switch {
case t == "software" || t == "has_software":
return iace.ComponentTypeSoftware
case t == "firmware" || t == "has_firmware":
return iace.ComponentTypeFirmware
case t == "has_ai" || t == "ai_model":
return iace.ComponentTypeAIModel
case t == "hmi" || t == "display" || t == "touchscreen":
return iace.ComponentTypeHMI
case t == "sensor" || t == "camera":
return iace.ComponentTypeSensor
case t == "electric_motor" || t == "electric_drive":
return iace.ComponentTypeElectrical
case t == "networked" || t == "ethernet" || t == "wifi":
return iace.ComponentTypeNetwork
case t == "hydraulic" || t == "pneumatic":
return iace.ComponentTypeActuator
}
}
return iace.ComponentTypeMechanical
}
// extractOperationalStatesFromMetadata reads the explicit operational_states
// selection that the user set via the Betriebszustand-UI.
func extractOperationalStatesFromMetadata(metadata json.RawMessage) []string {
if metadata == nil {
return nil
}
var meta map[string]json.RawMessage
if err := json.Unmarshal(metadata, &meta); err != nil {
return nil
}
raw, ok := meta["operational_states"]
if !ok {
return nil
}
var states []string
if err := json.Unmarshal(raw, &states); err != nil {
return nil
}
return states
}
// mergeStringSlices merges two string slices, deduplicating entries.
func mergeStringSlices(a, b []string) []string {
seen := make(map[string]bool, len(a)+len(b))
var result []string
for _, s := range a {
if !seen[s] {
seen[s] = true
result = append(result, s)
}
}
for _, s := range b {
if !seen[s] {
seen[s] = true
result = append(result, s)
}
}
return result
}
// extractIndustrySectorsFromMetadata reads the industry_sectors selection
// from project metadata and maps them to MachineTypes for pattern filtering.
func extractIndustrySectorsFromMetadata(metadata json.RawMessage) []string {
if metadata == nil {
return nil
}
var meta map[string]json.RawMessage
if err := json.Unmarshal(metadata, &meta); err != nil {
return nil
}
limitsRaw, ok := meta["limits_form"]
if !ok {
return nil
}
var limits map[string]json.RawMessage
if err := json.Unmarshal(limitsRaw, &limits); err != nil {
return nil
}
sectorsRaw, ok := limits["industry_sectors"]
if !ok {
return nil
}
var sectors []string
if err := json.Unmarshal(sectorsRaw, &sectors); err != nil {
return nil
}
labelMap := map[string][]string{
"Allgemeiner Maschinenbau": {"general_industry"},
"Automobil / Zulieferer": {"automotive"},
"Robotik / Cobot": {"robotics_cobot", "cobot"},
"Medizintechnik": {"medical_device", "infusion_pump", "ventilator", "patient_monitor"},
"Lebensmittel / Getraenke": {"food_processing"},
"Verpackung": {"packaging"},
"Pharma / Chemie": {"chemical", "pharmaceutical"},
"Bau / Baumaschinen": {"construction", "crane", "excavator"},
"Forst / Holzbearbeitung": {"forestry", "woodworking", "circular_saw"},
"Aufzuege / Foerdertechnik": {"elevator", "lift", "escalator", "conveyor"},
"Textil": {"textile", "spinning", "weaving", "finishing"},
"Landmaschinen": {"agricultural", "tractor", "harvester"},
"Druck / Papier": {"printing"},
"Metall / CNC": {"cnc", "metalworking", "lathe", "milling"},
"Schweissen / Oberflaechentechnik": {"welding", "surface_treatment"},
}
var result []string
seen := make(map[string]bool)
for _, sector := range sectors {
for _, mt := range labelMap[sector] {
if !seen[mt] {
seen[mt] = true
result = append(result, mt)
}
}
}
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),
)
}
// genericSafetyTerms are words that appear in almost all risk assessments
// and should NOT be used to determine machine-specificity.
var genericSafetyTerms = map[string]bool{
"maschine": true, "anlage": true, "bereich": true, "gesamte": true,
"arbeitsplatz": true, "gefahrbereich": true, "gefahrstelle": true,
"gefahrenstelle": true, "person": true, "werker": true, "bediener": true,
"steuerung": true, "schutzeinrichtung": true, "sicherheit": true,
"betrieb": true, "wartung": true, "instandhaltung": true, "reinigung": true,
"bewegung": true, "beweglich": true, "feststehend": true, "teil": true,
"teile": true, "oeffnung": true, "zugang": true, "gefahr": true,
"verletzung": true, "quetsch": true, "scher": true, "schneid": true,
"stoss": true, "schlag": true, "einzug": true, "brand": true,
"motor": true, "antrieb": true, "achse": true, "achsen": true,
"kabel": true, "leitung": true, "schaltschrank": true, "spannung": true,
"schutz": true, "gehaeuse": true, "oberflaeche": true, "boden": true,
"leitfaehig": true, "elektrisch": true, "mechanisch": true,
"bedienfeld": true, "display": true, "anzeige": true,
"energie": true, "druck": true, "temperatur": true,
// Abbreviations and synonyms that should not trigger relevance filter
"kss": true, "emv": true, "esd": true, "dcs": true, "plr": true, "sil": true,
"hmi": true, "sps": true, "rcd": true, "loto": true, "psa": true,
// Common action words
"bersten": true, "platzen": true, "abspringen": true, "spritzen": true,
"einatmen": true, "ausrutschen": true, "herabfallen": true,
"durchschlaegen": true, "wegschleudern": true,
// Common structural terms that don't indicate a specific machine
"gesamter": true, "gesamtes": true, "bereichs": true, "stelle": true,
"innen": true, "aussen": true, "transport": true, "seite": true,
"front": true, "rueck": true, "ober": true, "unter": true,
"fuehrung": true, "lager": true, "verschleiss": true, "welle": true,
"getriebe": true, "kette": true, "riemen": true, "feder": true,
"spindel": true, "werkzeug": true, "werkstueck": true, "flucht": true,
}
// isPatternRelevant checks whether a pattern match is relevant to the actual
// machine described in the narrative. Uses narrative vocabulary overlap:
// if the pattern's zone/scenario contains machine-specific words (not generic
// safety terms) and NONE of them appear in the narrative → irrelevant.
func isPatternRelevant(mp iace.PatternMatch, narrative string, compNames []string) bool {
patternText := iace.NormalizeDEPublic(mp.ZoneDE + " " + mp.ScenarioDE + " " + mp.PatternName)
narrativeNorm := iace.NormalizeDEPublic(narrative)
// Extract machine-specific words from pattern (not generic safety terms)
patternWords := strings.Fields(patternText)
var specificWords []string
for _, w := range patternWords {
// Clean punctuation
w = strings.Trim(w, ".,;:!?()/-")
if len(w) < 5 || genericSafetyTerms[w] {
continue
}
specificWords = append(specificWords, w)
}
// If pattern has no specific words, it's generic → always relevant
if len(specificWords) == 0 {
return true
}
// Check if at least one specific word appears in the narrative or components
for _, sw := range specificWords {
if strings.Contains(narrativeNorm, sw) {
return true
}
for _, cn := range compNames {
if strings.Contains(cn, sw) {
return true
}
}
}
// No specific word found in narrative → pattern is for a different machine
return false
}
// categoryHazardCap returns the maximum number of hazards to generate per category.
// Caps are based on typical ISO 12100 risk assessment proportions:
// - Core physical categories (mechanical, electrical): scale with component count
// - Secondary categories (thermal, noise, material): smaller fixed caps
// - Software/IT/organizational categories: minimal (these are usually covered by
// other standards like IEC 62443, not ISO 12100 machinery risk assessment)
func categoryHazardCap(cat string, componentCount int) int {
// Core machinery hazard categories — scale with complexity
switch cat {
case "mechanical_hazard":
// Typically 1-3 hazards per component (quetschen, scheren, stoss...)
cap := componentCount * 3
if cap < 15 {
cap = 15
}
if cap > 60 {
cap = 60
}
return cap
case "electrical_hazard":
// Typically 8-15 for a standard machine
cap := componentCount
if cap < 8 {
cap = 8
}
if cap > 20 {
cap = 20
}
return cap
case "pneumatic_hydraulic":
return 8
case "thermal_hazard":
return 6
case "noise_vibration":
return 4
case "material_environmental":
return 6
case "ergonomic", "ergonomic_hazard":
return 4
case "fire_explosion":
return 4
case "radiation_hazard", "emc_hazard":
return 3
// Software/IT/organizational — minimal for machinery assessment
case "safety_function_failure":
return 5
case "software_fault":
return 3
case "configuration_error":
return 3
case "hmi_error":
return 3
case "maintenance_hazard":
return 4
case "mode_confusion":
return 2
default:
return 3
}
}
// normalizeZoneKey reduces a zone string to its core components for better dedup.
// E.g. "Schaltschrank, Sammelschiene" and "Schaltschrank-Innenraum, Sammelschienen"
// should dedup to the same key.
func normalizeZoneKey(zone string) string {
if zone == "" {
return ""
}
norm := iace.NormalizeDEPublic(zone)
// Remove filler words and punctuation
for _, r := range []string{",", "/", "(", ")", "-", ".", ":", ";"} {
norm = strings.ReplaceAll(norm, r, " ")
}
// Extract significant words (>3 chars), sort for stable key
words := strings.Fields(norm)
var sig []string
seen := make(map[string]bool)
stopWords := map[string]bool{
"der": true, "die": true, "das": true, "und": true, "oder": true,
"von": true, "des": true, "den": true, "dem": true, "ein": true,
"eine": true, "fuer": true, "bei": true, "mit": true, "nach": true,
"alle": true, "aller": true, "allem": true, "sowie": true,
"insbesondere": true, "bereich": true, "gesamte": true, "gesamter": true,
"innerhalb": true, "ausserhalb": true, "umgebung": true,
}
for _, w := range words {
if len(w) < 4 || stopWords[w] || seen[w] {
continue
}
seen[w] = true
sig = append(sig, w)
}
if len(sig) == 0 {
return norm
}
// Take first 3 significant words as key (enough for dedup)
if len(sig) > 3 {
sig = sig[:3]
}
return strings.Join(sig, "_")
}
// findHazardsForMeasureByCategory finds all hazards matching a measure's category.
func findHazardsForMeasureByCategory(measureCat string, hazardsByCategory map[string][]uuid.UUID) []uuid.UUID {
if ids, ok := hazardsByCategory[measureCat]; ok {
return ids
}
for cat, ids := range hazardsByCategory {
if len(measureCat) > 3 && len(cat) > 3 && cat[:4] == measureCat[:4] {
return ids
}
}
return nil
}