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,111 @@
'use client'
import type { RiskMatrixData } from '../_hooks/useRiskMatrix'
// Severity × Probability(W) grid — the classic risk matrix. Cells are coloured by
// the WORST (dominant) risk band of the hazards they hold, deliberately muted
// (confidence-aware tonality: inform, don't alarm). The number is the hazard
// count in that bucket.
const LEVELS = ['vernachlaessigbar', 'gering', 'mittel', 'hoch', 'kritisch'] as const
const LEVEL_LABEL: Record<string, string> = {
vernachlaessigbar: 'vernachlässigbar',
gering: 'gering',
mittel: 'mittel',
hoch: 'hoch',
kritisch: 'kritisch',
}
const LEVEL_BG: Record<string, string> = {
kritisch: 'bg-red-200 text-red-900 dark:bg-red-900/50 dark:text-red-200',
hoch: 'bg-orange-200 text-orange-900 dark:bg-orange-900/50 dark:text-orange-200',
mittel: 'bg-yellow-200 text-yellow-900 dark:bg-yellow-900/40 dark:text-yellow-200',
gering: 'bg-lime-200 text-lime-900 dark:bg-lime-900/40 dark:text-lime-200',
vernachlaessigbar: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-200',
}
const LEVEL_DOT: Record<string, string> = {
kritisch: 'bg-red-400', hoch: 'bg-orange-400', mittel: 'bg-yellow-400',
gering: 'bg-lime-400', vernachlaessigbar: 'bg-green-300',
}
export function RiskMatrix({ data }: { data: RiskMatrixData }) {
if (!data || data.total === 0) return null
// Index cells by "severity-probability" for O(1) lookup.
const cellMap = new Map(data.matrix.map((c) => [`${c.severity}-${c.probability}`, c]))
const severities = [5, 4, 3, 2, 1] // rows, worst on top
const probabilities = [1, 2, 3, 4, 5] // columns
return (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4 space-y-4">
<div>
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300">Risiko-Matrix (Schwere × Wahrscheinlichkeit)</h3>
<p className="text-xs text-gray-500 mt-0.5">
{data.total} Gefährdungen, automatisch geschätzt (BreakPilot-Modell). Die Zahl je Feld ist die
Anzahl der Gefährdungen; die Farbe zeigt das höchste Risiko-Band im Feld. Schätzung Sie
entscheiden mit Ihrem/Ihrer Sachverständigen.
</p>
</div>
<div className="flex gap-4 flex-wrap items-start">
{/* Matrix grid */}
<div className="inline-block">
<div className="flex">
<div className="w-16" />
<div className="text-center text-[10px] text-gray-500 flex-1" style={{ minWidth: 200 }}>
Wahrscheinlichkeit (W)
</div>
</div>
<div className="flex">
<div className="w-16 flex items-center justify-center -rotate-90 text-[10px] text-gray-500 whitespace-nowrap">
Schwere (S)
</div>
<table className="border-collapse">
<tbody>
{severities.map((s) => (
<tr key={s}>
<td className="text-[10px] text-gray-400 pr-1 text-right align-middle w-4">{s}</td>
{probabilities.map((w) => {
const cell = cellMap.get(`${s}-${w}`)
const bg = cell ? LEVEL_BG[cell.dominant_level] : 'bg-gray-50 dark:bg-gray-900/40 text-gray-300'
return (
<td key={w} className="p-0.5">
<div
className={`w-11 h-11 rounded flex items-center justify-center text-sm font-bold ${bg}`}
title={cell ? `S${s} × W${w}: ${cell.count} Gefährdung(en) · ${LEVEL_LABEL[cell.dominant_level]}` : `S${s} × W${w}: 0`}
>
{cell ? cell.count : ''}
</div>
</td>
)
})}
</tr>
))}
<tr>
<td />
{probabilities.map((w) => (
<td key={w} className="text-[10px] text-gray-400 text-center">{w}</td>
))}
</tr>
</tbody>
</table>
</div>
</div>
{/* Level summary + confidence */}
<div className="space-y-2 text-xs min-w-[180px]">
{LEVELS.slice().reverse().map((lvl) => (
<div key={lvl} className="flex items-center gap-2">
<span className={`inline-block w-3 h-3 rounded-sm ${LEVEL_DOT[lvl]}`} />
<span className="text-gray-600 dark:text-gray-300 flex-1">{LEVEL_LABEL[lvl]}</span>
<span className="font-semibold text-gray-900 dark:text-gray-100">{data.level_counts[lvl] || 0}</span>
</div>
))}
<div className="pt-2 mt-1 border-t border-gray-100 dark:border-gray-700 text-gray-500">
Tool-Konfidenz: <strong>{Math.round(data.high_confidence_pct)}%</strong> mit hoher Konfidenz
(Verletzungsmechanismus eindeutig).
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,64 @@
'use client'
import { useEffect, useState } from 'react'
export interface HazardRiskDetail {
hazard_id: string
name: string
category: string
zone: string
severity: number
frequency: number
probability: number
avoidance: number
risk_point: number
risk_low: number
risk_high: number
level: string
level_range: string
confidence: string
}
export interface RiskMatrixCell {
severity: number
probability: number
count: number
dominant_level: string
hazard_ids: string[]
}
export interface RiskMatrixData {
hazards: HazardRiskDetail[]
matrix: RiskMatrixCell[]
level_counts: Record<string, number>
total: number
high_confidence_pct: number
}
/** Loads the project-wide confidence-aware risk matrix (computed server-side). */
export function useRiskMatrix(projectId: string) {
const [data, setData] = useState<RiskMatrixData | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
let cancelled = false
async function load() {
setLoading(true)
try {
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/risk-matrix`)
const json = res.ok ? ((await res.json()) as RiskMatrixData) : null
if (!cancelled) setData(json)
} catch (err) {
console.error('Failed to load risk matrix:', err)
} finally {
if (!cancelled) setLoading(false)
}
}
load()
return () => {
cancelled = true
}
}, [projectId])
return { data, loading }
}
@@ -2,12 +2,15 @@
import { useParams } from 'next/navigation'
import { useRiskAssessment } from './_hooks/useRiskAssessment'
import { useRiskMatrix } from './_hooks/useRiskMatrix'
import { RiskModelCard } from './_components/RiskModelCard'
import { RiskMatrix } from './_components/RiskMatrix'
export default function RisikobewertungPage() {
const params = useParams<{ projectId: string }>()
const projectId = params.projectId
const { hazards, suggestions, loading } = useRiskAssessment(projectId)
const { data: matrix } = useRiskMatrix(projectId)
return (
<div className="space-y-6">
@@ -21,6 +24,8 @@ export default function RisikobewertungPage() {
</p>
</div>
{matrix && matrix.total > 0 && <RiskMatrix data={matrix} />}
{loading && (
<div className="text-sm text-gray-500 dark:text-gray-400">Lade Gefaehrdungen</div>
)}
@@ -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. "mittelhoch"
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)
}
}