e9002175ac
Adds a curated database of safety-relevant features for the major
manufacturers across mechanical/plant engineering, written entirely in
own words with norm anchors. No verbatim manufacturer texts — therefore
no copyright issue:
- Markennennung (§ 23 MarkenG nominative use) is permitted.
- Fakten ueber Produkt-Sicherheitsfunktionen are not protected by § 2
UrhG (only Werke, not facts).
- NormReferences contain only the identifiers (e.g. "EN ISO 13849-1
PLd Kat.3"), never the norm text itself.
Coverage (52 entries across 12 categories):
Industrieroboter (10): FANUC DCS, KUKA SafeOperation, ABB SafeMove,
Yaskawa FSU, Staeubli CS9, Kawasaki Cubic-S, Mitsubishi MELFA,
Universal Robots PolyScope, Doosan PRS, Comau SafeNet
CNC/WZM (8): DMG MORI, Mazak, TRUMPF, Okuma, Hermle, Heidenhain
SPLC, GROB, Heller
Pneumatik (4): Festo, SMC, AVENTICS, Parker
Hydraulik (3): Bosch Rexroth, HAWE, HYDAC
Safety-PLC / Sicherheitstechnik (8): PILZ, SICK, Schmersal, Euchner,
Leuze, Phoenix Contact, Banner, Wieland
Standard-PLC (5): Siemens, Beckhoff, Rockwell, Schneider, B&R
Pressen (3): Schuler, Bruderer, AIDA
Spritzguss (3): Arburg, KraussMaffei, ENGEL
Verpackung (2): Krones, Bosch Packaging/Syntegon
Laser/Schweissen (3): Bystronic, Amada, Fronius
Foerdertechnik (2): Interroll, SEW EURODRIVE
Engine integration:
- LookupManufacturerFeaturesInText() scans the project narrative for
any of the manufacturer aliases (case-insensitive, umlaut-tolerant).
- Init-Handler appends matched feature clarifications to the relevant
hazard's "Mit Anlagenbauer zu klaeren:" block — for the right
HazardCategory only (e.g. FANUC DCS only on mechanical_hazard).
- For a Bremse project narrative mentioning "Fanuc Robodrill", the
engine now adds clarification questions like "Ist DCS am Roboter
konfiguriert?" to relevant mechanical hazards automatically.
Tests: 7 new pin tests — manufacturer count, norm prefixes, FANUC/KUKA
detection in narrative, umlaut robustness (Staeubli vs Staubli).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
440 lines
15 KiB
Go
440 lines
15 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]uuid.UUID) // dedupKey → hazardID
|
|
catCount := make(map[string]int)
|
|
for _, mp := range matchOutput.MatchedPatterns {
|
|
// Narrative relevance filter
|
|
if !isPatternRelevant(mp, narrativeText, compNames) {
|
|
continue
|
|
}
|
|
|
|
for _, cat := range mp.HazardCats {
|
|
maxForCat := categoryHazardCap(cat, len(comps))
|
|
if catCount[cat] >= maxForCat {
|
|
continue
|
|
}
|
|
|
|
zoneKey := normalizeZoneKey(mp.ZoneDE)
|
|
if zoneKey == "" {
|
|
zoneKey = mp.PatternID
|
|
}
|
|
dedupKey := cat + ":" + zoneKey
|
|
|
|
// If this dedupKey already exists but current pattern has
|
|
// SuggestedMeasureIDs, add them to the existing hazard
|
|
if existingHzID, exists := seenCatZone[dedupKey]; exists {
|
|
if len(mp.SuggestedMeasureIDs) > 0 {
|
|
existing := hazardPatternMeasures[existingHzID]
|
|
hazardPatternMeasures[existingHzID] = append(existing, mp.SuggestedMeasureIDs...)
|
|
}
|
|
continue
|
|
}
|
|
|
|
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, ",")
|
|
|
|
// Append pattern-defined clarification questions to the
|
|
// description so they're visible in the UI without DB-
|
|
// schema changes. The engine does NOT invent commentary;
|
|
// it only hangs the standard ISO/EN clarification points
|
|
// onto the hazard so the operator knows what to verify
|
|
// with the Anlagenbauer.
|
|
desc := mp.ScenarioDE
|
|
clarBuckets := mp.ClarificationQuestionsDE
|
|
// Manufacturer-specific clarifications: if the narrative
|
|
// mentions a known manufacturer (FANUC/KUKA/Siemens/...),
|
|
// append its feature-specific questions to the matching
|
|
// hazard categories. Markennennung ist nominative use
|
|
// (§ 23 MarkenG), Fakten ueber Safety-Features sind nicht
|
|
// urheberrechtlich geschuetzt.
|
|
for _, mf := range iace.LookupManufacturerFeaturesInText(narrativeText) {
|
|
applies := len(mf.AppliesToHazardCats) == 0
|
|
for _, hc := range mf.AppliesToHazardCats {
|
|
if hc == cat {
|
|
applies = true
|
|
break
|
|
}
|
|
}
|
|
if !applies {
|
|
continue
|
|
}
|
|
prefix := mf.Manufacturer + " (" + mf.FeatureName + "): "
|
|
for _, q := range mf.Clarifications {
|
|
clarBuckets = append(clarBuckets, prefix+q)
|
|
}
|
|
}
|
|
if len(clarBuckets) > 0 {
|
|
desc += "\n\nMit Anlagenbauer zu klaeren:"
|
|
for _, q := range clarBuckets {
|
|
desc += "\n- " + q
|
|
}
|
|
}
|
|
|
|
hz, cerr := h.store.CreateHazard(ctx, iace.CreateHazardRequest{
|
|
ProjectID: projectID,
|
|
ComponentID: compID,
|
|
Name: name,
|
|
Description: desc,
|
|
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]++
|
|
seenCatZone[dedupKey] = hz.ID
|
|
hazardIDsByCategory[cat] = append(hazardIDsByCategory[cat], hz.ID)
|
|
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: only pattern-specific SuggestedMeasureIDs are
|
|
// used, FILTERED by category. Measures whose HazardCategory is
|
|
// incompatible with the pattern's accepted set are skipped with a
|
|
// MEASURE-SKIP log entry. There is NO category fallback any more —
|
|
// if the pattern author left a hazard without applicable measures,
|
|
// the hazard is created with zero mitigations and the operator must
|
|
// consult an expert. This is the only honest answer: silently
|
|
// inventing generic defaults (the previous behavior) produced
|
|
// nonsense like "Rotationsbewegung vermeiden" for a sharp-edge
|
|
// hazard. See feat/iace-measure-category-filter for context.
|
|
_ = measuresByCat // retained for backwards-compat read by other code paths
|
|
_ = patternCatToMeasureCat
|
|
zeroMitigationHazards := 0
|
|
for _, hazID := range allHazardIDs {
|
|
hazCat := hazardCatByID[hazID]
|
|
accepted := acceptableMeasureCategories(hazCat)
|
|
added := 0
|
|
// Aggregate norm references across all kept mitigations for this
|
|
// hazard so we can attach a single "Referenzierte Normen" line
|
|
// to the hazard description below.
|
|
var hazardNorms []string
|
|
seenNorm := map[string]bool{}
|
|
|
|
if patternMIDs, ok := hazardPatternMeasures[hazID]; ok {
|
|
for _, mid := range patternMIDs {
|
|
if added >= maxMitigationsPerHazard {
|
|
break
|
|
}
|
|
entry, ok := measureByID[mid]
|
|
if !ok {
|
|
continue
|
|
}
|
|
if !isCategoryCompatible(entry.HazardCategory, accepted) {
|
|
fmt.Printf("MEASURE-SKIP: pattern-cat=%s acceptable=%v but mid=%s has cat=%s (%q) — skipping mismatch\n",
|
|
hazCat, keysOf(accepted), mid, entry.HazardCategory, entry.Name)
|
|
continue
|
|
}
|
|
|
|
rt := iace.ReductionType(entry.ReductionType)
|
|
if rt == "" {
|
|
rt = iace.ReductionTypeInformation
|
|
}
|
|
mitDesc := entry.Description
|
|
if len(entry.NormReferences) > 0 {
|
|
mitDesc += "\n\nNormen: " + strings.Join(entry.NormReferences, " | ")
|
|
for _, n := range entry.NormReferences {
|
|
if !seenNorm[n] {
|
|
seenNorm[n] = true
|
|
hazardNorms = append(hazardNorms, n)
|
|
}
|
|
}
|
|
}
|
|
_, cerr := h.store.CreateMitigation(ctx, iace.CreateMitigationRequest{
|
|
HazardID: hazID, ReductionType: rt,
|
|
Name: entry.Name, Description: mitDesc,
|
|
})
|
|
if cerr != nil {
|
|
fmt.Printf("MEASURE-ERROR: mid=%s name=%s err=%v\n", mid, entry.Name, cerr)
|
|
} else {
|
|
created++
|
|
added++
|
|
}
|
|
}
|
|
}
|
|
// Append the aggregated norm list to the hazard so the UI shows
|
|
// a single "Referenzierte Normen" panel per hazard.
|
|
if len(hazardNorms) > 0 {
|
|
if existing, getErr := h.store.GetHazard(ctx, hazID); getErr == nil && existing != nil {
|
|
if !strings.Contains(existing.Description, "Referenzierte Normen:") {
|
|
newDesc := existing.Description + "\n\nReferenzierte Normen: " + strings.Join(hazardNorms, " | ")
|
|
_, _ = h.store.UpdateHazard(ctx, hazID, map[string]interface{}{
|
|
"description": newDesc,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
if added == 0 {
|
|
zeroMitigationHazards++
|
|
fmt.Printf("COVERAGE-GAP: hazard %s (cat=%s) has no pattern-specific measures — operator must consult expert\n",
|
|
hazID, hazCat)
|
|
}
|
|
}
|
|
if zeroMitigationHazards > 0 {
|
|
fmt.Printf("COVERAGE-GAP-SUMMARY: %d/%d hazards in this project have no mitigations and need expert review\n",
|
|
zeroMitigationHazards, len(allHazardIDs))
|
|
}
|
|
patternMeasureCount := 0
|
|
for _, mids := range hazardPatternMeasures {
|
|
patternMeasureCount += len(mids)
|
|
}
|
|
mitStep = InitStep{Name: "Massnahmen erstellt", Status: "done", Count: created,
|
|
Details: fmt.Sprintf("%d pattern-spezifisch fuer %d Hazards", patternMeasureCount, len(hazardPatternMeasures))}
|
|
} 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,
|
|
},
|
|
})
|
|
}
|