d339d1edc7
ISO 12100 trennt: Hazard (Quelle) → Hazardous Situation (Person exponiert) → Harm (Verletzung).
Bisher war alles in einem Hazard-Record vermischt.
Implementierung als abgeleitetes Feld (keine DB-Migration noetig):
- HazardType Feld auf Hazard Entity ("hazard"|"hazardous_situation"|"harm")
- DeriveHazardType() berechnet Typ aus Scenario/PossibleHarm/Category
- Explizites Override moeglich (HazardType direkt setzen)
- GeneratedHazardType auf HazardPattern fuer Pattern-gesteuerte Zuweisung
- Store: GetHazard/ListHazards setzen HazardType automatisch
- Init-Handler: Fuellt jetzt TriggerEvent, PossibleHarm, AffectedPerson, HazardousZone
aus Pattern-Match-Daten (vorher leer gelassen)
6 neue Tests: ScenarioAndHarm, HarmOnly, CategoryOnly, ExplicitOverride,
EmptyFallback, PatternMatchField
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
407 lines
13 KiB
Go
407 lines
13 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)
|
|
}
|
|
|
|
engine := iace.NewPatternEngine()
|
|
matchOutput := engine.Match(iace.MatchInput{
|
|
ComponentLibraryIDs: componentIDs,
|
|
EnergySourceIDs: energyIDs,
|
|
LifecyclePhases: parseResult.LifecyclePhases,
|
|
CustomTags: parseResult.CustomTags,
|
|
OperationalStates: parseResult.OperationalStates,
|
|
StateTransitions: parseResult.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
|
|
}
|
|
|
|
// 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
|
|
}
|