diff --git a/admin-compliance/app/sdk/iace/[projectId]/risikobewertung/_components/RiskMatrix.tsx b/admin-compliance/app/sdk/iace/[projectId]/risikobewertung/_components/RiskMatrix.tsx new file mode 100644 index 00000000..d89d0cbf --- /dev/null +++ b/admin-compliance/app/sdk/iace/[projectId]/risikobewertung/_components/RiskMatrix.tsx @@ -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 = { + vernachlaessigbar: 'vernachlässigbar', + gering: 'gering', + mittel: 'mittel', + hoch: 'hoch', + kritisch: 'kritisch', +} +const LEVEL_BG: Record = { + 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 = { + 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 ( +
+
+

Risiko-Matrix (Schwere × Wahrscheinlichkeit)

+

+ {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. +

+
+ +
+ {/* Matrix grid */} +
+
+
+
+ Wahrscheinlichkeit (W) → +
+
+
+
+ Schwere (S) ↑ +
+ + + {severities.map((s) => ( + + + {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 ( + + ) + })} + + ))} + + + ))} + + +
{s} +
+ {cell ? cell.count : ''} +
+
+ {probabilities.map((w) => ( + {w}
+
+
+ + {/* Level summary + confidence */} +
+ {LEVELS.slice().reverse().map((lvl) => ( +
+ + {LEVEL_LABEL[lvl]} + {data.level_counts[lvl] || 0} +
+ ))} +
+ Tool-Konfidenz: {Math.round(data.high_confidence_pct)}% mit hoher Konfidenz + (Verletzungsmechanismus eindeutig). +
+
+
+
+ ) +} diff --git a/admin-compliance/app/sdk/iace/[projectId]/risikobewertung/_hooks/useRiskMatrix.ts b/admin-compliance/app/sdk/iace/[projectId]/risikobewertung/_hooks/useRiskMatrix.ts new file mode 100644 index 00000000..2e568437 --- /dev/null +++ b/admin-compliance/app/sdk/iace/[projectId]/risikobewertung/_hooks/useRiskMatrix.ts @@ -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 + 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(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 } +} diff --git a/admin-compliance/app/sdk/iace/[projectId]/risikobewertung/page.tsx b/admin-compliance/app/sdk/iace/[projectId]/risikobewertung/page.tsx index 8e954d36..c8ee5ecf 100644 --- a/admin-compliance/app/sdk/iace/[projectId]/risikobewertung/page.tsx +++ b/admin-compliance/app/sdk/iace/[projectId]/risikobewertung/page.tsx @@ -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 (
@@ -21,6 +24,8 @@ export default function RisikobewertungPage() {

+ {matrix && matrix.total > 0 && } + {loading && (
Lade Gefaehrdungen…
)} diff --git a/ai-compliance-sdk/internal/api/handlers/iace_handler_risk.go b/ai-compliance-sdk/internal/api/handlers/iace_handler_risk.go index 91c8f83e..f8a880da 100644 --- a/ai-compliance-sdk/internal/api/handlers/iace_handler_risk.go +++ b/ai-compliance-sdk/internal/api/handlers/iace_handler_risk.go @@ -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)) +} diff --git a/ai-compliance-sdk/internal/app/routes_iace.go b/ai-compliance-sdk/internal/app/routes_iace.go index 611e93f1..da4c2813 100644 --- a/ai-compliance-sdk/internal/app/routes_iace.go +++ b/ai-compliance-sdk/internal/app/routes_iace.go @@ -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) diff --git a/ai-compliance-sdk/internal/iace/risk_matrix.go b/ai-compliance-sdk/internal/iace/risk_matrix.go new file mode 100644 index 00000000..f167f8a7 --- /dev/null +++ b/ai-compliance-sdk/internal/iace/risk_matrix.go @@ -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 +} diff --git a/ai-compliance-sdk/internal/iace/risk_matrix_test.go b/ai-compliance-sdk/internal/iace/risk_matrix_test.go new file mode 100644 index 00000000..341402e5 --- /dev/null +++ b/ai-compliance-sdk/internal/iace/risk_matrix_test.go @@ -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) + } +}