feat(iace): dual-model risk-suggestion endpoint for Risikobewertung tab
CI / detect-changes (push) Successful in 8s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / build-sha-integrity (push) Failing after 4s
CI / validate-canonical-controls (push) Successful in 11s
CI / loc-budget (push) Successful in 14s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Has been skipped
CI / test-go (push) Failing after 38s
CI / iace-gt-coverage (push) Successful in 23s
CI / test-python-backend (push) Has been skipped
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
CI / detect-changes (push) Successful in 8s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / build-sha-integrity (push) Failing after 4s
CI / validate-canonical-controls (push) Successful in 11s
CI / loc-budget (push) Successful in 14s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Has been skipped
CI / test-go (push) Failing after 38s
CI / iace-gt-coverage (push) Successful in 23s
CI / test-python-backend (push) Has been skipped
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
GET /projects/:id/hazards/:hid/risk-suggestion returns BreakPilot's justified starting values for BOTH risk models per hazard: - EN-62061-style F/W/P/S (the Excel format the professional knows) - Fine-Kinney P/E/C (US-recognized) each with a plain-language justification + the visible formula. Read-only and computed from public-data anchors (ESAW/NIOSH/OSHA via the engine estimators) — the professional adjusts the values; no norm table is stored or reproduced. Adds EstimateFrequency (lifecycle -> 1-5) and BuildRiskSuggestion. Go SDK has no OpenAPI baseline, so the only contract surface is the frontend consumer (the new Risikobewertung tab, next). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user