diff --git a/ai-compliance-sdk/internal/api/handlers/iace_handler_risk.go b/ai-compliance-sdk/internal/api/handlers/iace_handler_risk.go new file mode 100644 index 00000000..91c8f83e --- /dev/null +++ b/ai-compliance-sdk/internal/api/handlers/iace_handler_risk.go @@ -0,0 +1,30 @@ +package handlers + +import ( + "net/http" + + "github.com/breakpilot/ai-compliance-sdk/internal/iace" + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +// GetRiskSuggestion returns BreakPilot's justified dual-model risk suggestion +// for a hazard: the EN-62061-style F/W/P/S model and the Fine-Kinney P/E/C +// model, each with suggested values, justifications and the visible formula. +// Read-only and computed from public-data anchors — the professional adjusts +// the values; no norm table is stored or reproduced. +// +// GET /projects/:id/hazards/:hid/risk-suggestion +func (h *IACEHandler) GetRiskSuggestion(c *gin.Context) { + hid, err := uuid.Parse(c.Param("hid")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid hazard ID"}) + return + } + hz, err := h.store.GetHazard(c.Request.Context(), hid) + if err != nil || hz == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "hazard not found"}) + return + } + c.JSON(http.StatusOK, iace.BuildRiskSuggestion(hz)) +} diff --git a/ai-compliance-sdk/internal/app/routes_iace.go b/ai-compliance-sdk/internal/app/routes_iace.go index 05c5dc6b..0c1da632 100644 --- a/ai-compliance-sdk/internal/app/routes_iace.go +++ b/ai-compliance-sdk/internal/app/routes_iace.go @@ -68,6 +68,7 @@ func registerIACERoutes(v1 *gin.RouterGroup, h *handlers.IACEHandler) { iaceRoutes.POST("/projects/:id/hazards/:hid/suggest-measures", h.SuggestMeasuresForHazard) iaceRoutes.POST("/projects/:id/mitigations/:mid/suggest-evidence", h.SuggestEvidenceForMitigation) iaceRoutes.POST("/projects/:id/hazards/:hid/assess", h.AssessRisk) + iaceRoutes.GET("/projects/:id/hazards/:hid/risk-suggestion", h.GetRiskSuggestion) iaceRoutes.GET("/projects/:id/risk-summary", h.GetRiskSummary) iaceRoutes.GET("/projects/:id/suggested-norms", h.SuggestProjectNorms) iaceRoutes.POST("/projects/:id/hazards/:hid/reassess", h.ReassessRisk) diff --git a/ai-compliance-sdk/internal/iace/risk_estimation.go b/ai-compliance-sdk/internal/iace/risk_estimation.go index b2d19800..f4e44c1e 100644 --- a/ai-compliance-sdk/internal/iace/risk_estimation.go +++ b/ai-compliance-sdk/internal/iace/risk_estimation.go @@ -166,6 +166,30 @@ func EstimateSeverity(cats []string, scenario string, defaultS int) int { return s } +// EstimateFrequency maps the active lifecycle phases to a 1-5 exposure-frequency +// value for the EN-62061-style model (how often a person is exposed to the +// task). Our own scale, no norm table. +func EstimateFrequency(phases []string) int { + has := func(n string) bool { + for _, p := range phases { + if strings.Contains(p, n) { + return true + } + } + return false + } + switch { + case has("normal_operation") || has("auto_operation") || has("manual_operation"): + return 4 + case has("setup") || has("maintenance") || has("cleaning") || has("changeover"): + return 3 + case len(phases) > 0: + return 2 + default: + return 3 + } +} + // EstimateRiskLevel combines the four parameters into BreakPilot's OWN risk // index and band. The index is a generic severity-weighted sum of the // likelihood factors — index = S * (F + W + P) — i.e. basic arithmetic on the diff --git a/ai-compliance-sdk/internal/iace/risk_suggestion.go b/ai-compliance-sdk/internal/iace/risk_suggestion.go new file mode 100644 index 00000000..9fc28e62 --- /dev/null +++ b/ai-compliance-sdk/internal/iace/risk_suggestion.go @@ -0,0 +1,111 @@ +package iace + +import ( + "fmt" + "strings" +) + +// Dual-model risk suggestion for the "Risikobewertung" tab. BreakPilot proposes +// justified starting values for BOTH a EN-62061-style model (F/W/P/S) and the +// Fine-Kinney model (P/E/C); the professional adjusts them (e.g. from his own +// licensed DIN/Beuth data). We expose the FORMULAS and computed values only — +// no norm table is stored or reproduced. + +// SuggestedValue is a proposed parameter value plus the plain-language reason. +type SuggestedValue struct { + Value float64 `json:"value"` + Justification string `json:"justification"` +} + +// EN62061Suggestion is the EN-62061-style risk (the Excel format the +// professional knows): R = S * (F + W + P). +type EN62061Suggestion struct { + Severity SuggestedValue `json:"severity"` + Frequency SuggestedValue `json:"frequency"` + Probability SuggestedValue `json:"probability"` + Avoidance SuggestedValue `json:"avoidance"` + Score int `json:"score"` + Level string `json:"level"` + Formula string `json:"formula"` +} + +// FineKinneySuggestion is the Fine-Kinney risk (US-recognized): R = P * E * C. +type FineKinneySuggestion struct { + Probability SuggestedValue `json:"probability"` + Exposure SuggestedValue `json:"exposure"` + Consequence SuggestedValue `json:"consequence"` + Score float64 `json:"score"` + Band string `json:"band"` + Action string `json:"action"` + Formula string `json:"formula"` +} + +// RiskSuggestion carries both models for one hazard. +type RiskSuggestion struct { + HazardID string `json:"hazard_id"` + ContactMode string `json:"contact_mode"` + EN62061 EN62061Suggestion `json:"en62061"` + FineKinney FineKinneySuggestion `json:"fine_kinney"` + Note string `json:"note"` +} + +// BuildRiskSuggestion derives both models' justified starting values from the +// hazard's category/scenario/lifecycle, using only public-data anchors. +func BuildRiskSuggestion(hz *Hazard) RiskSuggestion { + cats := []string{hz.Category} + scenario := hz.Scenario + if scenario == "" { + scenario = hz.Name + } + lifecycle := splitLifecyclePhases(hz.LifecyclePhase) + + mode := DetectContactMode(cats, scenario) + modeLabel := mode + if modeLabel == "" { + modeLabel = "unbestimmt" + } + + // EN-62061-style (F/W/P/S) + s := EstimateSeverity(cats, scenario, 0) + f := EstimateFrequency(lifecycle) + w := EstimateProbabilityW(cats, scenario) + p := EstimateAvoidabilityP(cats, scenario) + idx, level := EstimateRiskLevel(s, f, w, p) + + // Fine-Kinney (P/E/C) + fk := SuggestFineKinney(cats, scenario, lifecycle, 0) + + return RiskSuggestion{ + HazardID: hz.ID.String(), + ContactMode: modeLabel, + EN62061: EN62061Suggestion{ + Severity: SuggestedValue{float64(s), fmt.Sprintf("Schwere S%d aus Verletzungsbild der Kontaktart '%s' (NIOSH/OSHA/MIL-STD-882)", s, modeLabel)}, + Frequency: SuggestedValue{float64(f), "Haeufigkeit F aus Lebensphasen-Exposition des Projekts"}, + Probability: SuggestedValue{float64(w), fmt.Sprintf("Wahrscheinlichkeit W aus ESAW-Haeufigkeit der Kontaktart '%s'", modeLabel)}, + Avoidance: SuggestedValue{float64(p), fmt.Sprintf("Vermeidbarkeit P aus Kinematik der Kontaktart '%s'", modeLabel)}, + Score: idx, + Level: level, + Formula: "R = S × (F + W + P)", + }, + FineKinney: FineKinneySuggestion{ + Probability: SuggestedValue{fk.Probability.Value, fk.Probability.Justification}, + Exposure: SuggestedValue{fk.Exposure.Value, fk.Exposure.Justification}, + Consequence: SuggestedValue{fk.Consequence.Value, fk.Consequence.Justification}, + Score: fk.Score, + Band: fk.Band, + Action: fk.Action, + Formula: "R = P × E × C", + }, + Note: "Begruendete Vorschlagswerte (BreakPilot, oeffentliche Datenquellen). Vom Sachverstaendigen anpassbar.", + } +} + +func splitLifecyclePhases(s string) []string { + var out []string + for _, p := range strings.Split(s, ",") { + if p = strings.TrimSpace(p); p != "" { + out = append(out, p) + } + } + return out +} diff --git a/ai-compliance-sdk/internal/iace/risk_suggestion_test.go b/ai-compliance-sdk/internal/iace/risk_suggestion_test.go new file mode 100644 index 00000000..983075fc --- /dev/null +++ b/ai-compliance-sdk/internal/iace/risk_suggestion_test.go @@ -0,0 +1,44 @@ +package iace + +import ( + "testing" + + "github.com/google/uuid" +) + +func TestBuildRiskSuggestion_DualModel(t *testing.T) { + hz := &Hazard{ + ID: uuid.New(), + Name: "Quetschung unter absenkender Hubplattform", + Scenario: "Bediener wird zwischen Plattform und Boden eingeklemmt (Quetschung)", + Category: "mechanical_hazard", + LifecyclePhase: "normal_operation, maintenance", + } + rs := BuildRiskSuggestion(hz) + + if rs.ContactMode != "crushing" { + t.Errorf("contact mode = %q, want crushing", rs.ContactMode) + } + // EN-62061 side populated + formula exposed + if rs.EN62061.Severity.Value < 1 || rs.EN62061.Score < 1 || rs.EN62061.Level == "" { + t.Errorf("EN62061 not populated: %+v", rs.EN62061) + } + if rs.EN62061.Formula == "" || rs.FineKinney.Formula == "" { + t.Error("both formulas must be exposed for the professional") + } + // Fine-Kinney side populated, score == P*E*C + fk := rs.FineKinney + if fk.Probability.Value <= 0 || fk.Exposure.Value <= 0 || fk.Consequence.Value <= 0 { + t.Errorf("FK params not populated: %+v", fk) + } + if want := fk.Probability.Value * fk.Exposure.Value * fk.Consequence.Value; fk.Score != want { + t.Errorf("FK score = %v, want P*E*C = %v", fk.Score, want) + } + if fk.Band == "" { + t.Error("FK band must be set") + } + // Justifications are the whole point (nachvollziehbar) + if rs.EN62061.Probability.Justification == "" || fk.Consequence.Justification == "" { + t.Error("justifications must be present on both models") + } +}