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:
@@ -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),
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user