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
@@ -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, ",")