feat(iace): project-wide risk matrix (Severity × Probability)
Adds GET /projects/:id/risk-matrix — a confidence-aware risk view computed on read from each hazard's category/scenario/lifecycle using the SAME model as the GT benchmark (no persistence, so it never goes stale against the model; the hand-defaulted iace_hazards risk columns stay untouched). - risk_matrix.go: EstimateHazardRisk (single source of truth for S/F/W/P + range + level + confidence) and BuildRiskMatrix (per-hazard list + a 5×5 Severity×Probability aggregation grid with dominant level per cell). - Frontend: RiskMatrix grid in the Risikobewertung tab (muted colours per the confidence-aware tonality), level counts + tool-confidence summary, fed by useRiskMatrix. Shows risk for EVERY project, not only GT ones. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -28,3 +28,22 @@ func (h *IACEHandler) GetRiskSuggestion(c *gin.Context) {
|
||||
}
|
||||
c.JSON(http.StatusOK, iace.BuildRiskSuggestion(hz))
|
||||
}
|
||||
|
||||
// GetRiskMatrix handles GET /projects/:id/risk-matrix.
|
||||
// Project-wide confidence-aware risk view computed on read from each hazard (no
|
||||
// persistence): per-hazard risk list + a Severity×Probability aggregation grid.
|
||||
// Uses the same model as the GT benchmark, so matrix numbers match the
|
||||
// comparison. Lets a customer see risk for EVERY project, not only GT ones.
|
||||
func (h *IACEHandler) GetRiskMatrix(c *gin.Context) {
|
||||
projectID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"})
|
||||
return
|
||||
}
|
||||
hazards, err := h.store.ListHazards(c.Request.Context(), projectID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, iace.BuildRiskMatrix(hazards))
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@ func registerIACERoutes(v1 *gin.RouterGroup, h *handlers.IACEHandler) {
|
||||
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/risk-matrix", h.GetRiskMatrix)
|
||||
iaceRoutes.GET("/projects/:id/suggested-norms", h.SuggestProjectNorms)
|
||||
iaceRoutes.POST("/projects/:id/hazards/:hid/reassess", h.ReassessRisk)
|
||||
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
package iace
|
||||
|
||||
import "sort"
|
||||
|
||||
// Project-wide risk view, computed on read from each hazard's category / scenario
|
||||
// / lifecycle using the SAME confidence-aware model as the benchmark
|
||||
// (EstimateSeverity/Frequency/ProbabilityW/AvoidabilityP/RiskRange). Nothing is
|
||||
// persisted — the risk is always derived from the hazard, so it can never go
|
||||
// stale against the model (unlike the hand-defaulted iace_hazards risk columns).
|
||||
// The matrix axis is Severity × Probability(W) — the classic risk matrix — since
|
||||
// those are the two parameters an assessor reads off the grid.
|
||||
|
||||
// HazardRisk is the full confidence-aware risk estimate for one hazard.
|
||||
type HazardRisk struct {
|
||||
Severity int `json:"severity"`
|
||||
Frequency int `json:"frequency"`
|
||||
Probability int `json:"probability"` // W (occurrence)
|
||||
Avoidance int `json:"avoidance"` // P
|
||||
RiskPoint int `json:"risk_point"`
|
||||
RiskLow int `json:"risk_low"`
|
||||
RiskHigh int `json:"risk_high"`
|
||||
Level string `json:"level"` // band of the point
|
||||
LevelRange string `json:"level_range"` // e.g. "mittel–hoch"
|
||||
Confidence string `json:"confidence"` // hoch / mittel / niedrig
|
||||
}
|
||||
|
||||
// EstimateHazardRisk derives the confidence-aware risk estimate for a hazard from
|
||||
// its hazard category, scenario text and lifecycle phases. Single source of truth
|
||||
// reused by the risk matrix endpoint.
|
||||
func EstimateHazardRisk(cats []string, scenario string, lifecyclePhases []string) HazardRisk {
|
||||
s := EstimateSeverity(cats, scenario, 0)
|
||||
f := EstimateFrequency(lifecyclePhases)
|
||||
w := EstimateProbabilityW(cats, scenario)
|
||||
p := EstimateAvoidabilityP(cats, scenario)
|
||||
low, point, high := EstimateRiskRange(s, f, w, p)
|
||||
level, levelRange := RiskLevelRange(low, point, high)
|
||||
return HazardRisk{
|
||||
Severity: s, Frequency: f, Probability: w, Avoidance: p,
|
||||
RiskPoint: point, RiskLow: low, RiskHigh: high,
|
||||
Level: level, LevelRange: levelRange,
|
||||
Confidence: EstimateConfidence(cats, scenario),
|
||||
}
|
||||
}
|
||||
|
||||
// HazardRiskDetail is one hazard plus its risk estimate (for the per-hazard list).
|
||||
type HazardRiskDetail struct {
|
||||
HazardID string `json:"hazard_id"`
|
||||
Name string `json:"name"`
|
||||
Category string `json:"category"`
|
||||
Zone string `json:"zone"`
|
||||
HazardRisk
|
||||
}
|
||||
|
||||
// RiskMatrixCell is one Severity×Probability bucket of the 5×5 grid.
|
||||
type RiskMatrixCell struct {
|
||||
Severity int `json:"severity"`
|
||||
Probability int `json:"probability"`
|
||||
Count int `json:"count"`
|
||||
DominantLevel string `json:"dominant_level"`
|
||||
HazardIDs []string `json:"hazard_ids"`
|
||||
}
|
||||
|
||||
// RiskMatrix is the project risk view: per-hazard detail + the aggregated grid.
|
||||
type RiskMatrix struct {
|
||||
Hazards []HazardRiskDetail `json:"hazards"`
|
||||
Matrix []RiskMatrixCell `json:"matrix"` // only non-empty cells
|
||||
LevelCounts map[string]int `json:"level_counts"`
|
||||
Total int `json:"total"`
|
||||
HighConfidencePct float64 `json:"high_confidence_pct"`
|
||||
}
|
||||
|
||||
// bandRank orders risk levels so a cell's "dominant" (worst) level can be picked.
|
||||
var bandRank = map[string]int{
|
||||
"vernachlaessigbar": 0, "gering": 1, "mittel": 2, "hoch": 3, "kritisch": 4,
|
||||
}
|
||||
|
||||
// BuildRiskMatrix computes the confidence-aware risk for every hazard and
|
||||
// aggregates them into a Severity×Probability grid.
|
||||
func BuildRiskMatrix(hazards []Hazard) RiskMatrix {
|
||||
out := RiskMatrix{
|
||||
LevelCounts: map[string]int{},
|
||||
Total: len(hazards),
|
||||
}
|
||||
type key struct{ s, w int }
|
||||
cells := map[key]*RiskMatrixCell{}
|
||||
hiConf := 0
|
||||
|
||||
for _, h := range hazards {
|
||||
scenario := h.Scenario
|
||||
if scenario == "" {
|
||||
scenario = h.Name
|
||||
}
|
||||
risk := EstimateHazardRisk([]string{h.Category}, scenario, splitLifecyclePhases(h.LifecyclePhase))
|
||||
out.Hazards = append(out.Hazards, HazardRiskDetail{
|
||||
HazardID: h.ID.String(), Name: h.Name, Category: h.Category,
|
||||
Zone: h.HazardousZone, HazardRisk: risk,
|
||||
})
|
||||
out.LevelCounts[risk.Level]++
|
||||
if risk.Confidence == "hoch" {
|
||||
hiConf++
|
||||
}
|
||||
k := key{risk.Severity, risk.Probability}
|
||||
c := cells[k]
|
||||
if c == nil {
|
||||
c = &RiskMatrixCell{Severity: risk.Severity, Probability: risk.Probability, DominantLevel: risk.Level}
|
||||
cells[k] = c
|
||||
}
|
||||
c.Count++
|
||||
c.HazardIDs = append(c.HazardIDs, h.ID.String())
|
||||
if bandRank[risk.Level] > bandRank[c.DominantLevel] {
|
||||
c.DominantLevel = risk.Level
|
||||
}
|
||||
}
|
||||
|
||||
for _, c := range cells {
|
||||
out.Matrix = append(out.Matrix, *c)
|
||||
}
|
||||
// Deterministic order: by severity desc, then probability desc.
|
||||
sort.Slice(out.Matrix, func(i, j int) bool {
|
||||
if out.Matrix[i].Severity != out.Matrix[j].Severity {
|
||||
return out.Matrix[i].Severity > out.Matrix[j].Severity
|
||||
}
|
||||
return out.Matrix[i].Probability > out.Matrix[j].Probability
|
||||
})
|
||||
if len(hazards) > 0 {
|
||||
out.HighConfidencePct = pct(hiConf, len(hazards))
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package iace
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestEstimateHazardRisk_Ordered(t *testing.T) {
|
||||
r := EstimateHazardRisk([]string{"electrical_hazard"}, "Elektrischer Schlag am Gehaeuse", []string{"normal_operation"})
|
||||
if r.Severity < 1 || r.Severity > 5 || r.Probability < 1 || r.Avoidance < 1 {
|
||||
t.Fatalf("params out of range: %+v", r)
|
||||
}
|
||||
if r.RiskLow > r.RiskPoint || r.RiskPoint > r.RiskHigh {
|
||||
t.Errorf("range not ordered: low=%d point=%d high=%d", r.RiskLow, r.RiskPoint, r.RiskHigh)
|
||||
}
|
||||
if r.Confidence != "hoch" { // "elektrisch" keyword → clear contact mode
|
||||
t.Errorf("expected confidence hoch, got %q", r.Confidence)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRiskMatrix(t *testing.T) {
|
||||
hazards := []Hazard{
|
||||
{ID: uuid.New(), Name: "Elektrischer Schlag", Category: "electrical_hazard", Scenario: "Stromschlag am Gehaeuse", LifecyclePhase: "normal_operation"},
|
||||
{ID: uuid.New(), Name: "Elektrischer Schlag 2", Category: "electrical_hazard", Scenario: "Stromschlag an Klemme", LifecyclePhase: "normal_operation"},
|
||||
{ID: uuid.New(), Name: "Quetschen", Category: "mechanical_hazard", Scenario: "Quetschen der Hand", LifecyclePhase: "maintenance"},
|
||||
}
|
||||
m := BuildRiskMatrix(hazards)
|
||||
if m.Total != 3 || len(m.Hazards) != 3 {
|
||||
t.Fatalf("expected 3 hazards, got total=%d hazards=%d", m.Total, len(m.Hazards))
|
||||
}
|
||||
// The two identical electrical hazards land in the same Severity×Probability cell.
|
||||
var sum int
|
||||
for _, c := range m.Matrix {
|
||||
sum += c.Count
|
||||
if c.Severity < 1 || c.Probability < 1 {
|
||||
t.Errorf("cell has invalid coords: %+v", c)
|
||||
}
|
||||
if c.DominantLevel == "" {
|
||||
t.Errorf("cell missing dominant level: %+v", c)
|
||||
}
|
||||
}
|
||||
if sum != 3 {
|
||||
t.Errorf("matrix cell counts sum to %d, want 3", sum)
|
||||
}
|
||||
// Level counts must also total the hazard count.
|
||||
lc := 0
|
||||
for _, n := range m.LevelCounts {
|
||||
lc += n
|
||||
}
|
||||
if lc != 3 {
|
||||
t.Errorf("level counts sum to %d, want 3", lc)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user