285b74382a
The Betriebszustand-UI saved states to metadata.operational_states but the initialize handler only read states from the parsed narrative text. Now merges both sources so the UI selection actually affects which patterns fire during initialization. Added integration E2E test that verifies: 2 states → fewer patterns, 9 states → more patterns. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
451 lines
14 KiB
Go
451 lines
14 KiB
Go
package handlers
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
|
|
"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
|
|
}
|
|
|
|
steps := make([]InitStep, 0, 6)
|
|
|
|
// ── 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 {
|
|
// Derive component type from tags
|
|
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)
|
|
}
|
|
|
|
// Merge explicit operational_states from UI with parsed states from narrative
|
|
operationalStates := mergeStringSlices(parseResult.OperationalStates, extractOperationalStatesFromMetadata(project.Metadata))
|
|
stateTransitions := parseResult.StateTransitions
|
|
|
|
engine := iace.NewPatternEngine()
|
|
matchOutput := engine.Match(iace.MatchInput{
|
|
ComponentLibraryIDs: componentIDs,
|
|
EnergySourceIDs: energyIDs,
|
|
LifecyclePhases: parseResult.LifecyclePhases,
|
|
CustomTags: parseResult.CustomTags,
|
|
OperationalStates: operationalStates,
|
|
StateTransitions: stateTransitions,
|
|
HumanRoles: parseResult.Roles,
|
|
})
|
|
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)
|
|
|
|
if len(existingHazards) == 0 && len(matchOutput.MatchedPatterns) > 0 {
|
|
// Get first component for hazard assignment
|
|
comps, _ := h.store.ListComponents(ctx, projectID)
|
|
var defaultCompID uuid.UUID
|
|
if len(comps) > 0 {
|
|
defaultCompID = comps[0].ID
|
|
}
|
|
|
|
// Deduplicate by category — one hazard per category
|
|
created := 0
|
|
seenCat := make(map[string]bool)
|
|
for _, mp := range matchOutput.MatchedPatterns {
|
|
for _, cat := range mp.HazardCats {
|
|
if seenCat[cat] {
|
|
continue
|
|
}
|
|
seenCat[cat] = true
|
|
|
|
name := mp.PatternName
|
|
if name == "" {
|
|
name = cat
|
|
}
|
|
scenario := mp.ScenarioDE
|
|
hazardType := mp.GeneratedHazardType
|
|
if hazardType == "" {
|
|
hazardType = iace.DefaultHazardType
|
|
}
|
|
hz, cerr := h.store.CreateHazard(ctx, iace.CreateHazardRequest{
|
|
ProjectID: projectID,
|
|
ComponentID: defaultCompID,
|
|
Name: name,
|
|
Description: scenario,
|
|
Category: cat,
|
|
Scenario: scenario,
|
|
TriggerEvent: mp.TriggerDE,
|
|
PossibleHarm: mp.HarmDE,
|
|
AffectedPerson: mp.AffectedDE,
|
|
HazardousZone: mp.ZoneDE,
|
|
})
|
|
if cerr == nil {
|
|
created++
|
|
hazardIDsByCategory[cat] = hz.ID
|
|
}
|
|
}
|
|
}
|
|
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] = eh.ID
|
|
}
|
|
}
|
|
steps = append(steps, hazardStep)
|
|
|
|
// ── Step 6: Create mitigations (pattern-suggested + category fallback) ──
|
|
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
|
|
usedMeasureIDs := make(map[string]bool)
|
|
|
|
// A) Pattern-suggested measures (direct reference)
|
|
for _, sm := range matchOutput.SuggestedMeasures {
|
|
entry, ok := measureByID[sm.MeasureID]
|
|
if !ok || usedMeasureIDs[sm.MeasureID] {
|
|
continue
|
|
}
|
|
hazardID := findHazardForMeasureByCategory(entry.HazardCategory, hazardIDsByCategory)
|
|
if hazardID == uuid.Nil {
|
|
continue
|
|
}
|
|
rt := iace.ReductionType(entry.ReductionType)
|
|
if rt == "" {
|
|
rt = iace.ReductionTypeInformation
|
|
}
|
|
_, cerr := h.store.CreateMitigation(ctx, iace.CreateMitigationRequest{
|
|
HazardID: hazardID,
|
|
ReductionType: rt,
|
|
Name: entry.Name,
|
|
Description: entry.Description,
|
|
})
|
|
if cerr == nil {
|
|
created++
|
|
usedMeasureIDs[sm.MeasureID] = true
|
|
}
|
|
}
|
|
|
|
// B) Category fallback — for each hazard category, add measures
|
|
// from the library that match (but weren't pattern-suggested)
|
|
for hazCat, hazID := range hazardIDsByCategory {
|
|
measCat := patternCatToMeasureCat(hazCat)
|
|
candidates := measuresByCat[measCat]
|
|
added := 0
|
|
for _, m := range candidates {
|
|
if usedMeasureIDs[m.ID] || added >= 8 {
|
|
break
|
|
}
|
|
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++
|
|
usedMeasureIDs[m.ID] = true
|
|
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,
|
|
},
|
|
})
|
|
}
|
|
|
|
// extractNarrativeFromMetadata builds a combined text from the limits_form.
|
|
func extractNarrativeFromMetadata(metadata json.RawMessage) string {
|
|
if metadata == nil {
|
|
return ""
|
|
}
|
|
var meta map[string]json.RawMessage
|
|
if err := json.Unmarshal(metadata, &meta); err != nil {
|
|
return ""
|
|
}
|
|
limitsRaw, ok := meta["limits_form"]
|
|
if !ok {
|
|
return ""
|
|
}
|
|
var limits map[string]interface{}
|
|
if err := json.Unmarshal(limitsRaw, &limits); err != nil {
|
|
return ""
|
|
}
|
|
|
|
textFields := []string{
|
|
"general_description", "intended_purpose", "foreseeable_misuse",
|
|
"space_limits", "time_limits", "environmental_conditions",
|
|
"energy_sources", "materials_processed", "operating_modes",
|
|
"maintenance_requirements", "personnel_requirements",
|
|
"interfaces_description", "control_system_description",
|
|
"safety_functions_description",
|
|
}
|
|
var result string
|
|
for _, field := range textFields {
|
|
if v, ok := limits[field]; ok {
|
|
if s, ok := v.(string); ok && s != "" {
|
|
result += s + "\n\n"
|
|
}
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// patternCatToMeasureCat maps pattern hazard categories to measure categories.
|
|
// Patterns use "mechanical_hazard", measures use "mechanical".
|
|
func patternCatToMeasureCat(patternCat string) string {
|
|
m := map[string]string{
|
|
"mechanical_hazard": "mechanical",
|
|
"electrical_hazard": "electrical",
|
|
"thermal_hazard": "thermal",
|
|
"noise_vibration": "noise_vibration",
|
|
"pneumatic_hydraulic": "pneumatic_hydraulic",
|
|
"material_environmental": "material_environmental",
|
|
"ergonomic": "ergonomic",
|
|
"ergonomic_hazard": "ergonomic",
|
|
"software_fault": "software_control",
|
|
"safety_function_failure": "safety_function",
|
|
"fire_explosion": "thermal",
|
|
"radiation_hazard": "material_environmental",
|
|
"unauthorized_access": "cyber_network",
|
|
"communication_failure": "cyber_network",
|
|
"firmware_corruption": "cyber_network",
|
|
"logging_audit_failure": "cyber_network",
|
|
"ai_misclassification": "ai_specific",
|
|
"false_classification": "ai_specific",
|
|
"model_drift": "ai_specific",
|
|
"data_poisoning": "ai_specific",
|
|
"sensor_spoofing": "ai_specific",
|
|
"unintended_bias": "ai_specific",
|
|
"sensor_fault": "software_control",
|
|
"configuration_error": "software_control",
|
|
"update_failure": "software_control",
|
|
"hmi_error": "software_control",
|
|
"emc_hazard": "electrical",
|
|
"maintenance_hazard": "mechanical",
|
|
"mode_confusion": "software_control",
|
|
}
|
|
if cat, ok := m[patternCat]; ok {
|
|
return cat
|
|
}
|
|
return "general"
|
|
}
|
|
|
|
// deriveComponentType guesses the component type from its tags.
|
|
func deriveComponentType(tags []string) iace.ComponentType {
|
|
for _, t := range tags {
|
|
switch {
|
|
case t == "software" || t == "has_software":
|
|
return iace.ComponentTypeSoftware
|
|
case t == "firmware" || t == "has_firmware":
|
|
return iace.ComponentTypeFirmware
|
|
case t == "has_ai" || t == "ai_model":
|
|
return iace.ComponentTypeAIModel
|
|
case t == "hmi" || t == "display" || t == "touchscreen":
|
|
return iace.ComponentTypeHMI
|
|
case t == "sensor" || t == "camera":
|
|
return iace.ComponentTypeSensor
|
|
case t == "electric_motor" || t == "electric_drive":
|
|
return iace.ComponentTypeElectrical
|
|
case t == "networked" || t == "ethernet" || t == "wifi":
|
|
return iace.ComponentTypeNetwork
|
|
case t == "hydraulic" || t == "pneumatic":
|
|
return iace.ComponentTypeActuator
|
|
}
|
|
}
|
|
return iace.ComponentTypeMechanical
|
|
}
|
|
|
|
// extractOperationalStatesFromMetadata reads the explicit operational_states
|
|
// selection that the user set via the Betriebszustand-UI.
|
|
func extractOperationalStatesFromMetadata(metadata json.RawMessage) []string {
|
|
if metadata == nil {
|
|
return nil
|
|
}
|
|
var meta map[string]json.RawMessage
|
|
if err := json.Unmarshal(metadata, &meta); err != nil {
|
|
return nil
|
|
}
|
|
raw, ok := meta["operational_states"]
|
|
if !ok {
|
|
return nil
|
|
}
|
|
var states []string
|
|
if err := json.Unmarshal(raw, &states); err != nil {
|
|
return nil
|
|
}
|
|
return states
|
|
}
|
|
|
|
// mergeStringSlices merges two string slices, deduplicating entries.
|
|
func mergeStringSlices(a, b []string) []string {
|
|
seen := make(map[string]bool, len(a)+len(b))
|
|
var result []string
|
|
for _, s := range a {
|
|
if !seen[s] {
|
|
seen[s] = true
|
|
result = append(result, s)
|
|
}
|
|
}
|
|
for _, s := range b {
|
|
if !seen[s] {
|
|
seen[s] = true
|
|
result = append(result, s)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// findHazardForMeasureByCategory finds a matching hazard for a measure.
|
|
func findHazardForMeasureByCategory(measureCat string, hazardsByCategory map[string]uuid.UUID) uuid.UUID {
|
|
// Direct match
|
|
if id, ok := hazardsByCategory[measureCat]; ok {
|
|
return id
|
|
}
|
|
// Fuzzy match — "mechanical" matches "mechanical_hazard"
|
|
for cat, id := range hazardsByCategory {
|
|
if len(measureCat) > 3 && len(cat) > 3 && cat[:4] == measureCat[:4] {
|
|
return id
|
|
}
|
|
}
|
|
// Fallback: first hazard
|
|
for _, id := range hazardsByCategory {
|
|
return id
|
|
}
|
|
return uuid.Nil
|
|
}
|