577ceae4e6
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>
130 lines
4.7 KiB
Go
130 lines
4.7 KiB
Go
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
|
||
}
|