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:
@@ -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. "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