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:
Benjamin Admin
2026-06-11 08:54:47 +02:00
parent 901de1ca97
commit 577ceae4e6
7 changed files with 383 additions and 0 deletions
@@ -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)
}
}