Files
breakpilot-compliance/ai-compliance-sdk/internal/api/handlers/iace_handler_init.go
T
Benjamin Admin 2b5376ed54 fix(iace): pattern-specific measures take priority over category fallback
Each hazard now gets measures from its SOURCE PATTERN first
(SuggestedMeasureIDs), then category fallback for remaining slots.

Previously all mechanical hazards got the same generic top-5 measures
(Gefahrstelle eliminieren, Sicherheitsabstaende, Scharfe Kanten...).
Now a KSS-Schlauch hazard gets M420 (Druckfeste Auslegung) first.

SuggestedMeasureIDs added to PatternMatch struct and passed through
from pattern definition to hazard creation to measure assignment.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-15 14:17:32 +02:00

362 lines
12 KiB
Go

package handlers
import (
"fmt"
"net/http"
"strings"
"github.com/breakpilot/ai-compliance-sdk/internal/iace"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// InitStep tracks progress of each initialization step.
type InitStep struct {
Name string `json:"name"`
Status string `json:"status"` // "done", "skipped", "error"
Count int `json:"count,omitempty"`
Details string `json:"details,omitempty"`
}
// InitializeProject handles POST /projects/:id/initialize
// Chains: parse narrative → create components → fire patterns →
// create hazards + measures + verification → suggest norms.
// Idempotent: skips steps that are already populated.
func (h *IACEHandler) InitializeProject(c *gin.Context) {
projectID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"})
return
}
tenantID, err := getTenantID(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx := c.Request.Context()
project, err := h.store.GetProject(ctx, projectID)
if err != nil || project == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "project not found"})
return
}
// Support ?force=true to clear existing hazards + mitigations before re-init
forceReinit := c.Query("force") == "true"
steps := make([]InitStep, 0, 6)
// ── Step 0 (optional): Clear existing data for force re-init ──
if forceReinit {
cleared := 0
if mits, _ := h.store.ListMitigationsByProject(ctx, projectID); len(mits) > 0 {
for _, m := range mits {
_ = h.store.DeleteMitigation(ctx, m.ID)
cleared++
}
}
if hazards, _ := h.store.ListHazards(ctx, projectID); len(hazards) > 0 {
for _, hz := range hazards {
_ = h.store.DeleteHazard(ctx, hz.ID)
cleared++
}
}
steps = append(steps, InitStep{Name: "Alte Daten geloescht", Status: "done", Count: cleared})
}
// ── Step 1: Extract narrative from limits_form ──
narrativeText := extractNarrativeFromMetadata(project.Metadata)
if narrativeText == "" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Grenzen-Formular ist leer. Bitte zuerst die Maschinenbeschreibung ausfuellen.",
})
return
}
// ── Step 2: Parse narrative deterministically ──
parseResult := iace.ParseNarrative(narrativeText, project.MachineType)
steps = append(steps, InitStep{
Name: "Narrative analysiert",
Status: "done",
Count: len(parseResult.Components),
Details: fmt.Sprintf("%d Komponenten, %d Energiequellen, %d Tags", len(parseResult.Components), len(parseResult.EnergySources), len(parseResult.CustomTags)),
})
// ── Step 3: Create components (skip if already exist) ──
existingComps, _ := h.store.ListComponents(ctx, projectID)
compStep := InitStep{Name: "Komponenten erstellt", Status: "skipped"}
if len(existingComps) == 0 && len(parseResult.Components) > 0 {
created := 0
for _, comp := range parseResult.Components {
compType := deriveComponentType(comp.Tags)
_, cerr := h.store.CreateComponent(ctx, iace.CreateComponentRequest{
ProjectID: projectID,
Name: comp.NameDE,
ComponentType: compType,
Description: "Auto-erkannt aus Maschinenbeschreibung (" + comp.MatchedOn + ")",
})
if cerr == nil {
created++
}
}
compStep = InitStep{Name: "Komponenten erstellt", Status: "done", Count: created}
} else if len(existingComps) > 0 {
compStep.Details = "Bereits vorhanden"
compStep.Count = len(existingComps)
}
steps = append(steps, compStep)
// ── Step 4: Fire pattern engine ──
var componentIDs, energyIDs []string
for _, comp := range parseResult.Components {
componentIDs = append(componentIDs, comp.LibraryID)
}
for _, e := range parseResult.EnergySources {
energyIDs = append(energyIDs, e.SourceID)
}
operationalStates := mergeStringSlices(parseResult.OperationalStates, extractOperationalStatesFromMetadata(project.Metadata))
machineTypes := extractIndustrySectorsFromMetadata(project.Metadata)
engine := iace.NewPatternEngine()
matchOutput := engine.Match(iace.MatchInput{
ComponentLibraryIDs: componentIDs,
EnergySourceIDs: energyIDs,
LifecyclePhases: parseResult.LifecyclePhases,
CustomTags: parseResult.CustomTags,
OperationalStates: operationalStates,
StateTransitions: parseResult.StateTransitions,
HumanRoles: parseResult.Roles,
MachineTypes: machineTypes,
})
steps = append(steps, InitStep{
Name: "Patterns abgeglichen",
Status: "done",
Count: len(matchOutput.MatchedPatterns),
})
// ── Step 5: Create hazards from matched patterns (skip if exist) ──
existingHazards, _ := h.store.ListHazards(ctx, projectID)
hazardStep := InitStep{Name: "Gefaehrdungen erstellt", Status: "skipped"}
hazardIDsByCategory := make(map[string][]uuid.UUID)
hazardPatternMeasures := make(map[uuid.UUID][]string)
if len(existingHazards) == 0 && len(matchOutput.MatchedPatterns) > 0 {
comps, _ := h.store.ListComponents(ctx, projectID)
var defaultCompID uuid.UUID
compByName := make(map[string]uuid.UUID)
if len(comps) > 0 {
defaultCompID = comps[0].ID
for _, c := range comps {
compByName[iace.NormalizeDEPublic(c.Name)] = c.ID
}
}
// Build component name set for relevance filtering
compNames := make([]string, 0, len(comps))
for name := range compByName {
compNames = append(compNames, name)
}
created := 0
seenCatZone := make(map[string]bool)
catCount := make(map[string]int)
for _, mp := range matchOutput.MatchedPatterns {
// Narrative relevance filter: skip patterns whose zone/scenario
// mentions machine-specific terms that don't appear in our components
if !isPatternRelevant(mp, narrativeText, compNames) {
continue
}
for _, cat := range mp.HazardCats {
// Per-category cap: limit hazards per category based on relevance
maxForCat := categoryHazardCap(cat, len(comps))
if catCount[cat] >= maxForCat {
continue
}
// Dedup by category + normalized zone
zoneKey := normalizeZoneKey(mp.ZoneDE)
if zoneKey == "" {
zoneKey = mp.PatternID
}
dedupKey := cat + ":" + zoneKey
if seenCatZone[dedupKey] {
continue
}
seenCatZone[dedupKey] = true
name := mp.PatternName
if name == "" {
name = cat
}
if mp.ZoneDE != "" && !containsSubstring(name, mp.ZoneDE) {
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
}
}
}
// Join all applicable lifecycles as comma-separated string
lifecycleStr := strings.Join(mp.ApplicableLifecycles, ",")
hz, cerr := h.store.CreateHazard(ctx, iace.CreateHazardRequest{
ProjectID: projectID,
ComponentID: compID,
Name: name,
Description: mp.ScenarioDE,
Category: cat,
Scenario: mp.ScenarioDE,
Function: iace.EncodeOpStates(mp.OperationalStates),
LifecyclePhase: lifecycleStr,
TriggerEvent: mp.TriggerDE,
PossibleHarm: mp.HarmDE,
AffectedPerson: mp.AffectedDE,
HazardousZone: mp.ZoneDE,
})
if cerr == nil {
created++
catCount[cat]++
hazardIDsByCategory[cat] = append(hazardIDsByCategory[cat], hz.ID)
// Remember this pattern's suggested measures for this hazard
if len(mp.SuggestedMeasureIDs) > 0 {
hazardPatternMeasures[hz.ID] = mp.SuggestedMeasureIDs
}
}
}
}
hazardStep = InitStep{Name: "Gefaehrdungen erstellt", Status: "done", Count: created}
} else if len(existingHazards) > 0 {
hazardStep.Details = "Bereits vorhanden"
hazardStep.Count = len(existingHazards)
for _, eh := range existingHazards {
hazardIDsByCategory[eh.Category] = append(hazardIDsByCategory[eh.Category], eh.ID)
}
}
steps = append(steps, hazardStep)
// ── Step 6: Create mitigations ──
existingMits, _ := h.store.ListMitigationsByProject(ctx, projectID)
mitStep := InitStep{Name: "Massnahmen erstellt", Status: "skipped"}
if len(existingMits) == 0 && len(hazardIDsByCategory) > 0 {
measureLib := iace.GetProtectiveMeasureLibrary()
measureByID := make(map[string]iace.ProtectiveMeasureEntry, len(measureLib))
measuresByCat := make(map[string][]iace.ProtectiveMeasureEntry)
for _, m := range measureLib {
measureByID[m.ID] = m
measuresByCat[m.HazardCategory] = append(measuresByCat[m.HazardCategory], m)
}
created := 0
const maxMitigationsPerHazard = 5
// Build a flat list of all hazard IDs for iteration
var allHazardIDs []uuid.UUID
hazardCatByID := make(map[uuid.UUID]string)
for cat, ids := range hazardIDsByCategory {
for _, id := range ids {
allHazardIDs = append(allHazardIDs, id)
hazardCatByID[id] = cat
}
}
// For each hazard: assign up to maxMitigationsPerHazard measures
// Priority 1: Pattern-specific SuggestedMeasureIDs (from the pattern that created this hazard)
// Priority 2: Category fallback (generic measures for the hazard category)
for _, hazID := range allHazardIDs {
hazCat := hazardCatByID[hazID]
measCat := patternCatToMeasureCat(hazCat)
added := 0
usedIDs := make(map[string]bool)
// Priority 1: Pattern-specific measures
if patternMIDs, ok := hazardPatternMeasures[hazID]; ok {
for _, mid := range patternMIDs {
if added >= maxMitigationsPerHazard {
break
}
entry, ok := measureByID[mid]
if !ok {
continue
}
rt := iace.ReductionType(entry.ReductionType)
if rt == "" {
rt = iace.ReductionTypeInformation
}
_, cerr := h.store.CreateMitigation(ctx, iace.CreateMitigationRequest{
HazardID: hazID, ReductionType: rt,
Name: entry.Name, Description: entry.Description,
})
if cerr == nil {
created++
added++
usedIDs[mid] = true
}
}
}
// Priority 2: Category fallback (skip already-used IDs)
for _, m := range measuresByCat[measCat] {
if added >= maxMitigationsPerHazard || usedIDs[m.ID] {
continue
}
rt := iace.ReductionType(m.ReductionType)
if rt == "" {
rt = iace.ReductionTypeInformation
}
_, cerr := h.store.CreateMitigation(ctx, iace.CreateMitigationRequest{
HazardID: hazID, ReductionType: rt,
Name: m.Name, Description: m.Description,
})
if cerr == nil {
created++
added++
}
}
}
mitStep = InitStep{Name: "Massnahmen erstellt", Status: "done", Count: created}
} else if len(existingMits) > 0 {
mitStep.Details = "Bereits vorhanden"
mitStep.Count = len(existingMits)
}
steps = append(steps, mitStep)
// ── Step 7: Suggest norms ──
var hazardCats []string
for cat := range hazardIDsByCategory {
hazardCats = append(hazardCats, cat)
}
normResult := iace.SuggestNorms(project.MachineType, hazardCats, parseResult.CustomTags)
normCount := 0
if normResult != nil {
normCount = len(normResult.ANorms) + len(normResult.B1Norms) + len(normResult.B2Norms) + len(normResult.CNorms)
}
steps = append(steps, InitStep{Name: "Normen vorgeschlagen", Status: "done", Count: normCount})
// ── Audit trail ──
h.store.AddAuditEntry(ctx, projectID, "project_initialization", projectID,
iace.AuditActionCreate, tenantID.String(), nil,
mustMarshalJSON(map[string]interface{}{"steps": steps}),
)
c.JSON(http.StatusOK, gin.H{
"project_id": projectID.String(),
"steps": steps,
"summary": gin.H{
"components": steps[1].Count, "patterns": steps[2].Count,
"hazards": steps[3].Count, "mitigations": steps[4].Count,
"norms": steps[5].Count,
},
})
}