feat(iace): Phase 1 — Haftungs-Fixes, Massnahmen-Verkabelung, Explainability Engine
Phase 1A — Haftungs-kritische Fixes: - SIL/PL-Badges als "Vorab-Einschaetzung" mit Tooltip gekennzeichnet - Coverage-Disclaimer in CE-Akte, Projekt-Uebersicht und Print-Export - Norm-Referenzen: 42 Kapitelverweise durch Themen-Deskriptoren ersetzt Phase 1B — Massnahmen-Verkabelung: - 16 neue Massnahmen (M201-M216) fuer bisher unabgedeckte Kategorien (communication_failure, hmi_error, firmware_corruption, maintenance, sensor_fault, mode_confusion) - Kategorie-Fallback im Initialize-Endpoint: ordnet Massnahmen aus der Bibliothek automatisch per HazardCategory zu (max 8 pro Kategorie) - Total: 225 → 241 Massnahmen, 0 Kategorien ohne Massnahmen Phase 1C — Explainability Engine: - MatchReason Struct in PatternMatch (type, tag, met) - Pattern Engine schreibt fuer jeden Match strukturierte Begruendungen - Frontend zeigt "Erkannt weil: Komponente X, Energie Y, Kein Ausschluss Z" Weitere Aenderungen: - BAuA/OSHA Regulatory Hints: 3 Enrich-Endpoints (per Hazard, per Measure, Batch) - Dokumente-Tab in IACE-Bibliothek (36.708 Chunks aus Qdrant) - Varianten-UX: Basis-Projekt-Summary auf Varianten-Seite - Projekt-Initialisierung: POST /initialize kettet Parse→Komponenten→Patterns→Hazards→Massnahmen→Normen - 18 pre-existing TS-Fehler gefixt, Route-Konflikt behoben - Component-Library + Measures-Library Tests aktualisiert Tests: Go alle bestanden, TS 0 Fehler, Playwright 141+ bestanden Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,395 @@
|
||||
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,
|
||||
})
|
||||
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
|
||||
hz, cerr := h.store.CreateHazard(ctx, iace.CreateHazardRequest{
|
||||
ProjectID: projectID,
|
||||
ComponentID: defaultCompID,
|
||||
Name: name,
|
||||
Description: scenario,
|
||||
Category: cat,
|
||||
Scenario: scenario,
|
||||
})
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user