Files
breakpilot-compliance/ai-compliance-sdk/internal/api/handlers/iace_handler_init.go
T
Benjamin Admin 2afa5a179b feat(iace): Risikograph EN ISO 13849-1 PLr + Methoden-Kopf im Bericht
Phase 17 of the risk-assessment polish. Two pieces:

A) PLr per EN ISO 13849-1 Anhang A (Risikograph)
   - HazardPattern.DefaultAvoidability (1 = P1, 2 = P2). Optional;
     defaults to P1 if unset (conservative — operator can raise after
     review).
   - ComputePLr(s,f,p) implements the canonical 8-leaf binary tree
     (S1F1P1 -> a, ..., S2F2P2 -> e). Pinned by 8 table-driven tests.
   - SeverityToS / ExposureToF map the existing 1-5 fields to the
     binary S/F at the documented threshold (3).
   - At project initialise, every hazard's Description is appended
     with "Risikograph EN ISO 13849-1 (Anhang A): S2 · F1 · P1 -> PLr c"
     so the audit value is visible without leaving the hazard view.
   - PatternMatch carries DefaultAvoidability so the init handler can
     pick it up without a second pattern lookup.

B) Methoden-Kopf am Bericht
   - GET /clarifications.html now opens with a standardised methodology
     block: ISO 12100 Anhang B (hazard ID) + ISO 13849-1 Anhang A
     (PLr graph) + ISO 12100 6.2/6.3/6.4 (reduction hierarchy). Same
     wording on every export, ready for the Anlagenbauer-Uebergabe.
   - Only norm identifiers — no norm text reproduced.

C) ISO12100Section in Hazard Description
   - When a pattern is labeled with ISO12100Section, the hazard
     description gets a "Klassifikation: EN ISO 12100 Anhang B,
     Abschnitt 6.3.5.4" suffix. Provenance for the auditor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 02:03:10 +02:00

432 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, ",")
// Phase 2: clarification questions are no longer embedded
// in the hazard description — they live in the dedicated
// /clarifications API and the UI loads them on demand.
// The hazard description stays clean and focused on the
// scenario itself. Only the aggregated norm-references
// block is appended below for an at-a-glance audit trail.
desc := mp.ScenarioDE
// Phase 17: PLr per EN ISO 13849-1 Anhang A. The graph
// inputs come from the pattern's DefaultSeverity/Exposure
// (mapped to S1/S2 and F1/F2 at threshold 3) plus
// DefaultAvoidability (P1/P2). If avoidability is unset
// we default to P1 — the conservative direction is
// downward (lower PLr), the operator can raise it
// manually after expert review.
avoid := 1
if mp.DefaultAvoidability == 2 {
avoid = 2
}
if mp.DefaultSeverity > 0 && mp.DefaultExposure > 0 {
sBin := iace.SeverityToS(mp.DefaultSeverity)
fBin := iace.ExposureToF(mp.DefaultExposure)
plr := iace.ComputePLr(sBin, fBin, avoid)
desc += fmt.Sprintf("\n\nRisikograph EN ISO 13849-1 (Anhang A): S%d · F%d · P%d → PLr %s",
sBin, fBin, avoid, plr)
}
if mp.ISO12100Section != "" {
desc += "\n\nKlassifikation: EN ISO 12100 Anhang B, Abschnitt " + mp.ISO12100Section
}
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,
},
})
}