feat(iace): cross-domain precision overhaul + component review + schema reconcile

Engine precision (stop foreign-machine patterns leaking into a project):
- Wire project.MachineType into the engine machine-type gate (empty input no
  longer fires every machine class — press/cnc/excavator/crane/medical...).
- Capability-domain gating extended by 7 domains (outdoor, ventilation,
  machining, bulk, palletizer, playground, fitness) so domain-specific hazards
  only fire when the narrative names that domain; emitted via keyword_dictionary.
- Relevance backstop moved into iace (single gating contract, testable), and its
  dominant false-anchor class removed (a long pattern word no longer matches a
  short common token; prepositions/leitung added to the generic stoplist).
- New guard tests: TestCrossDomainPrecision (full pipeline, 0 foreign per GT) and
  TestPatternReachability now asserts 0 dead patterns. Both GTs keep coverage 1.0.

Reachability fix: the 51 dead patterns required electrical/pneumatic/hydraulic
tags nothing produced — renamed to the canonical electrical_energy/
pneumatic_pressure/hydraulic_pressure/hydraulic_part.

Component review (negation is best-effort + expert-correctable):
- Parser surfaces negated components (ComponentMatch.Negated) instead of dropping
  them; negated contribute no tags/energy → no phantom hazards.
- presence_status (vorhanden|nicht_vorhanden|geloescht) + ce_marked on components;
  only `vorhanden` feed matching. CE+safety-relevant flags the PL/SIL obligation.
- Force re-seed preserves the expert's component decisions instead of wiping them.
- Tag-based component→hazard assignment (was: all on the first component).
- Negation-aware narrative parsing ("keine Pneumatik" no longer extracts it).

Local-dev DB: ai-sdk sets search_path=compliance,core,public; reconcile migrations
152-156 bring the consolidated local iace tables to the current schema + add the
presence_status/ce_marked columns. Machine-type vocabulary endpoint for the form.

[migration-approved]

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-06-10 17:15:55 +02:00
parent 3bd4e0aaaf
commit afb3f83f30
47 changed files with 1275 additions and 169 deletions
@@ -0,0 +1,86 @@
package handlers
import (
"github.com/breakpilot/ai-compliance-sdk/internal/iace"
"github.com/google/uuid"
)
// componentReviewDecision captures an expert's manual review of an auto-detected
// component (presence move, CE marking, safety relevance) so a force re-seed can
// restore it instead of wiping it. Keyed by normalised component name — the
// deterministic narrative parser re-derives the same names.
type componentReviewDecision struct {
presence string
ceMarked bool
safetyRelevant bool
}
// resolvePresence returns the presence_status for a freshly parsed component:
// the expert's prior decision wins; otherwise the engine's negation verdict
// (negated → nicht_vorhanden, else vorhanden).
func resolvePresence(dec componentReviewDecision, hasDecision, negated bool) string {
if hasDecision && dec.presence != "" {
return dec.presence
}
if negated {
return iace.PresenceAbsent
}
return iace.PresencePresent
}
// pickComponentForPattern links a matched hazard pattern to the project
// component that most plausibly causes it.
//
// matchedTags are the component/energy tags that actually made the pattern fire
// (RequiredComponentTags + RequiredEnergyTags satisfied by the machine). The
// project component whose library tags overlap them best is treated as the
// cause. Falls back to zone-name overlap, then to the default (first) component.
//
// Without this, every hazard defaulted to the first component, so the knowledge
// graph drew all "erzeugt" edges from a single node.
func pickComponentForPattern(
matchedTags []string,
zoneDE string,
parseComps []iace.ComponentMatch,
compByName map[string]uuid.UUID,
defaultCompID uuid.UUID,
) uuid.UUID {
if len(matchedTags) > 0 {
want := make(map[string]bool, len(matchedTags))
for _, t := range matchedTags {
want[t] = true
}
bestScore := 0
bestID := uuid.Nil
for _, c := range parseComps {
id, ok := compByName[iace.NormalizeDEPublic(c.NameDE)]
if !ok {
continue
}
score := 0
for _, t := range c.Tags {
if want[t] {
score++
}
}
if score > bestScore {
bestScore = score
bestID = id
}
}
if bestScore > 0 {
return bestID
}
}
if zoneDE != "" {
zoneNorm := iace.NormalizeDEPublic(zoneDE)
for cName, cID := range compByName {
if containsSubstring(zoneNorm, cName) || containsSubstring(cName, zoneNorm) {
return cID
}
}
}
return defaultCompID
}
@@ -0,0 +1,32 @@
package handlers
import (
"testing"
"github.com/breakpilot/ai-compliance-sdk/internal/iace"
)
func TestResolvePresence(t *testing.T) {
none := componentReviewDecision{}
tests := []struct {
name string
dec componentReviewDecision
has bool
negated bool
want string
}{
{"no decision, present", none, false, false, iace.PresencePresent},
{"no decision, negated", none, false, true, iace.PresenceAbsent},
{"expert un-negated wins", componentReviewDecision{presence: iace.PresencePresent}, true, true, iace.PresencePresent},
{"expert negated wins", componentReviewDecision{presence: iace.PresenceAbsent}, true, false, iace.PresenceAbsent},
{"expert deleted persists", componentReviewDecision{presence: iace.PresenceDeleted}, true, false, iace.PresenceDeleted},
{"empty decision falls back to engine", componentReviewDecision{presence: ""}, true, true, iace.PresenceAbsent},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := resolvePresence(tt.dec, tt.has, tt.negated); got != tt.want {
t.Errorf("got %q, want %q", got, tt.want)
}
})
}
}
@@ -0,0 +1,43 @@
package handlers
import (
"testing"
"github.com/breakpilot/ai-compliance-sdk/internal/iace"
"github.com/google/uuid"
)
func TestPickComponentForPattern(t *testing.T) {
motor := uuid.New()
hubwerk := uuid.New()
def := uuid.New()
parseComps := []iace.ComponentMatch{
{NameDE: "Elektromotor (Drehstrom)", Tags: []string{"electrical", "rotating_part"}},
{NameDE: "Hubwerk", Tags: []string{"moving_part", "crush_point"}},
}
compByName := map[string]uuid.UUID{
iace.NormalizeDEPublic("Elektromotor (Drehstrom)"): motor,
iace.NormalizeDEPublic("Hubwerk"): hubwerk,
}
tests := []struct {
name string
matchedTags []string
zone string
want uuid.UUID
}{
{"electrical tag → motor", []string{"electrical"}, "", motor},
{"crush tags → hubwerk", []string{"crush_point", "moving_part"}, "", hubwerk},
{"no overlap → default", []string{"unknown_tag"}, "", def},
{"zone fallback → hubwerk", nil, "Gefahr am Hubwerk-Bereich", hubwerk},
{"nothing → default", nil, "", def},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := pickComponentForPattern(tt.matchedTags, tt.zone, parseComps, compByName, def)
if got != tt.want {
t.Errorf("got %v, want %v", got, tt.want)
}
})
}
}
@@ -47,6 +47,11 @@ func (h *IACEHandler) InitializeProject(c *gin.Context) {
steps := make([]InitStep, 0, 6)
// Phase 4: preserve the expert's component review across a force re-seed.
// Captured (by normalised name) before deletion, re-applied after the fresh
// parse so manual presence moves, CE marks and safety flags are not wiped.
componentDecisions := make(map[string]componentReviewDecision)
// ── Step 0 (optional): Clear existing data for force re-init ──
if forceReinit {
cleared := 0
@@ -62,6 +67,19 @@ func (h *IACEHandler) InitializeProject(c *gin.Context) {
cleared++
}
}
// Also clear components so the fresh narrative parse re-creates them —
// but first remember the expert's review so it survives the re-seed.
if comps, _ := h.store.ListComponents(ctx, projectID); len(comps) > 0 {
for _, cp := range comps {
componentDecisions[iace.NormalizeDEPublic(cp.Name)] = componentReviewDecision{
presence: cp.PresenceStatus,
ceMarked: cp.CEMarked,
safetyRelevant: cp.IsSafetyRelevant,
}
_ = h.store.DeleteComponent(ctx, cp.ID)
cleared++
}
}
steps = append(steps, InitStep{Name: "Alte Daten geloescht", Status: "done", Count: cleared})
}
@@ -90,11 +108,15 @@ func (h *IACEHandler) InitializeProject(c *gin.Context) {
created := 0
for _, comp := range parseResult.Components {
compType := deriveComponentType(comp.Tags)
dec, hasDecision := componentDecisions[iace.NormalizeDEPublic(comp.NameDE)]
_, cerr := h.store.CreateComponent(ctx, iace.CreateComponentRequest{
ProjectID: projectID,
Name: comp.NameDE,
ComponentType: compType,
Description: "Auto-erkannt aus Maschinenbeschreibung (" + comp.MatchedOn + ")",
ProjectID: projectID,
Name: comp.NameDE,
ComponentType: compType,
Description: "Auto-erkannt aus Maschinenbeschreibung (" + comp.MatchedOn + ")",
PresenceStatus: resolvePresence(dec, hasDecision, comp.Negated),
CEMarked: dec.ceMarked,
IsSafetyRelevant: dec.safetyRelevant,
})
if cerr == nil {
created++
@@ -110,6 +132,10 @@ func (h *IACEHandler) InitializeProject(c *gin.Context) {
// ── Step 4: Fire pattern engine ──
var componentIDs, energyIDs []string
for _, comp := range parseResult.Components {
dec, hasDecision := componentDecisions[iace.NormalizeDEPublic(comp.NameDE)]
if resolvePresence(dec, hasDecision, comp.Negated) != iace.PresencePresent {
continue // negated/deleted (engine verdict OR expert decision) → no matching
}
componentIDs = append(componentIDs, comp.LibraryID)
}
for _, e := range parseResult.EnergySources {
@@ -118,6 +144,14 @@ func (h *IACEHandler) InitializeProject(c *gin.Context) {
operationalStates := mergeStringSlices(parseResult.OperationalStates, extractOperationalStatesFromMetadata(project.Metadata))
machineTypes := extractIndustrySectorsFromMetadata(project.Metadata)
// The project's own machine type MUST reach the engine's machine-type gate,
// otherwise (empty input) every pattern scoped to a foreign machine class
// fires — press, CNC, excavator, crane, road-roller, medical ... For a lift
// this floods the result with cross-domain nonsense. With it set, those
// patterns are gated out; only this machine class + ungated patterns remain.
if project.MachineType != "" {
machineTypes = append(machineTypes, project.MachineType)
}
engine := iace.NewPatternEngine()
matchOutput := engine.Match(iace.MatchInput{
@@ -143,7 +177,15 @@ func (h *IACEHandler) InitializeProject(c *gin.Context) {
hazardPatternMeasures := make(map[uuid.UUID][]string)
if len(existingHazards) == 0 && len(matchOutput.MatchedPatterns) > 0 {
comps, _ := h.store.ListComponents(ctx, projectID)
allComps, _ := h.store.ListComponents(ctx, projectID)
// Hazards only attach to PRESENT components — negated/deleted ones are
// surfaced for review but never own a generated hazard.
comps := make([]iace.Component, 0, len(allComps))
for _, c := range allComps {
if c.PresenceStatus == iace.PresencePresent || c.PresenceStatus == "" {
comps = append(comps, c)
}
}
var defaultCompID uuid.UUID
compByName := make(map[string]uuid.UUID)
if len(comps) > 0 {
@@ -164,7 +206,7 @@ func (h *IACEHandler) InitializeProject(c *gin.Context) {
catCount := make(map[string]int)
for _, mp := range matchOutput.MatchedPatterns {
// Narrative relevance filter
if !isPatternRelevant(mp, narrativeText, compNames) {
if !iace.IsPatternRelevant(mp, narrativeText, compNames) {
continue
}
@@ -198,16 +240,7 @@ func (h *IACEHandler) InitializeProject(c *gin.Context) {
name = name + " (" + mp.ZoneDE + ")"
}
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
}
}
}
compID := pickComponentForPattern(mp.MatchedTags, mp.ZoneDE, parseResult.Components, compByName, defaultCompID)
// Join all applicable lifecycles as comma-separated string
lifecycleStr := strings.Join(mp.ApplicableLifecycles, ",")
@@ -339,81 +339,6 @@ func containsSubstring(haystack, needle string) bool {
)
}
// 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
@@ -0,0 +1,21 @@
package handlers
import (
"net/http"
"github.com/breakpilot/ai-compliance-sdk/internal/iace"
"github.com/gin-gonic/gin"
)
// ListMachineTypes handles GET /machine-types
// Returns the controlled machine-type vocabulary the pattern engine gates on,
// each with a German label + UI group. The project-create form uses this so the
// machine type always matches the engine vocabulary (no free-text flood or
// under-coverage from a value that matches no pattern).
func (h *IACEHandler) ListMachineTypes(c *gin.Context) {
types := iace.MachineTypeVocabulary()
c.JSON(http.StatusOK, gin.H{
"machine_types": types,
"total": len(types),
})
}
+20 -1
View File
@@ -39,7 +39,26 @@ func Run() {
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, cfg.DatabaseURL)
poolCfg, err := pgxpool.ParseConfig(cfg.DatabaseURL)
if err != nil {
log.Fatalf("Failed to parse database URL: %v", err)
}
// The iace/compliance tables live in the `compliance` schema (see CLAUDE.md:
// "DB search_path: compliance,core,public"). Set it explicitly so the
// connection does not silently resolve to `public` (an empty/legacy schema)
// when the URL carries no search_path — as happened on the local dev DB.
// Only set when not already specified in the URL, so prod stays untouched.
if poolCfg.ConnConfig.RuntimeParams == nil {
poolCfg.ConnConfig.RuntimeParams = map[string]string{}
}
if poolCfg.ConnConfig.RuntimeParams["search_path"] == "" {
searchPath := os.Getenv("DB_SEARCH_PATH")
if searchPath == "" {
searchPath = "compliance,core,public"
}
poolCfg.ConnConfig.RuntimeParams["search_path"] = searchPath
}
pool, err := pgxpool.NewWithConfig(ctx, poolCfg)
if err != nil {
log.Fatalf("Failed to connect to database: %v", err)
}
@@ -22,6 +22,7 @@ func registerIACERoutes(v1 *gin.RouterGroup, h *handlers.IACEHandler) {
iaceRoutes.GET("/norms-library/crossref", h.ListNormCrossRefs)
iaceRoutes.GET("/norms-library/:id/crossref", h.GetNormCrossRef)
iaceRoutes.GET("/lifecycle-phases", h.ListLifecyclePhases)
iaceRoutes.GET("/machine-types", h.ListMachineTypes)
iaceRoutes.GET("/roles", h.ListRoles)
iaceRoutes.GET("/evidence-types", h.ListEvidenceTypes)
iaceRoutes.GET("/protective-measures-library", h.ListProtectiveMeasures)
@@ -94,7 +94,7 @@ func extractCitedNorms(hz []Hazard, mt []Mitigation) []string {
seen := make(map[string]bool)
consider := func(s string) {
fields := strings.FieldsFunc(s, func(r rune) bool {
return r == ' ' || r == ',' || r == ';' || r == '\n' || r == ';' || r == '('
return r == ' ' || r == ',' || r == ';' || r == '\n' || r == '('
})
for i := 0; i < len(fields)-1; i++ {
head := strings.ToUpper(strings.TrimSpace(fields[i]))
@@ -214,6 +214,27 @@ var foreignDomainTerms = map[string]string{
"schweissen": "welding", "lichtbogenschweiss": "welding",
"rolltreppe": "escalator", "fahrtreppe": "escalator",
"spinnerei ": "textile", "extrusion": "plastics",
// construction / mobile machinery
"radlader": "construction", "bagger": "construction", "mobilkran": "crane",
"betonpump": "construction", "strassenwalze": "construction", "strassenbau": "construction",
// press / forming tool space
"werkzeugeinbauraum": "press", "stoessel": "press", "oberwerkzeug": "press",
"unterwerkzeug": "press", "abfuellstempel": "filling",
// machining coolant
"kss-": "machining", "kuehlschmierstoff": "machining",
// confined space / bulk material
"silo": "bulk", "gaerbehaelter": "bulk", "getreidesilo": "bulk", "mehlsilo": "bulk",
"schuettgut": "bulk", "sauerstoffmangel": "confined_space", "erstickung": "confined_space",
// medical
"patient": "medical", "sterilis": "medical", "defibrill": "medical",
// outdoor / biological / cold
"zecke": "outdoor", "hantavirus": "outdoor", "schimmel": "environmental",
"nagerkot": "outdoor", "winterarbeit": "outdoor", "tiefkuehl": "cold", "unterkuehl": "cold",
// playground / fitness
"klettergeraet": "playground", "spielplatz": "playground", "kraftstation": "fitness",
"bankdrueck": "fitness", "kniebeug": "fitness",
// palletizer
"palettierer": "palletizer",
}
// TestGT_DomainLeakage names the patterns that leak across domains. For each GT
@@ -117,7 +117,7 @@ func GetAGVAgriPatterns() []HazardPattern {
{
ID: "HP206", NameDE: "Batteriebrand im AGV", NameEN: "Battery fire in AGV",
RequiredComponentTags: []string{"agv", "battery"},
RequiredEnergyTags: []string{"electrical"},
RequiredEnergyTags: []string{"electrical_energy"},
GeneratedHazardCats: []string{"thermal_hazard", "material_environmental"},
SuggestedMeasureIDs: []string{"M124", "M141"},
SuggestedEvidenceIDs: []string{"E01", "E20"},
@@ -132,7 +132,7 @@ func GetAGVAgriPatterns() []HazardPattern {
{
ID: "HP207", NameDE: "Quetschen beim automatischen Laden", NameEN: "Crushing during automatic charging",
RequiredComponentTags: []string{"agv", "pinch_point", "battery"},
RequiredEnergyTags: []string{"electrical"},
RequiredEnergyTags: []string{"electrical_energy"},
GeneratedHazardCats: []string{"mechanical_hazard", "electrical_hazard"},
SuggestedMeasureIDs: []string{"M001", "M054"},
SuggestedEvidenceIDs: []string{"E01", "E08"},
@@ -193,7 +193,7 @@ func GetAGVAgriPatterns() []HazardPattern {
{
ID: "HP211", NameDE: "EMV-Stoerung deaktiviert AGV-Sicherheit", NameEN: "EMI disables AGV safety systems",
RequiredComponentTags: []string{"agv", "sensor_part", "electrical_part"},
RequiredEnergyTags: []string{"electrical"},
RequiredEnergyTags: []string{"electrical_energy"},
GeneratedHazardCats: []string{"electrical_hazard", "safety_function_failure"},
SuggestedMeasureIDs: []string{"M478", "M479", "M141"},
SuggestedEvidenceIDs: []string{"E01"},
@@ -276,7 +276,7 @@ func GetCNCHazardPatterns() []HazardPattern {
{
ID: "HP1418", NameDE: "Elektrischer Schlag durch Schweissgeraet", NameEN: "Electric shock from welding equipment",
RequiredComponentTags: []string{"welding_equipment"},
RequiredEnergyTags: []string{"electrical"},
RequiredEnergyTags: []string{"electrical_energy"},
GeneratedHazardCats: []string{"electrical_hazard"},
SuggestedMeasureIDs: []string{"M087", "M090", "MN025"},
SuggestedEvidenceIDs: []string{"E01", "E10"},
@@ -211,7 +211,7 @@ func GetCNCHazardPatternsExt() []HazardPattern {
{
ID: "HP1434", NameDE: "Restkuehlmittel tropft auf elektrische Komponenten", NameEN: "Residual coolant dripping on electrical components",
RequiredComponentTags: []string{"cutting_tool"},
RequiredEnergyTags: []string{"electrical"},
RequiredEnergyTags: []string{"electrical_energy"},
GeneratedHazardCats: []string{"electrical_hazard"},
SuggestedMeasureIDs: []string{"M087", "M274"},
SuggestedEvidenceIDs: []string{"E01", "E10"},
@@ -101,7 +101,7 @@ func GetCyberExtendedPatterns() []HazardPattern {
{
ID: "HP806", NameDE: "Datenverlust nach Spannungsausfall", NameEN: "Data loss after power failure",
RequiredComponentTags: []string{"has_software"},
RequiredEnergyTags: []string{"electrical"},
RequiredEnergyTags: []string{"electrical_energy"},
GeneratedHazardCats: []string{"software_fault"},
SuggestedMeasureIDs: []string{"M103", "M141"},
SuggestedEvidenceIDs: []string{"E01", "E14"},
@@ -164,7 +164,7 @@ func GetElevatorPatterns() []HazardPattern {
ID: "HP183", NameDE: "Elektrischer Schlag im Triebwerksraum", NameEN: "Electric shock in machine room",
MachineTypes: []string{"elevator", "lift", "escalator"},
RequiredComponentTags: []string{"elevator_traction", "electrical_part"},
RequiredEnergyTags: []string{"electrical"},
RequiredEnergyTags: []string{"electrical_energy"},
GeneratedHazardCats: []string{"electrical_hazard"},
SuggestedMeasureIDs: []string{"M051", "M054", "M141"},
SuggestedEvidenceIDs: []string{"E01", "E08"},
@@ -56,7 +56,7 @@ func GetFinalPatternsB() []HazardPattern {
{
ID: "HP1089", NameDE: "Elektrostatische Entladung", NameEN: "Electrostatic discharge",
RequiredComponentTags: []string{"electrical_part"},
RequiredEnergyTags: []string{"electrical"},
RequiredEnergyTags: []string{"electrical_energy"},
GeneratedHazardCats: []string{"electrical_hazard"},
SuggestedMeasureIDs: []string{"M088", "M329", "M141"},
SuggestedEvidenceIDs: []string{"E01"},
@@ -190,7 +190,7 @@ func GetFinalPatternsC() []HazardPattern {
{
ID: "HP1185", NameDE: "Sensor-Kurzschluss durch Feuchtigkeit", NameEN: "Sensor short by moisture",
RequiredComponentTags: []string{"sensor_part", "electrical_part"},
RequiredEnergyTags: []string{"electrical"},
RequiredEnergyTags: []string{"electrical_energy"},
GeneratedHazardCats: []string{"sensor_fault", "electrical_hazard"},
SuggestedMeasureIDs: []string{"M119", "M214", "M141"},
SuggestedEvidenceIDs: []string{"E01", "E06"},
@@ -971,7 +971,7 @@ func GetFinalPatternsD() []HazardPattern {
{
ID: "HP1334", NameDE: "Statische Aufladung Schuettgut", NameEN: "Static charge bulk material",
RequiredComponentTags: []string{"chemical_risk", "structural_part"},
RequiredEnergyTags: []string{"electrical"},
RequiredEnergyTags: []string{"electrical_energy"},
GeneratedHazardCats: []string{"fire_explosion", "electrical_hazard"},
SuggestedMeasureIDs: []string{"M088", "M329", "M385", "M141"},
SuggestedEvidenceIDs: []string{"E01", "E06"},
@@ -56,7 +56,7 @@ func GetGTBremseHazardPatterns() []HazardPattern {
{
ID: "HP1712", NameDE: "Augen-/Hautverletzung durch Druckluft-Reinigungsduese in Bearbeitungszelle", NameEN: "Eye/skin injury from compressed-air cleaning nozzle in machining cell",
RequiredComponentTags: []string{},
RequiredEnergyTags: []string{"pneumatic"},
RequiredEnergyTags: []string{"pneumatic_pressure"},
GeneratedHazardCats: []string{"mechanical_hazard"},
SuggestedMeasureIDs: []string{"M504", "M505", "M501"},
Priority: 97,
@@ -137,7 +137,7 @@ func GetGTBremseHazardPatterns() []HazardPattern {
{
ID: "HP1716", NameDE: "Kurzschluss/Brand durch Reinigung am elektrisch aktiven Schaltschrank", NameEN: "Short circuit/fire from cleaning at live cabinet",
RequiredComponentTags: []string{"electrical_part"},
RequiredEnergyTags: []string{"electrical"},
RequiredEnergyTags: []string{"electrical_energy"},
GeneratedHazardCats: []string{"electrical_hazard"},
SuggestedMeasureIDs: []string{"M521", "M522", "M539"},
Priority: 96,
@@ -158,7 +158,7 @@ func GetGTBremseHazardPatterns() []HazardPattern {
{
ID: "HP1717", NameDE: "Verletzung durch unvermittelt austretende pneumatische Restenergie", NameEN: "Injury from unexpectedly released pneumatic stored energy",
RequiredComponentTags: []string{"stored_energy"},
RequiredEnergyTags: []string{"pneumatic"},
RequiredEnergyTags: []string{"pneumatic_pressure"},
GeneratedHazardCats: []string{"mechanical_hazard"},
SuggestedMeasureIDs: []string{"M485", "M534", "M527"},
Priority: 96,
@@ -13,7 +13,7 @@ func GetISO12100GapPatterns() []HazardPattern {
{
ID: "HP1900", NameDE: "Verletzung durch Vakuum (Vakuumgreifer / Saug-Anlage)", NameEN: "Vacuum injury (suction/vacuum equipment)",
RequiredComponentTags: []string{"clamping_part"},
RequiredEnergyTags: []string{"pneumatic"},
RequiredEnergyTags: []string{"pneumatic_pressure"},
GeneratedHazardCats: []string{"mechanical_hazard"},
SuggestedMeasureIDs: []string{"M061", "M002", "M141"},
Priority: 90,
@@ -112,7 +112,7 @@ func GetMaintenanceExtPatterns() []HazardPattern {
HarmDE: "Quetschung der Fuesse", AffectedDE: "Einrichter", ZoneDE: "Spannbereich",
DefaultSeverity: 3, DefaultExposure: 4},
{ID: "HP715", NameDE: "Stromschlag bei Steckerwechsel", NameEN: "Shock during connector change",
RequiredComponentTags: []string{"electrical_part"}, RequiredEnergyTags: []string{"electrical"},
RequiredComponentTags: []string{"electrical_part"}, RequiredEnergyTags: []string{"electrical_energy"},
RequiredLifecycles: []string{"setup"}, GeneratedHazardCats: []string{"electrical_hazard"},
SuggestedMeasureIDs: []string{"M522", "M539", "M518", "M141"}, SuggestedEvidenceIDs: []string{"E09"}, Priority: 65,
ScenarioDE: "Steckverbinder unter Spannung gewechselt", TriggerDE: "Nicht spannungsfrei",
@@ -312,7 +312,7 @@ func GetMaintenanceExtPatterns() []HazardPattern {
DefaultSeverity: 3, DefaultExposure: 2},
// — Reinigung (HP913-HP917) —
{ID: "HP913", NameDE: "Nassreinigung nahe Elektrik", NameEN: "Wet cleaning near electrics",
RequiredComponentTags: []string{"electrical_part"}, RequiredEnergyTags: []string{"electrical"},
RequiredComponentTags: []string{"electrical_part"}, RequiredEnergyTags: []string{"electrical_energy"},
RequiredLifecycles: []string{"cleaning"}, GeneratedHazardCats: []string{"electrical_hazard"},
SuggestedMeasureIDs: []string{"M082", "M141"}, SuggestedEvidenceIDs: []string{"E09"}, Priority: 75,
ScenarioDE: "Wasser gelangt in Schaltschrank", TriggerDE: "Hochdruckreiniger nahe Elektrik",
@@ -407,7 +407,7 @@ func GetMaintenanceExtPatterns() []HazardPattern {
HarmDE: "Erfassen, Quetschen", AffectedDE: "Pruefpersonal", ZoneDE: "Maschinenarbeitsraum",
DefaultSeverity: 5, DefaultExposure: 2},
{ID: "HP926", NameDE: "Messung an spannungsfuehrender Anlage", NameEN: "Measurement on energized system",
RequiredComponentTags: []string{"electrical_part"}, RequiredEnergyTags: []string{"electrical"},
RequiredComponentTags: []string{"electrical_part"}, RequiredEnergyTags: []string{"electrical_energy"},
RequiredLifecycles: []string{"maintenance"}, GeneratedHazardCats: []string{"electrical_hazard"},
SuggestedMeasureIDs: []string{"M082", "M141"}, SuggestedEvidenceIDs: []string{"E09"}, Priority: 75,
ScenarioDE: "Messung unter Spannung", TriggerDE: "Messgeraet rutscht ab",
@@ -26,7 +26,7 @@ func GetPlasticsMetalPatterns() []HazardPattern {
{
ID: "HP501", NameDE: "Quetschen durch Schliesseinheit Spritzgiessmaschine", NameEN: "Crushing by injection moulding clamping unit",
RequiredComponentTags: []string{"crush_point", "moving_part"},
RequiredEnergyTags: []string{"hydraulic"},
RequiredEnergyTags: []string{"hydraulic_pressure"},
GeneratedHazardCats: []string{"mechanical_hazard"},
SuggestedMeasureIDs: []string{"M003", "M004", "M082"},
SuggestedEvidenceIDs: []string{"E08", "E09"},
@@ -41,7 +41,7 @@ func GetPlasticsMetalPatterns() []HazardPattern {
{
ID: "HP502", NameDE: "Hochdruck-Injektion von Kunststoff in die Hand", NameEN: "High-pressure injection of plastic into hand",
RequiredComponentTags: []string{"high_pressure"},
RequiredEnergyTags: []string{"hydraulic"},
RequiredEnergyTags: []string{"hydraulic_pressure"},
GeneratedHazardCats: []string{"mechanical_hazard"},
SuggestedMeasureIDs: []string{"M003", "M082", "M141"},
SuggestedEvidenceIDs: []string{"E09", "E20"},
@@ -88,7 +88,7 @@ func GetPlasticsMetalPatterns() []HazardPattern {
{
ID: "HP505", NameDE: "Platzen des Blasformwerkzeugs", NameEN: "Blow mould burst",
RequiredComponentTags: []string{"high_pressure", "structural_part"},
RequiredEnergyTags: []string{"pneumatic"},
RequiredEnergyTags: []string{"pneumatic_pressure"},
GeneratedHazardCats: []string{"mechanical_hazard"},
SuggestedMeasureIDs: []string{"M001", "M003", "M141"},
SuggestedEvidenceIDs: []string{"E09", "E20"},
@@ -209,7 +209,7 @@ func GetPlasticsMetalPatterns() []HazardPattern {
{
ID: "HP513", NameDE: "Hydraulikversagen Schliessdruck", NameEN: "Hydraulic failure of clamping pressure",
RequiredComponentTags: []string{"high_pressure"},
RequiredEnergyTags: []string{"hydraulic"},
RequiredEnergyTags: []string{"hydraulic_pressure"},
GeneratedHazardCats: []string{"mechanical_hazard"},
SuggestedMeasureIDs: []string{"M003", "M082", "M141"},
SuggestedEvidenceIDs: []string{"E09", "E20"},
@@ -442,7 +442,7 @@ func GetPlasticsMetalPatterns() []HazardPattern {
{
ID: "HP528", NameDE: "Hydraulikoelnebel in Atemluft", NameEN: "Hydraulic oil mist in breathing air",
RequiredComponentTags: []string{"chemical_risk", "high_pressure"},
RequiredEnergyTags: []string{"hydraulic"},
RequiredEnergyTags: []string{"hydraulic_pressure"},
GeneratedHazardCats: []string{"material_environmental"},
SuggestedMeasureIDs: []string{"M141"},
SuggestedEvidenceIDs: []string{"E20"},
@@ -377,7 +377,7 @@ func GetRobotCellPatterns() []HazardPattern {
{
ID: "HP1641", NameDE: "Gefaehrliche Beruehrungsspannung durch Schutzleiterfehler", NameEN: "Dangerous touch voltage due to PE failure",
RequiredComponentTags: []string{},
RequiredEnergyTags: []string{"electrical"},
RequiredEnergyTags: []string{"electrical_energy"},
GeneratedHazardCats: []string{"electrical_hazard"},
SuggestedMeasureIDs: []string{"M511", "M512", "M514", "M515", "M475"},
Priority: 98,
@@ -394,7 +394,7 @@ func GetRobotCellPatterns() []HazardPattern {
{
ID: "HP1642", NameDE: "Kabelbrand durch Ueberlast oder Kurzschluss", NameEN: "Cable fire from overload or short circuit",
RequiredComponentTags: []string{},
RequiredEnergyTags: []string{"electrical"},
RequiredEnergyTags: []string{"electrical_energy"},
GeneratedHazardCats: []string{"electrical_hazard"},
SuggestedMeasureIDs: []string{"M009"},
Priority: 98,
@@ -11,7 +11,7 @@ func GetSpecificMachinePatterns() []HazardPattern {
{
ID: "HP730", NameDE: "Bersten eines Druckbehaelters", NameEN: "Pressure vessel burst",
RequiredComponentTags: []string{"high_pressure", "structural_part"},
RequiredEnergyTags: []string{"pneumatic"},
RequiredEnergyTags: []string{"pneumatic_pressure"},
GeneratedHazardCats: []string{"mechanical_hazard", "pneumatic_hydraulic"},
SuggestedMeasureIDs: []string{"M003", "M141"},
SuggestedEvidenceIDs: []string{"E01", "E11", "E20"},
@@ -26,7 +26,7 @@ func GetSpecificMachinePatterns() []HazardPattern {
{
ID: "HP731", NameDE: "Dampfaustritt an Flanschverbindung", NameEN: "Steam leak at flange connection",
RequiredComponentTags: []string{"high_pressure", "high_temperature"},
RequiredEnergyTags: []string{"thermal", "pneumatic"},
RequiredEnergyTags: []string{"thermal", "pneumatic_pressure"},
GeneratedHazardCats: []string{"thermal_hazard", "pneumatic_hydraulic"},
SuggestedMeasureIDs: []string{"M005", "M141"},
SuggestedEvidenceIDs: []string{"E10", "E11", "E20"},
@@ -41,7 +41,7 @@ func GetSpecificMachinePatterns() []HazardPattern {
{
ID: "HP732", NameDE: "Sicherheitsventil klemmt unter Druck", NameEN: "Safety valve stuck under pressure",
RequiredComponentTags: []string{"high_pressure", "safety_device"},
RequiredEnergyTags: []string{"pneumatic"},
RequiredEnergyTags: []string{"pneumatic_pressure"},
GeneratedHazardCats: []string{"safety_function_failure", "pneumatic_hydraulic"},
SuggestedMeasureIDs: []string{"M104", "M141"},
SuggestedEvidenceIDs: []string{"E01", "E08", "E11"},
@@ -58,7 +58,7 @@ func GetSpecificMachinePatterns() []HazardPattern {
{
ID: "HP733", NameDE: "Wasserschlag in Rohrleitung", NameEN: "Water hammer in pipeline",
RequiredComponentTags: []string{"high_pressure", "structural_part"},
RequiredEnergyTags: []string{"pneumatic"},
RequiredEnergyTags: []string{"pneumatic_pressure"},
GeneratedHazardCats: []string{"mechanical_hazard", "pneumatic_hydraulic"},
SuggestedMeasureIDs: []string{"M003", "M141"},
SuggestedEvidenceIDs: []string{"E01", "E11"},
@@ -103,7 +103,7 @@ func GetSpecificMachinePatterns() []HazardPattern {
{
ID: "HP736", NameDE: "Druckstoss bei schnellem Ventilschluss", NameEN: "Pressure surge from rapid valve closure",
RequiredComponentTags: []string{"high_pressure"},
RequiredEnergyTags: []string{"pneumatic"},
RequiredEnergyTags: []string{"pneumatic_pressure"},
GeneratedHazardCats: []string{"pneumatic_hydraulic"},
SuggestedMeasureIDs: []string{"M003", "M141"},
SuggestedEvidenceIDs: []string{"E01", "E11"},
@@ -133,7 +133,7 @@ func GetSpecificMachinePatterns() []HazardPattern {
{
ID: "HP738", NameDE: "Bersten des Schauglases am Druckbehaelter", NameEN: "Sight glass burst on pressure vessel",
RequiredComponentTags: []string{"high_pressure"},
RequiredEnergyTags: []string{"pneumatic"},
RequiredEnergyTags: []string{"pneumatic_pressure"},
GeneratedHazardCats: []string{"mechanical_hazard", "pneumatic_hydraulic"},
SuggestedMeasureIDs: []string{"M003", "M005", "M141"},
SuggestedEvidenceIDs: []string{"E01", "E11", "E20"},
@@ -148,7 +148,7 @@ func GetSpecificMachinePatterns() []HazardPattern {
{
ID: "HP739", NameDE: "Dampfkessel Trockenlauf", NameEN: "Boiler dry firing",
RequiredComponentTags: []string{"high_pressure", "high_temperature"},
RequiredEnergyTags: []string{"thermal", "pneumatic"},
RequiredEnergyTags: []string{"thermal", "pneumatic_pressure"},
GeneratedHazardCats: []string{"thermal_hazard", "pneumatic_hydraulic"},
SuggestedMeasureIDs: []string{"M104", "M141"},
SuggestedEvidenceIDs: []string{"E01", "E08", "E11"},
@@ -199,7 +199,7 @@ func GetSpecificMachinePatterns() []HazardPattern {
{
ID: "HP742", NameDE: "Oeluebertritt in Druckluftsystem", NameEN: "Oil carry-over into compressed air system",
RequiredComponentTags: []string{"high_pressure"},
RequiredEnergyTags: []string{"pneumatic"},
RequiredEnergyTags: []string{"pneumatic_pressure"},
GeneratedHazardCats: []string{"material_environmental"},
SuggestedMeasureIDs: []string{"M141"},
SuggestedEvidenceIDs: []string{"E01", "E20"},
@@ -214,7 +214,7 @@ func GetSpecificMachinePatterns() []HazardPattern {
{
ID: "HP743", NameDE: "Druckluftschlauch reisst und peitscht", NameEN: "Compressed air hose rupture and whiplash",
RequiredComponentTags: []string{"high_pressure"},
RequiredEnergyTags: []string{"pneumatic"},
RequiredEnergyTags: []string{"pneumatic_pressure"},
GeneratedHazardCats: []string{"mechanical_hazard", "pneumatic_hydraulic"},
SuggestedMeasureIDs: []string{"M003", "M005", "M141"},
SuggestedEvidenceIDs: []string{"E01", "E11", "E20"},
@@ -229,7 +229,7 @@ func GetSpecificMachinePatterns() []HazardPattern {
{
ID: "HP744", NameDE: "Pulsation in Druckleitung", NameEN: "Pulsation in pressure line",
RequiredComponentTags: []string{"high_pressure", "structural_part"},
RequiredEnergyTags: []string{"pneumatic"},
RequiredEnergyTags: []string{"pneumatic_pressure"},
GeneratedHazardCats: []string{"pneumatic_hydraulic"},
SuggestedMeasureIDs: []string{"M141"},
SuggestedEvidenceIDs: []string{"E01", "E11"},
@@ -278,7 +278,7 @@ func GetSpecificMachinePatterns() []HazardPattern {
{
ID: "HP747", NameDE: "Brand in Gondel einer Windenergieanlage", NameEN: "Fire in wind turbine nacelle",
RequiredComponentTags: []string{"high_temperature", "electrical_part"},
RequiredEnergyTags: []string{"electrical", "thermal"},
RequiredEnergyTags: []string{"electrical_energy", "thermal"},
GeneratedHazardCats: []string{"thermal_hazard", "electrical_hazard"},
SuggestedMeasureIDs: []string{"M141"},
SuggestedEvidenceIDs: []string{"E01", "E10", "E20"},
@@ -308,7 +308,7 @@ func GetSpecificMachinePatterns() []HazardPattern {
{
ID: "HP749", NameDE: "Blitzschlag an Windturbine", NameEN: "Lightning strike on wind turbine",
RequiredComponentTags: []string{"structural_part", "electrical_part"},
RequiredEnergyTags: []string{"electrical"},
RequiredEnergyTags: []string{"electrical_energy"},
GeneratedHazardCats: []string{"electrical_hazard"},
SuggestedMeasureIDs: []string{"M082", "M141"},
SuggestedEvidenceIDs: []string{"E01", "E10", "E20"},
@@ -327,7 +327,7 @@ func GetSpecificMachinePatterns() []HazardPattern {
{
ID: "HP750", NameDE: "Lichtbogen an DC-Steckverbindung", NameEN: "Arc fault at DC connector",
RequiredComponentTags: []string{"electrical_part"},
RequiredEnergyTags: []string{"electrical"},
RequiredEnergyTags: []string{"electrical_energy"},
GeneratedHazardCats: []string{"electrical_hazard", "thermal_hazard"},
SuggestedMeasureIDs: []string{"M082", "M141"},
SuggestedEvidenceIDs: []string{"E01", "E10", "E20"},
@@ -357,7 +357,7 @@ func GetSpecificMachinePatterns() []HazardPattern {
{
ID: "HP752", NameDE: "Elektrischer Schlag DC-Seite (Spannung bei Abschaltung)", NameEN: "DC shock (voltage present even when isolated)",
RequiredComponentTags: []string{"electrical_part", "stored_energy"},
RequiredEnergyTags: []string{"electrical"},
RequiredEnergyTags: []string{"electrical_energy"},
GeneratedHazardCats: []string{"electrical_hazard"},
SuggestedMeasureIDs: []string{"M082", "M141"},
SuggestedEvidenceIDs: []string{"E01", "E10", "E20"},
@@ -376,7 +376,7 @@ func GetSpecificMachinePatterns() []HazardPattern {
{
ID: "HP753", NameDE: "Thermal Runaway bei Lithium-Batterie", NameEN: "Thermal runaway of lithium battery",
RequiredComponentTags: []string{"stored_energy", "high_temperature"},
RequiredEnergyTags: []string{"electrical", "thermal"},
RequiredEnergyTags: []string{"electrical_energy", "thermal"},
GeneratedHazardCats: []string{"thermal_hazard", "electrical_hazard"},
SuggestedMeasureIDs: []string{"M005", "M141"},
SuggestedEvidenceIDs: []string{"E01", "E10", "E20"},
@@ -406,7 +406,7 @@ func GetSpecificMachinePatterns() []HazardPattern {
{
ID: "HP755", NameDE: "Elektrischer Schlag an Hochvolt-Batteriespeicher", NameEN: "Electric shock from high-voltage battery storage",
RequiredComponentTags: []string{"stored_energy", "electrical_part"},
RequiredEnergyTags: []string{"electrical"},
RequiredEnergyTags: []string{"electrical_energy"},
GeneratedHazardCats: []string{"electrical_hazard"},
SuggestedMeasureIDs: []string{"M082", "M141"},
SuggestedEvidenceIDs: []string{"E01", "E10", "E20"},
@@ -90,7 +90,7 @@ func GetSpecificMachinePatterns2() []HazardPattern {
{
ID: "HP761", NameDE: "Ansaugen durch Wassereinlass (Entrapment)", NameEN: "Suction entrapment by pool drain",
RequiredComponentTags: []string{"high_pressure"},
RequiredEnergyTags: []string{"pneumatic"},
RequiredEnergyTags: []string{"pneumatic_pressure"},
GeneratedHazardCats: []string{"mechanical_hazard"},
SuggestedMeasureIDs: []string{"M003", "M141"},
SuggestedEvidenceIDs: []string{"E01", "E08", "E20"},
@@ -120,7 +120,7 @@ func GetSpecificMachinePatterns2() []HazardPattern {
{
ID: "HP763", NameDE: "Elektrischer Schlag im Nassbereich", NameEN: "Electric shock in wet area",
RequiredComponentTags: []string{"electrical_part"},
RequiredEnergyTags: []string{"electrical"},
RequiredEnergyTags: []string{"electrical_energy"},
GeneratedHazardCats: []string{"electrical_hazard"},
SuggestedMeasureIDs: []string{"M082", "M141"},
SuggestedEvidenceIDs: []string{"E01", "E10", "E20"},
@@ -128,7 +128,7 @@ func GetTextileAgriPatterns() []HazardPattern {
HarmDE: "Amputation, schwere Schnittverletzungen", AffectedDE: "Bediener, Wartungspersonal", ZoneDE: "Schneidwerksbereich",
DefaultSeverity: 5, DefaultExposure: 3},
{ID: "HP1568", NameDE: "Hydraulik-Leitungsriss unter Hochdruck", NameEN: "Hydraulic hose burst",
RequiredComponentTags: []string{"hydraulic"}, GeneratedHazardCats: []string{"pneumatic_hydraulic"},
RequiredComponentTags: []string{"hydraulic_part"}, GeneratedHazardCats: []string{"pneumatic_hydraulic"},
SuggestedMeasureIDs: []string{"M466", "M234"}, SuggestedEvidenceIDs: []string{"E01"},
Priority: 88, MachineTypes: []string{"agricultural", "tractor", "harvester"},
OperationalStates: []string{"automatic_operation"},
@@ -191,7 +191,7 @@ func GetTextileAgriPatterns() []HazardPattern {
AffectedDE: "Fahrer", ZoneDE: "Fahrersitz",
DefaultSeverity: 3, DefaultExposure: 5},
{ID: "HP1575", NameDE: "Quetschung durch absenkenden Dreipunktanbau", NameEN: "Crushing by lowering three-point hitch",
RequiredComponentTags: []string{"hydraulic"}, GeneratedHazardCats: []string{"mechanical_hazard"},
RequiredComponentTags: []string{"hydraulic_part"}, GeneratedHazardCats: []string{"mechanical_hazard"},
SuggestedMeasureIDs: []string{"M461", "M474"}, SuggestedEvidenceIDs: []string{"E01", "E08"},
Priority: 90, MachineTypes: []string{"agricultural", "tractor"},
OperationalStates: []string{"manual_operation", "maintenance"}, HumanRoles: []string{"operator", "maintenance_tech"},
@@ -10,7 +10,7 @@ func GetWeldingGlassTextilePatterns() []HazardPattern {
{
ID: "HP530", NameDE: "Lichtbogen-Verbrennung an Haut/Augen", NameEN: "Arc burn on skin/eyes",
RequiredComponentTags: []string{"high_temperature", "electrical_part"},
RequiredEnergyTags: []string{"thermal", "electrical"},
RequiredEnergyTags: []string{"thermal", "electrical_energy"},
GeneratedHazardCats: []string{"thermal_hazard", "electrical_hazard"},
SuggestedMeasureIDs: []string{"M005", "M141"},
SuggestedEvidenceIDs: []string{"E10", "E20"},
@@ -25,7 +25,7 @@ func GetWeldingGlassTextilePatterns() []HazardPattern {
{
ID: "HP531", NameDE: "Elektrischer Schlag durch Schweissgeraet", NameEN: "Electric shock from welding equipment",
RequiredComponentTags: []string{"electrical_part"},
RequiredEnergyTags: []string{"electrical"},
RequiredEnergyTags: []string{"electrical_energy"},
GeneratedHazardCats: []string{"electrical_hazard"},
SuggestedMeasureIDs: []string{"M082", "M141"},
SuggestedEvidenceIDs: []string{"E09", "E20"},
@@ -132,7 +132,7 @@ func GetWeldingGlassTextilePatterns() []HazardPattern {
{
ID: "HP538", NameDE: "Elektrischer Schlag bei Widerstandsschweissen", NameEN: "Electric shock from resistance welding",
RequiredComponentTags: []string{"electrical_part"},
RequiredEnergyTags: []string{"electrical"},
RequiredEnergyTags: []string{"electrical_energy"},
GeneratedHazardCats: []string{"electrical_hazard"},
SuggestedMeasureIDs: []string{"M082", "M141"},
SuggestedEvidenceIDs: []string{"E09", "E20"},
@@ -118,14 +118,14 @@ func GetWorkshopPatterns() []HazardPattern {
DefaultSeverity: 3, DefaultExposure: 4},
// — Elektrische Gefaehrdungen erweitert (HP618-HP622) —
{ID: "HP618", NameDE: "Lichtbogenbildung bei Kurzschluss", NameEN: "Arc flash from short circuit",
RequiredComponentTags: []string{"electrical_part"}, RequiredEnergyTags: []string{"electrical"},
RequiredComponentTags: []string{"electrical_part"}, RequiredEnergyTags: []string{"electrical_energy"},
GeneratedHazardCats: []string{"electrical_hazard", "thermal_hazard"},
SuggestedMeasureIDs: []string{"M082", "M141"}, SuggestedEvidenceIDs: []string{"E09", "E20"}, Priority: 85,
ScenarioDE: "Kurzschluss erzeugt Lichtbogen mit Hitze und Druckwelle", TriggerDE: "Beschaedigte Isolation",
HarmDE: "Schwere Verbrennungen, Augenschaeden", AffectedDE: "Elektrofachkraefte", ZoneDE: "Schaltschrank",
DefaultSeverity: 5, DefaultExposure: 2},
{ID: "HP619", NameDE: "Kriechstrom durch Feuchtigkeit", NameEN: "Leakage current from humidity",
RequiredComponentTags: []string{"electrical_part"}, RequiredEnergyTags: []string{"electrical"},
RequiredComponentTags: []string{"electrical_part"}, RequiredEnergyTags: []string{"electrical_energy"},
GeneratedHazardCats: []string{"electrical_hazard"},
SuggestedMeasureIDs: []string{"M082", "M141"}, SuggestedEvidenceIDs: []string{"E09"}, Priority: 60,
ScenarioDE: "Feuchtigkeit erzeugt Kriechstroeme", TriggerDE: "Kondenswasser, fehlender IP-Schutz",
@@ -138,14 +138,14 @@ func GetWorkshopPatterns() []HazardPattern {
HarmDE: "Funkenbildung, Zuendquelle im Ex-Bereich", AffectedDE: "Bedienpersonal", ZoneDE: "Foerderbaender",
DefaultSeverity: 2, DefaultExposure: 4},
{ID: "HP621", NameDE: "Fehlender Potentialausgleich", NameEN: "Missing equipotential bonding",
RequiredComponentTags: []string{"electrical_part"}, RequiredEnergyTags: []string{"electrical"},
RequiredComponentTags: []string{"electrical_part"}, RequiredEnergyTags: []string{"electrical_energy"},
GeneratedHazardCats: []string{"electrical_hazard"},
SuggestedMeasureIDs: []string{"M082", "M141"}, SuggestedEvidenceIDs: []string{"E09"}, Priority: 65,
ScenarioDE: "Unterbrochener Schutzleiter am Gehaeuse", TriggerDE: "Korrodierte Erdung, defekter Schutzleiter",
HarmDE: "Beruehrungsspannung, Stromschlag", AffectedDE: "Bedienpersonal", ZoneDE: "Maschinengehaeuse",
DefaultSeverity: 4, DefaultExposure: 3},
{ID: "HP622", NameDE: "Blitzeinschlag bei Aussenaufstellung", NameEN: "Lightning strike outdoor installation",
RequiredComponentTags: []string{"electrical_part", "structural_part"}, RequiredEnergyTags: []string{"electrical"},
RequiredComponentTags: []string{"electrical_part", "structural_part"}, RequiredEnergyTags: []string{"electrical_energy"},
GeneratedHazardCats: []string{"electrical_hazard"},
SuggestedMeasureIDs: []string{"M082", "M141"}, SuggestedEvidenceIDs: []string{"E09"}, Priority: 55,
ScenarioDE: "Maschine im Freien ohne Blitzschutz", TriggerDE: "Fehlender Ueberspannungsableiter",
@@ -69,6 +69,13 @@ func GetKeywordDictionary() []KeywordEntry {
{Keywords: []string{"maehdrescher", "ballenpresse", "feldhaecksler", "traktor"}, ExtraTags: []string{"dom_agri"}},
{Keywords: []string{"rolltreppe", "fahrtreppe", "fahrsteig"}, ExtraTags: []string{"dom_escalator"}},
{Keywords: []string{"glasschneid", "glasbearbeitung", "flachglas", "glasscheibe", "glaskante", "glasmaschine"}, ExtraTags: []string{"dom_glass"}},
{Keywords: []string{"im freien", "freigelaende", "ausseneinsatz", "aussenarbeit", "witterung", "forstarbeit", "freiland", "aussengelaende", "winterdienst"}, ExtraTags: []string{"dom_outdoor"}},
{Keywords: []string{"lueftungsanlage", "lueftungskanal", "klimaanlage", "feuchtraum"}, ExtraTags: []string{"dom_ventilation"}},
{Keywords: []string{"kuehlschmierstoff", "kss-anlage", "kss-kreislauf", "kss-aufbereitung", "bearbeitungszentrum", "kuehlturm"}, ExtraTags: []string{"dom_machining"}},
{Keywords: []string{"silo", "schuettgut", "gaerbehaelter", "getreidesilo", "mehlsilo", "schuettgutfoerder"}, ExtraTags: []string{"dom_bulk"}},
{Keywords: []string{"palettierer", "palettieranlage", "palettierroboter"}, ExtraTags: []string{"dom_palletizer"}},
{Keywords: []string{"spielplatz", "klettergeraet", "spielgeraet", "spielturm"}, ExtraTags: []string{"dom_playground"}},
{Keywords: []string{"kraftstation", "fitnessgeraet", "trainingsgeraet", "kraftgeraet", "langhantel"}, ExtraTags: []string{"dom_fitness"}},
// Ghost-Closure (Emit-Seite): macht die 34 toten Required-Tags
// emittierbar, jeweils NUR via domaenenspezifische Keywords -> die 120
// Ghost-Patterns feuern wieder, aber nur fuer ihre echte Maschine (kein
@@ -193,7 +200,11 @@ func GetKeywordDictionary() []KeywordEntry {
{Keywords: []string{"greifer", "gripper"}, ComponentIDs: []string{"C002"}, ExtraTags: []string{"clamping_part", "pinch_point"}},
{Keywords: []string{"spindel", "spindle"}, ComponentIDs: []string{"C006"}, EnergyIDs: []string{"EN02"}, ExtraTags: []string{"rotating_part", "high_speed"}},
{Keywords: []string{"saege", "saw"}, ComponentIDs: []string{"C007"}, ExtraTags: []string{"cutting_part"}},
{Keywords: []string{"walze", "roller"}, ComponentIDs: []string{"C009"}, EnergyIDs: []string{"EN02"}, ExtraTags: []string{"rotating_part", "entanglement_risk"}},
// "roller" (English) removed: as a bare substring it false-matches
// German compounds like "Bodenroller" (a floor dolly) and "Controller",
// wrongly creating a rotating mill-roller component. "walze" and its
// compounds are the precise German terms for C009.
{Keywords: []string{"walze", "kalanderwalze", "walzwerk"}, ComponentIDs: []string{"C009"}, EnergyIDs: []string{"EN02"}, ExtraTags: []string{"rotating_part", "entanglement_risk"}},
{Keywords: []string{"kette", "chain"}, ComponentIDs: []string{"C010"}, ExtraTags: []string{"entanglement_risk"}},
{Keywords: []string{"bremse", "brake"}, ComponentIDs: []string{"C013"}, ExtraTags: []string{"safety_device"}},
{Keywords: []string{"schweiss", "weld"}, ComponentIDs: []string{"C016"}, EnergyIDs: []string{"EN05"}, ExtraTags: []string{"high_temperature"}},
@@ -0,0 +1,126 @@
package iace
import (
"sort"
"strings"
)
// MachineTypeInfo is one entry of the controlled machine-type vocabulary the
// pattern engine gates on. The project's machine type MUST come from this list
// (a form dropdown), otherwise the engine's machine-type gate either floods
// (empty) or under-covers (a free-text value that matches no pattern).
type MachineTypeInfo struct {
Key string `json:"key"`
LabelDE string `json:"label_de"`
Group string `json:"group"`
}
// machineTypeLabels gives German labels + UI groups for the machine-type keys
// used in pattern MachineTypes. Keys not listed here still appear (derived from
// the pattern set) with a humanised fallback label, so the vocabulary never
// silently drops a gated machine class.
var machineTypeLabels = map[string]MachineTypeInfo{
// Heben & Fördern
"lift": {LabelDE: "Hebevorrichtung / Hubgerät", Group: "Heben & Fördern"},
"elevator": {LabelDE: "Aufzug", Group: "Heben & Fördern"},
"escalator": {LabelDE: "Fahrtreppe / Rolltreppe", Group: "Heben & Fördern"},
"crane": {LabelDE: "Kran", Group: "Heben & Fördern"},
"conveyor": {LabelDE: "Förderanlage / Stetigförderer", Group: "Heben & Fördern"},
"packaging": {LabelDE: "Verpackungsmaschine", Group: "Heben & Fördern"},
"bottling": {LabelDE: "Abfüllanlage", Group: "Heben & Fördern"},
// Pressen & Umformen
"press": {LabelDE: "Presse (allgemein)", Group: "Pressen & Umformen"},
"hydraulic_press": {LabelDE: "Hydraulikpresse", Group: "Pressen & Umformen"},
"mechanical_press": {LabelDE: "Mechanische Presse", Group: "Pressen & Umformen"},
"printing_press": {LabelDE: "Druckmaschine", Group: "Pressen & Umformen"},
// Zerspanung & Bearbeitung
"cnc": {LabelDE: "CNC-Bearbeitungszentrum", Group: "Zerspanung & Bearbeitung"},
"lathe": {LabelDE: "Drehmaschine", Group: "Zerspanung & Bearbeitung"},
"milling": {LabelDE: "Fräsmaschine", Group: "Zerspanung & Bearbeitung"},
"grinding": {LabelDE: "Schleifmaschine", Group: "Zerspanung & Bearbeitung"},
"circular_saw": {LabelDE: "Kreissäge", Group: "Zerspanung & Bearbeitung"},
"metalworking": {LabelDE: "Metallbearbeitung", Group: "Zerspanung & Bearbeitung"},
"woodworking": {LabelDE: "Holzbearbeitung", Group: "Zerspanung & Bearbeitung"},
"welding": {LabelDE: "Schweißanlage", Group: "Zerspanung & Bearbeitung"},
// Roboter & Automation
"robotics_cobot": {LabelDE: "Roboterzelle / Cobot", Group: "Roboter & Automation"},
"rotary_transfer": {LabelDE: "Rundtaktmaschine", Group: "Roboter & Automation"},
"autonomous_vehicle": {LabelDE: "Fahrerloses Transportsystem (FTS/AGV)", Group: "Roboter & Automation"},
// Kunststoff, Glas & Textil
"textile": {LabelDE: "Textilmaschine", Group: "Kunststoff, Glas & Textil"},
"spinning": {LabelDE: "Spinnmaschine", Group: "Kunststoff, Glas & Textil"},
"weaving": {LabelDE: "Webmaschine", Group: "Kunststoff, Glas & Textil"},
"knitting": {LabelDE: "Strickmaschine", Group: "Kunststoff, Glas & Textil"},
"dyeing": {LabelDE: "Färbemaschine", Group: "Kunststoff, Glas & Textil"},
"carding": {LabelDE: "Kardiermaschine", Group: "Kunststoff, Glas & Textil"},
"twisting": {LabelDE: "Zwirnmaschine", Group: "Kunststoff, Glas & Textil"},
"stenter": {LabelDE: "Spannrahmen", Group: "Kunststoff, Glas & Textil"},
"finishing": {LabelDE: "Veredelungsmaschine", Group: "Kunststoff, Glas & Textil"},
// Bau & mobile Maschinen
"construction": {LabelDE: "Baumaschine", Group: "Bau & mobile Maschinen"},
"excavator": {LabelDE: "Bagger", Group: "Bau & mobile Maschinen"},
"tractor": {LabelDE: "Traktor", Group: "Bau & mobile Maschinen"},
"harvester": {LabelDE: "Erntemaschine", Group: "Bau & mobile Maschinen"},
"combine": {LabelDE: "Mähdrescher", Group: "Bau & mobile Maschinen"},
"agricultural": {LabelDE: "Landmaschine", Group: "Bau & mobile Maschinen"},
"forestry": {LabelDE: "Forstmaschine", Group: "Bau & mobile Maschinen"},
"sprayer": {LabelDE: "Spritzgerät / Sprühmaschine", Group: "Bau & mobile Maschinen"},
// Medizin & Labor
"medical_device": {LabelDE: "Medizingerät (allgemein)", Group: "Medizin & Labor"},
"patient_monitor": {LabelDE: "Patientenmonitor", Group: "Medizin & Labor"},
"infusion_pump": {LabelDE: "Infusionspumpe", Group: "Medizin & Labor"},
"ventilator": {LabelDE: "Beatmungsgerät", Group: "Medizin & Labor"},
"laser_device": {LabelDE: "Lasergerät", Group: "Medizin & Labor"},
"pharmaceutical": {LabelDE: "Pharmamaschine", Group: "Medizin & Labor"},
// Prozess & Sonstige
"food_processing": {LabelDE: "Lebensmittelmaschine", Group: "Prozess & Sonstige"},
"pump": {LabelDE: "Pumpe", Group: "Prozess & Sonstige"},
"compressor": {LabelDE: "Kompressor", Group: "Prozess & Sonstige"},
"surface_treatment": {LabelDE: "Oberflächenbehandlung", Group: "Prozess & Sonstige"},
"spray_booth": {LabelDE: "Lackierkabine", Group: "Prozess & Sonstige"},
"electroplating": {LabelDE: "Galvanik", Group: "Prozess & Sonstige"},
"grain_handling": {LabelDE: "Getreide- / Schüttgutanlage", Group: "Prozess & Sonstige"},
"glass_washing": {LabelDE: "Glaswaschanlage", Group: "Prozess & Sonstige"},
"laundry": {LabelDE: "Wäschereimaschine", Group: "Prozess & Sonstige"},
"playground": {LabelDE: "Spielplatzgerät", Group: "Prozess & Sonstige"},
"wind_turbine": {LabelDE: "Windenergieanlage", Group: "Prozess & Sonstige"},
"general_industry": {LabelDE: "Allgemeine Industriemaschine", Group: "Prozess & Sonstige"},
}
// MachineTypeVocabulary returns the controlled machine-type vocabulary derived
// from the live pattern set (so it never drifts from what the engine gates on),
// each with a German label + UI group. Sorted by group, then label.
func MachineTypeVocabulary() []MachineTypeInfo {
seen := make(map[string]bool)
for _, p := range AllPatterns() {
for _, mt := range p.MachineTypes {
seen[mt] = true
}
}
out := make([]MachineTypeInfo, 0, len(seen))
for key := range seen {
info, ok := machineTypeLabels[key]
if !ok {
info = MachineTypeInfo{LabelDE: humanizeMachineType(key), Group: "Sonstige"}
}
info.Key = key
out = append(out, info)
}
sort.Slice(out, func(i, j int) bool {
if out[i].Group != out[j].Group {
return out[i].Group < out[j].Group
}
return out[i].LabelDE < out[j].LabelDE
})
return out
}
func humanizeMachineType(key string) string {
parts := strings.Split(key, "_")
for i, p := range parts {
if p != "" {
parts[i] = strings.ToUpper(p[:1]) + p[1:]
}
}
return strings.Join(parts, " ")
}
@@ -45,6 +45,9 @@ type CreateComponentRequest struct {
Description string `json:"description,omitempty"`
IsSafetyRelevant bool `json:"is_safety_relevant"`
IsNetworked bool `json:"is_networked"`
CEMarked bool `json:"ce_marked"`
// PresenceStatus defaults to "vorhanden" when empty (see Store.CreateComponent).
PresenceStatus string `json:"presence_status,omitempty"`
}
// CreateHazardRequest is the API request for creating a new hazard
@@ -37,6 +37,13 @@ type Project struct {
ArchivedAt *time.Time `json:"archived_at,omitempty"`
}
// Component presence states for expert review of auto-detected components.
const (
PresencePresent = "vorhanden"
PresenceAbsent = "nicht_vorhanden"
PresenceDeleted = "geloescht"
)
// Component represents a system component within a project
type Component struct {
ID uuid.UUID `json:"id"`
@@ -48,6 +55,17 @@ type Component struct {
Description string `json:"description,omitempty"`
IsSafetyRelevant bool `json:"is_safety_relevant"`
IsNetworked bool `json:"is_networked"`
// CEMarked: bought component that carries its own CE / Declaration of
// Conformity (finished robot, actuator, drive, safety PLC). SAFE semantics:
// does NOT suppress hazards — only drives provenance/evidence hints and the
// "validate the integrated safety function (PL/SIL)" obligation when also
// safety-relevant.
CEMarked bool `json:"ce_marked"`
// PresenceStatus: vorhanden | nicht_vorhanden | geloescht. Only `vorhanden`
// components feed pattern matching. `nicht_vorhanden` = engine's best-effort
// negation verdict awaiting expert review; `geloescht` = expert removed it
// (kept as a soft-deleted audit row).
PresenceStatus string `json:"presence_status"`
Metadata json.RawMessage `json:"metadata,omitempty"`
SortOrder int `json:"sort_order"`
CreatedAt time.Time `json:"created_at"`
@@ -0,0 +1,78 @@
package iace
import "strings"
// Negation-aware keyword matching for the narrative parser.
//
// The limits form often states what a machine does NOT have, e.g.
// "Keine pneumatischen oder hydraulischen Schnittstellen. Hubantrieb ueber
// Kette." Naive substring matching wrongly extracted hydraulic/pneumatic
// components (and their energy sources), which then generated phantom hazards
// the assessor never raised. We only suppress DETERMINER negations
// ("keine/kein/ohne/weder <X>") — these directly negate the following noun
// phrase and are safe. Plain "nicht" is intentionally excluded: it modifies
// verbs/adjectives and over-negates ("Schutz nicht erforderlich, Zylinder
// vorhanden").
var negationDeterminers = map[string]bool{
"keine": true, "kein": true, "keinen": true, "keiner": true,
"keinem": true, "keines": true, "keinerlei": true, "weder": true,
"ohne": true, "no": true, "without": true,
}
// Tokens that end a negation's scope: a contrast ("aber") or a positive-presence
// cue ("mit ... vorhanden"). After one of these, a following keyword is positive.
var negationScopeEnders = map[string]bool{
"aber": true, "jedoch": true, "sondern": true, "doch": true,
"mit": true, "vorhanden": true, "verbaut": true, "vorgesehen": true,
"installiert": true, "ausgestattet": true, "but": true, "with": true,
}
// A negation determiner only reaches a keyword a few tokens away (a short list
// like "keine A, B oder C"). Beyond this span we assume the keyword is unrelated.
const negationMaxTokenSpan = 8
// keywordIsNegated reports whether the keyword occurrence starting at byte index
// idx in the (already normalised) text sits inside the scope of a determiner
// negation. It walks back to the start of the current sentence, then scans the
// preceding tokens right-to-left for a determiner negation, stopping at any
// scope-ender or after negationMaxTokenSpan tokens.
func keywordIsNegated(text string, idx int) bool {
start := 0
for i := idx - 1; i >= 0; i-- {
c := text[i]
if c == '.' || c == '\n' || c == ';' || c == '!' || c == '?' || c == ':' {
start = i + 1
break
}
}
tokens := strings.Fields(text[start:idx])
for d := 0; d < len(tokens) && d < negationMaxTokenSpan; d++ {
w := strings.Trim(tokens[len(tokens)-1-d], ",.;:()-")
if negationScopeEnders[w] {
return false
}
if negationDeterminers[w] {
return true
}
}
return false
}
// hasUnnegatedOccurrence reports whether kw appears in text at least once outside
// a negation scope. A term that is ONLY ever negated must not create components,
// energy sources or tags.
func hasUnnegatedOccurrence(text, kw string) bool {
from := 0
for {
rel := strings.Index(text[from:], kw)
if rel < 0 {
return false
}
abs := from + rel
if !keywordIsNegated(text, abs) {
return true
}
from = abs + len(kw)
}
}
@@ -0,0 +1,61 @@
package iace
import "testing"
// Helper-level: operates on already-normalised (lowercase, umlaut-folded) text.
func TestHasUnnegatedOccurrence(t *testing.T) {
neg := "keine pneumatischen oder hydraulischen schnittstellen. hubantrieb ueber kette (kettenspannung zyklisch zu pruefen)."
tests := []struct {
name string
text string
kw string
want bool
}{
{"negated hydraulik", neg, "hydraulisch", false},
{"negated pneumatik", neg, "pneumatisch", false},
{"positive kette after period", neg, "kette", true},
{"ohne negates", "ohne hydraulik vorhanden", "hydraulik", false},
{"mit ends scope", "ohne hydraulik, mit pneumatikzylinder", "pneumatik", true},
{"plain positive", "die maschine hat eine hydraulikpumpe", "hydraulik", true},
{"weder/contrast", "weder pneumatik noch hydraulik verbaut", "pneumatik", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := hasUnnegatedOccurrence(tt.text, tt.kw); got != tt.want {
t.Errorf("hasUnnegatedOccurrence(%q,%q)=%v, want %v", tt.text, tt.kw, got, tt.want)
}
})
}
}
// Integration: the Kistenhub interface sentence SURFACES the hydraulic (C041)
// and pneumatic (C051) components flagged as negated (for expert review), keeps
// the chain drive (C010) as present, and crucially leaks NO pneumatic/hydraulic
// tags into the matching set (so no phantom hazards).
func TestParseNarrative_NegatedInterfacesFlagged(t *testing.T) {
text := "Keine pneumatischen oder hydraulischen Schnittstellen. " +
"Hubantrieb über Kette (Kettenspannung zyklisch zu prüfen)."
res := ParseNarrative(text)
status := func(id string) (found, negated bool) {
for _, c := range res.Components {
if c.LibraryID == id {
return true, c.Negated
}
}
return false, false
}
if f, n := status("C041"); !f || !n {
t.Errorf("C041 (Hydraulikpumpe) should be surfaced & negated; found=%v negated=%v", f, n)
}
if f, n := status("C051"); !f || !n {
t.Errorf("C051 (Pneumatikzylinder) should be surfaced & negated; found=%v negated=%v", f, n)
}
if f, n := status("C010"); !f || n {
t.Errorf("C010 (Kettenantrieb) should be present & not negated; found=%v negated=%v", f, n)
}
for _, tag := range res.CustomTags {
if tag == "pneumatic_part" || tag == "hydraulic_part" {
t.Errorf("negated component leaked tag %q into the matching set", tag)
}
}
}
@@ -13,6 +13,10 @@ type ComponentMatch struct {
MatchedOn string `json:"matched_on"` // The keyword that triggered the match
Tags []string `json:"tags"`
Confidence float64 `json:"confidence"`
// Negated is the best-effort verdict that the keyword appeared only in a
// negated clause ("keine Pneumatik"). Negated components are surfaced for
// expert review but do NOT contribute tags/energy to pattern matching.
Negated bool `json:"negated"`
}
// EnergyMatch represents an energy source detected from narrative text.
@@ -189,7 +193,14 @@ func ParseNarrative(text string, machineType ...string) ParseResult {
kwNorm = strings.ReplaceAll(kwNorm, "ß", "ss")
if strings.Contains(lower, kwNorm) {
// Add components
// Best-effort negation verdict: the keyword is present, but if
// every occurrence sits in a negated clause ("keine Pneumatik")
// the component is surfaced as negated and contributes NO tags /
// energy to matching (so it generates no phantom hazards). The
// expert can flip the verdict in the Components view.
negated := !hasUnnegatedOccurrence(lower, kwNorm)
// Add components (negated ones carry the flag, no tags)
for _, cid := range entry.ComponentIDs {
if !seenComponents[cid] {
seenComponents[cid] = true
@@ -200,27 +211,31 @@ func ParseNarrative(text string, machineType ...string) ParseResult {
MatchedOn: kw,
Tags: comp.Tags,
Confidence: 0.8,
Negated: negated,
})
// Add component tags
for _, t := range comp.Tags {
tagSet[t] = true
if !negated {
for _, t := range comp.Tags {
tagSet[t] = true
}
}
}
}
// Add energy sources
for _, eid := range entry.EnergyIDs {
if !seenEnergy[eid] {
seenEnergy[eid] = true
result.EnergySources = append(result.EnergySources, EnergyMatch{
SourceID: eid,
NameDE: eid, // Will be enriched by caller
MatchedOn: kw,
})
if !negated {
// Add energy sources
for _, eid := range entry.EnergyIDs {
if !seenEnergy[eid] {
seenEnergy[eid] = true
result.EnergySources = append(result.EnergySources, EnergyMatch{
SourceID: eid,
NameDE: eid, // Will be enriched by caller
MatchedOn: kw,
})
}
}
// Add extra tags
for _, t := range entry.ExtraTags {
tagSet[t] = true
}
}
// Add extra tags
for _, t := range entry.ExtraTags {
tagSet[t] = true
}
break // First keyword match is enough per entry
}
@@ -0,0 +1,37 @@
package iace
import "testing"
// Regression: "Bodenroller" (a floor dolly the load sits on) and "Controller"
// must NOT be detected as a rotating mill roller (C009 "Walze"). Before the fix,
// the bare English keyword "roller" matched as a substring inside these German
// compounds and created a bogus Walze component for a crate-lift.
func TestParseNarrative_BodenrollerControllerNotWalze(t *testing.T) {
cases := []string{
"Das Kistenhubgeraet hebt Behaelter auf Bodenrollern und Transportwagen.",
"Beladung wahlweise von Bodenroller 400x600 mm.",
"Der SPS-Controller steuert den Hubantrieb.",
}
for _, text := range cases {
res := ParseNarrative(text)
for _, c := range res.Components {
if c.LibraryID == "C009" {
t.Errorf("text %q wrongly mapped to C009 (Walze) via %q", text, c.MatchedOn)
}
}
}
}
// The precise German term must still create the roller component.
func TestParseNarrative_WalzeDetectsC009(t *testing.T) {
res := ParseNarrative("Die Maschine besitzt eine angetriebene Walze zum Kalandrieren.")
found := false
for _, c := range res.Components {
if c.LibraryID == "C009" {
found = true
}
}
if !found {
t.Fatal("expected 'Walze' to detect component C009")
}
}
@@ -57,6 +57,28 @@ var domainGateTerms = map[string]string{
"maehdrescher": "dom_agri", "ballenpresse": "dom_agri", "feldhaecksler": "dom_agri",
// Roll-/Fahrtreppe
"rolltreppe": "dom_escalator", "fahrtreppe": "dom_escalator",
// Aussen-/Witterungs-/Bioarbeit (Forst, Bau im Freien)
"zecke": "dom_outdoor", "zeckenstich": "dom_outdoor", "fsme": "dom_outdoor",
"borreliose": "dom_outdoor", "im freien": "dom_outdoor", "freigelaende": "dom_outdoor",
"aussengelaende": "dom_outdoor", "ausseneinsatz": "dom_outdoor", "witterung": "dom_outdoor",
"winterarbeit": "dom_outdoor", "nagerkot": "dom_outdoor", "hantavirus": "dom_outdoor",
// Lueftung/Feuchte (Schimmel)
"schimmel": "dom_ventilation", "schimmelspor": "dom_ventilation",
"lueftungsanlage": "dom_ventilation", "lueftungskanal": "dom_ventilation",
// Zerspanung / Kuehlschmierstoff
"kuehlschmierstoff": "dom_machining", "kss-kreislauf": "dom_machining",
"kss-aufbereitung": "dom_machining", "kuehlturm": "dom_machining",
"bearbeitungszentrum": "dom_machining",
// Schuettgut / Silo / Gaerbehaelter (Confined Space mit Schuettgut)
"silo": "dom_bulk", "schuettgut": "dom_bulk", "gaerbehaelter": "dom_bulk",
"getreidesilo": "dom_bulk", "mehlsilo": "dom_bulk",
// Palettierer
"palettierer": "dom_palletizer", "palettieranlage": "dom_palletizer",
// Spielplatz / Spielgeraet
"klettergeraet": "dom_playground", "spielplatz": "dom_playground", "spielgeraet": "dom_playground",
// Fitness / Kraftgeraet
"gewichtstapel": "dom_fitness", "langhantel": "dom_fitness", "bankdrueck": "dom_fitness",
"kniebeug": "dom_fitness", "kraftstation": "dom_fitness",
}
// applyDomainGates appends a domain capability tag to every pattern whose own
@@ -0,0 +1,68 @@
package iace
import (
"sort"
"strings"
"testing"
)
// The cross-domain term list lives in gt_benchmark_harness_test.go
// (foreignDomainTerms, term → home domain). This precision guard reuses it and,
// unlike the diagnostic TestGT_DomainLeakage, runs the FULL production gating
// path including the relevance backstop, then ASSERTS zero leaks. It catches
// machine-type wiring regressions and weak-tag (structural_part) leaks in CI.
// firedHazardsForCase runs the exact production gating path (parse → engine match
// → relevance backstop) for one GT case and returns the surviving patterns.
func firedHazardsForCase(c gtCase) []PatternMatch {
narrative := c.narrativeOverride
pr := ParseNarrative(narrative, c.machineType)
input := parseResultToMatchInput(pr, c.machineType)
compNames := make([]string, 0, len(pr.Components))
for _, comp := range pr.Components {
if comp.Negated {
continue
}
compNames = append(compNames, NormalizeDEPublic(comp.NameDE))
}
out := NewPatternEngine().Match(input)
fired := make([]PatternMatch, 0, len(out.MatchedPatterns))
for _, mp := range out.MatchedPatterns {
if IsPatternRelevant(mp, narrative, compNames) {
fired = append(fired, mp)
}
}
return fired
}
// TestCrossDomainPrecision asserts that no fired pattern is foreign to the GT
// machine — neither machine-type-incompatible nor matching a foreign-domain term.
func TestCrossDomainPrecision(t *testing.T) {
for _, c := range gtBenchmarkCases {
c := c
t.Run(c.name, func(t *testing.T) {
fired := firedHazardsForCase(c)
t.Logf("%s (%s): %d patterns fired", c.name, c.machineType, len(fired))
var domainLeaks []string
for _, mp := range fired {
text := normalizeDE(mp.PatternName + " " + mp.ZoneDE + " " + mp.ScenarioDE)
for term, domain := range foreignDomainTerms {
if strings.Contains(text, term) {
domainLeaks = append(domainLeaks, domain+"/"+term+" → "+mp.PatternName)
break
}
}
}
sort.Strings(domainLeaks)
for _, l := range domainLeaks {
t.Logf(" FOREIGN-DOMAIN: %s", l)
}
if len(domainLeaks) > 0 {
t.Errorf("%s: %d cross-domain leak(s) — patterns from foreign machine classes fired", c.name, len(domainLeaks))
}
})
}
}
@@ -0,0 +1,103 @@
package iace
import (
"sort"
"strings"
"testing"
)
// producibleTagUniverse returns every component/energy tag that some machine
// could ever carry: tags from the component library, the energy library, the
// keyword dictionary's ExtraTags, and the domain-gate tags. A pattern whose
// RequiredComponentTags/RequiredEnergyTags include a tag outside this set can
// never match — no machine can produce that tag.
func producibleTagUniverse() map[string]bool {
u := make(map[string]bool)
for _, c := range GetComponentLibrary() {
for _, t := range c.Tags {
u[t] = true
}
}
for _, e := range GetEnergySources() {
for _, t := range e.Tags {
u[t] = true
}
}
for _, k := range GetKeywordDictionary() {
for _, t := range k.ExtraTags {
u[t] = true
}
}
// Domain-gate tags are produced from narrative domain terms.
for _, t := range []string{
"dom_agri", "dom_cnc", "dom_escalator", "dom_glass", "dom_grinding",
"dom_plastics", "dom_press", "dom_rolling", "dom_solar", "dom_textile",
"dom_welding", "dom_wind",
} {
u[t] = true
}
return u
}
// TestPatternReachability reports patterns that can never fire because they
// require a component/energy tag that nothing in the libraries produces. Every
// pattern should be usable in SOME CE risk assessment. Currently informational
// (t.Log) so we can review the list before deciding to prune.
func TestPatternReachability(t *testing.T) {
universe := producibleTagUniverse()
patterns := AllPatterns()
type dead struct {
id, name string
missing []string
}
var deads []dead
missingTagCount := make(map[string]int)
for _, p := range patterns {
var missing []string
for _, tag := range append(append([]string{}, p.RequiredComponentTags...), p.RequiredEnergyTags...) {
if !universe[tag] {
missing = append(missing, tag)
missingTagCount[tag]++
}
}
if len(missing) > 0 {
deads = append(deads, dead{p.ID, p.NameDE, missing})
}
}
t.Logf("Patterns gesamt: %d", len(patterns))
t.Logf("Unerreichbare (tote) Patterns: %d", len(deads))
// Most common unsatisfiable tags first — these point at the systemic gaps.
type kv struct {
tag string
n int
}
var ranked []kv
for tag, n := range missingTagCount {
ranked = append(ranked, kv{tag, n})
}
sort.Slice(ranked, func(i, j int) bool { return ranked[i].n > ranked[j].n })
t.Log("--- Unerfuellbare Required-Tags (Haeufigkeit) ---")
for _, r := range ranked {
t.Logf(" %3d %s", r.n, r.tag)
}
t.Log("--- Tote Patterns (erste 60) ---")
for i, d := range deads {
if i >= 60 {
break
}
t.Logf(" %s %q fehlend: %s", d.id, d.name, strings.Join(d.missing, ","))
}
// Guard: every pattern must be reachable by some CE risk assessment. A
// pattern requiring a tag no component/energy/keyword can ever produce is
// dead weight (and often a tag-naming typo). Keep this at zero.
if len(deads) > 0 {
t.Errorf("%d unreachable pattern(s) — required tags that nothing produces: %v",
len(deads), missingTagCount)
}
}
@@ -0,0 +1,181 @@
package iace
import "strings"
// Pattern relevance gating (engine-side backstop).
//
// The pattern engine fires any pattern whose required tags are present. Some
// patterns are gated only on near-universal tags (e.g. "structural_part" — every
// machine has a frame) or on strong-but-broad tags a machine genuinely has
// (gravity_risk, moving_part, high_force). That lets context-specific patterns
// from other environments leak in (tick bites, confined-space oxygen, palletizer
// reach-in). IsPatternRelevant is the text backstop: a pattern made only of
// GENERIC hazard vocabulary (quetschen, stromschlag, absturz, person ...) is a
// universal machine hazard and stays; a pattern carrying a machine-, environment-
// or organism-specific word (palettierer, klettergeraet, zeckenbiss) only applies
// if that word actually appears in this machine's limits.
//
// This is a BACKSTOP, not the primary gate — the authoritative gate is the
// engine's machine-type + required-tag matching (see patternMatches). Keeping the
// relevance logic in this package lets the precision test exercise the exact
// production path.
// genericSafetyTerms are exact words that appear in almost all risk assessments
// and must NOT be treated as machine-specific.
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,
// Generic anatomy (too short for safe prefix stems)
"arm": true, "arme": true, "bein": true, "beine": true, "fuss": true,
"fuesse": true, "kopf": true, "koepfe": true, "hand": true, "haende": true,
// Common German function words (prepositions/conjunctions/determiners) that
// are not machine-specific but survive the >=5-char specific-word cut.
"zwischen": true, "durch": true, "gegen": true, "neben": true,
"hinter": true, "waehrend": true, "sowie": true, "dabei": true,
"dadurch": true, "wodurch": true, "beim": true, "etwa": true,
"jeder": true, "jede": true, "jedes": true, "dieser": true, "diese": true,
"dieses": true, "welche": true, "welcher": true, "deren": true,
"dessen": true, "sodass": true, "damit": true,
// Location prepositions — never machine-distinctive.
"ueber": true, "oberhalb": true, "unterhalb": true, "innerhalb": true,
"ausserhalb": true, "entlang": true, "angrenzend": true, "darunter": true,
"umliegend": true, "benachbart": true,
}
// genericStems cover inflected generic words by prefix (German adds suffixes:
// person→personen/personal, arbeit→arbeiten/arbeitsraum, quetsch→quetschen).
// Only stems long/distinct enough that a prefix match cannot catch an unrelated
// specific compound are listed. This is the lemma half of the filter.
var genericStems = []string{
// actors / organisation
"person", "arbeit", "taetig", "mitarbeit", "bedien", "werker", "nutzer",
"betrieb", "wartung", "instandhalt", "reinig", "einricht", "transport",
"qualifik", "unterweis", "schulung",
// hazard phenomena / kinematics
"quetsch", "scher", "schneid", "schnitt", "stich", "stoss", "schlag",
"einzug", "einzieh", "erfass", "wickel", "absturz", "abstuerz", "sturz",
"stuerz", "kollision", "anprall", "anstoss", "verbrenn", "verbrueh",
"verletz", "gefaehrd", "klemm",
// energy / electrical / thermal descriptors
"stromschl", "spannung", "elektr", "thermi", "energie", "leitung",
// anatomy (long enough for prefix)
"finger", "koerper", "gliedmass", "extremit",
// structure / location / generic qualifiers
"bereich", "struktur", "gehaeuse", "oberflaech", "beweg", "feststehend",
"schutz", "sicher", "maschine", "anlage", "betriebs",
// common generic verbs / adjectives (contact, motion, causation, state)
"beruehr", "greif", "treff", "fall", "faell", "loes", "oeffn", "schliess",
"gelang", "erreich", "direkt", "schwer", "offen", "scharf", "teil",
"moeglich", "fehlend", "unerwart", "ploetzl", "unkontroll", "versehentl",
}
func isGenericTerm(w string) bool {
if genericSafetyTerms[w] {
return true
}
for _, s := range genericStems {
if strings.HasPrefix(w, s) {
return true
}
}
return false
}
// narrativeTokenSet builds the set of words the machine actually describes
// (limits text + component names), normalised and de-duplicated.
func narrativeTokenSet(narrative string, compNames []string) map[string]bool {
set := make(map[string]bool)
add := func(text string) {
for _, t := range strings.Fields(NormalizeDEPublic(text)) {
t = strings.Trim(t, ".,;:!?()/-\"")
if len(t) >= 4 {
set[t] = true
}
}
}
add(narrative)
for _, cn := range compNames {
add(cn)
}
return set
}
// specificWordInNarrative reports whether a machine-specific pattern word is
// present in the machine's vocabulary. Matches on token boundaries (full token,
// or either word a prefix of the other for ≥5 chars) so German inflection is
// tolerated ("behaelter" ~ "behaeltern") without substring false positives
// ("arbeiten" inside "bearbeiten").
func specificWordInNarrative(sw string, tokens map[string]bool) bool {
if tokens[sw] {
return true
}
if len(sw) < 5 {
return false
}
for t := range tokens {
// Only the inflection direction: a narrative token is the specific word
// plus a German suffix ("behaelter" → "behaeltern"). The REVERSE
// direction is dropped — it let a long pattern word anchor on a short
// common narrative token (pattern "uebertragen" matching "ueber",
// "zugangsbereich" matching "zugang"), the dominant false-positive class.
if len(t) >= 5 && strings.HasPrefix(t, sw) {
return true
}
}
return false
}
// IsPatternRelevant checks whether a pattern applies to the machine in the
// narrative. A pattern with no machine-specific word is generic → relevant. A
// pattern with specific words is relevant only if at least one appears in the
// machine's own vocabulary.
func IsPatternRelevant(mp PatternMatch, narrative string, compNames []string) bool {
patternText := NormalizeDEPublic(mp.ZoneDE + " " + mp.ScenarioDE + " " + mp.PatternName)
var specificWords []string
for _, w := range strings.Fields(patternText) {
w = strings.Trim(w, ".,;:!?()/-\"")
if len(w) < 5 || isGenericTerm(w) {
continue
}
specificWords = append(specificWords, w)
}
if len(specificWords) == 0 {
return true
}
tokens := narrativeTokenSet(narrative, compNames)
for _, sw := range specificWords {
if specificWordInNarrative(sw, tokens) {
return true
}
}
return false
}
@@ -0,0 +1,45 @@
package iace
import "testing"
func TestIsPatternRelevant(t *testing.T) {
// A chain-driven crate lift: no press, no outdoor work, no palletizer.
narrative := "Kistenhubgeraet hebt Behaelter ueber Kette. Elektromotor und SPS-Steuerung. " +
"Hubwerk mit Plattform und Not-Halt-Taster."
comps := []string{"Hubwerk", "Kettenantrieb", "Elektromotor (Drehstrom)", "SPS", "Plattform/Buehne"}
tests := []struct {
name string
mp PatternMatch
want bool
}{
{"foreign: tick bite", PatternMatch{
PatternName: "Zeckenbiss bei Ausseneinsatz", ScenarioDE: "Zeckenstich bei Arbeiten im Gruenen",
ZoneDE: "Freigelände, Wald, Wiese"}, false},
{"foreign: climbing structure", PatternMatch{
PatternName: "Absturz von Klettergeraet", ScenarioDE: "Kind stuerzt von Klettergeraet auf den Boden",
ZoneDE: "Klettergeraet, Fallzone darunter"}, false},
{"foreign: palletizer", PatternMatch{
PatternName: "Palettierer — mechanisch", ScenarioDE: "Palettierer bewegt schwere Gebinde und quetscht Personen",
ZoneDE: "Palettierer-Arbeitsraum, Palettenwechselzone"}, false},
{"foreign: ventilation mold", PatternMatch{
PatternName: "Schimmelpilz in Lueftungsanlage", ScenarioDE: "Staub mit Nagerkot in selten gereinigten Raeumen",
ZoneDE: "Lueftungskanal, Filterbereich"}, false},
{"generic: crush by moving parts", PatternMatch{
PatternName: "Quetschgefahr durch bewegte Teile", ScenarioDE: "Quetschen zwischen beweglichen und feststehenden Teilen",
ZoneDE: "Bewegungsbereich"}, true},
{"generic: electric shock", PatternMatch{
PatternName: "Direktes Beruehren", ScenarioDE: "Stromschlag bei Beruehrung spannungsfuehrender Teile",
ZoneDE: "Schaltschrank"}, true},
{"machine-word match: chain drive", PatternMatch{
PatternName: "Erfassen durch Kettenantrieb", ScenarioDE: "Einzug an offener Kette",
ZoneDE: "Kettenbereich"}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := IsPatternRelevant(tt.mp, narrative, comps); got != tt.want {
t.Errorf("IsPatternRelevant(%q)=%v, want %v", tt.mp.PatternName, got, tt.want)
}
})
}
}
@@ -17,6 +17,10 @@ import (
// CreateComponent creates a new component within a project
func (s *Store) CreateComponent(ctx context.Context, req CreateComponentRequest) (*Component, error) {
status := req.PresenceStatus
if status == "" {
status = PresencePresent
}
comp := &Component{
ID: uuid.New(),
ProjectID: req.ProjectID,
@@ -27,6 +31,8 @@ func (s *Store) CreateComponent(ctx context.Context, req CreateComponentRequest)
Description: req.Description,
IsSafetyRelevant: req.IsSafetyRelevant,
IsNetworked: req.IsNetworked,
CEMarked: req.CEMarked,
PresenceStatus: status,
CreatedAt: time.Now().UTC(),
UpdatedAt: time.Now().UTC(),
}
@@ -35,16 +41,16 @@ func (s *Store) CreateComponent(ctx context.Context, req CreateComponentRequest)
INSERT INTO iace_components (
id, project_id, parent_id, name, component_type,
version, description, is_safety_relevant, is_networked,
metadata, sort_order, created_at, updated_at
ce_marked, presence_status, metadata, sort_order, created_at, updated_at
) VALUES (
$1, $2, $3, $4, $5,
$6, $7, $8, $9,
$10, $11, $12, $13
$10, $11, $12, $13, $14, $15
)
`,
comp.ID, comp.ProjectID, comp.ParentID, comp.Name, string(comp.ComponentType),
comp.Version, comp.Description, comp.IsSafetyRelevant, comp.IsNetworked,
comp.Metadata, comp.SortOrder, comp.CreatedAt, comp.UpdatedAt,
comp.CEMarked, comp.PresenceStatus, comp.Metadata, comp.SortOrder, comp.CreatedAt, comp.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("create component: %w", err)
@@ -63,12 +69,12 @@ func (s *Store) GetComponent(ctx context.Context, id uuid.UUID) (*Component, err
SELECT
id, project_id, parent_id, name, component_type,
version, description, is_safety_relevant, is_networked,
metadata, sort_order, created_at, updated_at
ce_marked, presence_status, metadata, sort_order, created_at, updated_at
FROM iace_components WHERE id = $1
`, id).Scan(
&c.ID, &c.ProjectID, &c.ParentID, &c.Name, &compType,
&c.Version, &c.Description, &c.IsSafetyRelevant, &c.IsNetworked,
&metadata, &c.SortOrder, &c.CreatedAt, &c.UpdatedAt,
&c.CEMarked, &c.PresenceStatus, &metadata, &c.SortOrder, &c.CreatedAt, &c.UpdatedAt,
)
if err == pgx.ErrNoRows {
return nil, nil
@@ -89,7 +95,7 @@ func (s *Store) ListComponents(ctx context.Context, projectID uuid.UUID) ([]Comp
SELECT
id, project_id, parent_id, name, component_type,
version, description, is_safety_relevant, is_networked,
metadata, sort_order, created_at, updated_at
ce_marked, presence_status, metadata, sort_order, created_at, updated_at
FROM iace_components WHERE project_id = $1
ORDER BY sort_order ASC, created_at ASC
`, projectID)
@@ -107,7 +113,7 @@ func (s *Store) ListComponents(ctx context.Context, projectID uuid.UUID) ([]Comp
err := rows.Scan(
&c.ID, &c.ProjectID, &c.ParentID, &c.Name, &compType,
&c.Version, &c.Description, &c.IsSafetyRelevant, &c.IsNetworked,
&metadata, &c.SortOrder, &c.CreatedAt, &c.UpdatedAt,
&c.CEMarked, &c.PresenceStatus, &metadata, &c.SortOrder, &c.CreatedAt, &c.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("list components scan: %w", err)
@@ -150,6 +156,14 @@ func (s *Store) UpdateComponent(ctx context.Context, id uuid.UUID, updates map[s
query += fmt.Sprintf(", is_networked = $%d", argIdx)
args = append(args, val)
argIdx++
case "presence_status":
query += fmt.Sprintf(", presence_status = $%d", argIdx)
args = append(args, val)
argIdx++
case "ce_marked":
query += fmt.Sprintf(", ce_marked = $%d", argIdx)
args = append(args, val)
argIdx++
case "sort_order":
query += fmt.Sprintf(", sort_order = $%d", argIdx)
args = append(args, val)
@@ -0,0 +1,47 @@
-- Migration 152: reconcile iace_projects with the current Go schema.
-- ==========================================================================
-- The iace tables were created ad-hoc (no CREATE migration) and drifted across
-- environments. The consolidation copied an OLDER iace_projects into the local
-- `compliance` schema: it carried legacy columns (intended_use,
-- limits_description, reasonably_foreseeable_misuse, completeness_pct,
-- can_export) and lacked the columns the current store layer reads/writes
-- (store_projects.go CreateProject/GetProject): customer_name, description,
-- narrative_text, ce_marking_target, completeness_score, risk_summary,
-- triggered_regulations, archived_at.
--
-- This migration brings the table to the code's expectation. Idempotent and
-- guarded so it is a no-op where the table already matches. Legacy columns are
-- only made nullable (not dropped) so any historical data survives while the
-- current INSERT (which omits them) no longer fails on NOT NULL.
-- ==========================================================================
ALTER TABLE iace_projects
ADD COLUMN IF NOT EXISTS parent_project_id uuid,
ADD COLUMN IF NOT EXISTS customer_name text,
ADD COLUMN IF NOT EXISTS description text,
ADD COLUMN IF NOT EXISTS narrative_text text,
ADD COLUMN IF NOT EXISTS ce_marking_target text,
ADD COLUMN IF NOT EXISTS completeness_score double precision,
ADD COLUMN IF NOT EXISTS risk_summary jsonb,
ADD COLUMN IF NOT EXISTS triggered_regulations jsonb,
ADD COLUMN IF NOT EXISTS archived_at timestamptz;
-- Relax legacy NOT NULL columns the current code never writes, so INSERTs that
-- omit them succeed. DROP NOT NULL is a no-op when already nullable; the guard
-- skips columns that do not exist in this schema variant.
DO $$
DECLARE
col text;
BEGIN
FOREACH col IN ARRAY ARRAY[
'intended_use', 'limits_description', 'reasonably_foreseeable_misuse',
'completeness_pct', 'can_export'
] LOOP
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'iace_projects' AND column_name = col
) THEN
EXECUTE format('ALTER TABLE iace_projects ALTER COLUMN %I DROP NOT NULL', col);
END IF;
END LOOP;
END $$;
@@ -0,0 +1,70 @@
-- Migration 153: reconcile iace_components / iace_hazards / iace_mitigations
-- with the current Go schema.
-- ==========================================================================
-- Same drift as 152 (iace_projects): the consolidation copied an OLDER table
-- generation with different column names (title vs name, component_ids vs
-- component_id, safety_relevant vs is_safety_relevant). This adds every column
-- the current store layer reads/writes and relaxes legacy NOT NULL columns the
-- code no longer fills, so INSERTs succeed. Idempotent + guarded.
-- ==========================================================================
-- iace_components
ALTER TABLE iace_components
ADD COLUMN IF NOT EXISTS is_safety_relevant boolean NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS is_networked boolean NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS sort_order integer NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS parent_id uuid,
ADD COLUMN IF NOT EXISTS description text,
ADD COLUMN IF NOT EXISTS version text,
ADD COLUMN IF NOT EXISTS metadata jsonb;
-- iace_hazards
ALTER TABLE iace_hazards
ADD COLUMN IF NOT EXISTS component_id uuid,
ADD COLUMN IF NOT EXISTS library_hazard_id uuid,
ADD COLUMN IF NOT EXISTS name text,
ADD COLUMN IF NOT EXISTS scenario text,
ADD COLUMN IF NOT EXISTS sub_category text,
ADD COLUMN IF NOT EXISTS machine_module text,
ADD COLUMN IF NOT EXISTS function text,
ADD COLUMN IF NOT EXISTS lifecycle_phase text,
ADD COLUMN IF NOT EXISTS hazardous_zone text,
ADD COLUMN IF NOT EXISTS trigger_event text,
ADD COLUMN IF NOT EXISTS affected_person text,
ADD COLUMN IF NOT EXISTS possible_harm text,
ADD COLUMN IF NOT EXISTS review_status text;
-- iace_mitigations
ALTER TABLE iace_mitigations
ADD COLUMN IF NOT EXISTS name text,
ADD COLUMN IF NOT EXISTS verification_method text,
ADD COLUMN IF NOT EXISTS verification_result text,
ADD COLUMN IF NOT EXISTS verified_at timestamptz,
ADD COLUMN IF NOT EXISTS verified_by uuid,
ADD COLUMN IF NOT EXISTS is_relevant boolean NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS is_customer_standard boolean NOT NULL DEFAULT false;
-- Relax legacy NOT NULL columns the current code never writes.
DO $$
DECLARE
rec record;
BEGIN
FOR rec IN SELECT * FROM (VALUES
('iace_components','tenant_id'),('iace_components','safety_relevant'),('iace_components','tags'),
('iace_hazards','tenant_id'),('iace_hazards','title'),('iace_hazards','component_ids'),
('iace_hazards','library_id'),('iace_hazards','severity'),('iace_hazards','exposure'),
('iace_hazards','probability'),('iace_hazards','avoidance'),('iace_hazards','risk_score'),
('iace_hazards','risk_level'),
('iace_mitigations','tenant_id'),('iace_mitigations','project_id'),('iace_mitigations','title'),
('iace_mitigations','sub_type'),('iace_mitigations','control_ids'),
('iace_mitigations','verification_evidence')
) AS t(tbl, col)
LOOP
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = rec.tbl AND column_name = rec.col
) THEN
EXECUTE format('ALTER TABLE %I ALTER COLUMN %I DROP NOT NULL', rec.tbl, rec.col);
END IF;
END LOOP;
END $$;
@@ -0,0 +1,12 @@
-- Migration 154: add the mitigation UNIQUE/CHECK that the current code relies on.
-- The reconciled (consolidated) iace_mitigations lacked the constraints from
-- migrations 029/030, so CreateMitigation's `ON CONFLICT (hazard_id, name)`
-- failed (SQLSTATE 42P10) and no mitigations were created. Idempotent.
ALTER TABLE iace_mitigations
DROP CONSTRAINT IF EXISTS iace_mitigations_hazard_name_uniq;
ALTER TABLE iace_mitigations
ADD CONSTRAINT iace_mitigations_hazard_name_uniq UNIQUE (hazard_id, name);
CREATE INDEX IF NOT EXISTS idx_iace_mitigations_relevant
ON iace_mitigations(hazard_id) WHERE is_relevant = TRUE;
@@ -0,0 +1,5 @@
-- Migration 155: component presence status for expert review.
-- vorhanden | nicht_vorhanden (engine negation verdict) | geloescht (soft-delete).
-- Only `vorhanden` components feed pattern matching.
ALTER TABLE iace_components
ADD COLUMN IF NOT EXISTS presence_status text NOT NULL DEFAULT 'vorhanden';
@@ -0,0 +1,9 @@
-- Migration 156: ce_marked on iace_components.
-- Marks a bought component that carries its own CE / Declaration of Conformity
-- (finished robot, actuator, drive, safety PLC ...). SAFE semantics: hazards are
-- NOT suppressed (integration/application hazards remain the integrator's job);
-- ce_marked only drives provenance + evidence hints in the UI, and flags that a
-- CE + safety-relevant component still needs its integrated safety function
-- (PL/SIL) validated.
ALTER TABLE iace_components
ADD COLUMN IF NOT EXISTS ce_marked boolean NOT NULL DEFAULT false;