diff --git a/admin-compliance/app/sdk/iace/[projectId]/hazards/_components/HazardBlockView.tsx b/admin-compliance/app/sdk/iace/[projectId]/hazards/_components/HazardBlockView.tsx new file mode 100644 index 0000000..08f8432 --- /dev/null +++ b/admin-compliance/app/sdk/iace/[projectId]/hazards/_components/HazardBlockView.tsx @@ -0,0 +1,182 @@ +'use client' + +import React, { useState, useEffect } from 'react' +import { useParams } from 'next/navigation' +import { CATEGORY_LABELS } from './types' +import { RiskBadge } from './RiskBadge' + +interface BlockHazard { + hazard: { + id: string; name: string; description: string; category: string + hazardous_zone: string; scenario?: string; possible_harm?: string + } + assessment?: { severity: number; exposure: number; probability: number; inherent_risk: number; risk_level: string } | null + mitigation_ids: string[] +} + +interface HazardBlock { + parent_hazard: BlockHazard + children: BlockHazard[] + block_key: string + shared_measure_count: number + children_covered_by_parent: boolean +} + +interface BlockSummary { + total_blocks: number + parent_only_blocks: number + blocks_with_children: number + total_hazards: number + covered_children: number + uncovered_children: number + assessments_needed: number + assessments_saved: number +} + +export function HazardBlockView() { + const { projectId } = useParams<{ projectId: string }>() + const [blocks, setBlocks] = useState([]) + const [summary, setSummary] = useState(null) + const [loading, setLoading] = useState(true) + const [expanded, setExpanded] = useState>({}) + + useEffect(() => { + if (!projectId) return + fetch(`/api/sdk/v1/iace/projects/${projectId}/hazard-blocks`) + .then(r => r.ok ? r.json() : null) + .then(data => { + if (data) { + setBlocks(data.blocks || []) + setSummary(data.summary || null) + } + }) + .finally(() => setLoading(false)) + }, [projectId]) + + const toggle = (key: string) => setExpanded(prev => ({ ...prev, [key]: !prev[key] })) + + if (loading) return
Lade Bloecke...
+ + return ( +
+ {/* Summary Cards */} + {summary && ( +
+ + + + +
+ )} + + {/* Block List */} +
+ {blocks.map((block) => { + const isOpen = expanded[block.block_key] + const parent = block.parent_hazard + const childCount = block.children.length + const covered = block.children_covered_by_parent + + return ( +
+ {/* Parent Row */} +
0 ? '' : 'opacity-90'}`} + onClick={() => childCount > 0 && toggle(block.block_key)} + > + {/* Expand Arrow */} + {childCount > 0 ? ( + + + + ) : ( +
+ )} + + {/* Name + Category */} +
+
+ {parent.hazard.name} + {CATEGORY_LABELS[parent.hazard.category] || parent.hazard.category} +
+ {parent.hazard.hazardous_zone && ( +
{parent.hazard.hazardous_zone}
+ )} +
+ + {/* Risk */} + {parent.assessment ? ( +
+ R={parent.assessment.inherent_risk} + +
+ ) : ( + Nicht bewertet + )} + + {/* Child count badge */} + {childCount > 0 && ( +
+ +{childCount} + {covered && ( + + + + )} +
+ )} + + {/* Measures count */} + {block.shared_measure_count} M. +
+ + {/* Children (expanded) */} + {isOpen && childCount > 0 && ( +
+ {covered && ( +
+ Alle Untergefaehrdungen durch Massnahmen der Muttergefaehrdung abgedeckt — keine separate Bewertung noetig. +
+ )} + {block.children.map((child) => ( +
+
+
+ {child.hazard.name} + {child.hazard.hazardous_zone && ( + [{child.hazard.hazardous_zone}] + )} +
+ {child.assessment ? ( + R={child.assessment.inherent_risk} + ) : covered ? ( + Abgedeckt + ) : ( + Offen + )} +
+ ))} +
+ )} +
+ ) + })} +
+
+ ) +} + +function SummaryCard({ label, value, sub, color }: { label: string; value: number; sub: string; color?: string }) { + const textColor = color === 'green' ? 'text-green-600' : color === 'purple' ? 'text-purple-600' : 'text-gray-900 dark:text-white' + return ( +
+
{value}
+
{label}
+
{sub}
+
+ ) +} diff --git a/admin-compliance/app/sdk/iace/[projectId]/hazards/page.tsx b/admin-compliance/app/sdk/iace/[projectId]/hazards/page.tsx index c043624..a458260 100644 --- a/admin-compliance/app/sdk/iace/[projectId]/hazards/page.tsx +++ b/admin-compliance/app/sdk/iace/[projectId]/hazards/page.tsx @@ -4,6 +4,7 @@ import React, { useState, useMemo, useCallback } from 'react' import { useParams } from 'next/navigation' import { HazardForm } from './_components/HazardForm' import { HazardTable } from './_components/HazardTable' +import { HazardBlockView } from './_components/HazardBlockView' import { RiskAssessmentTable } from './_components/RiskAssessmentTable' import { ResidualRiskPanel, getResidualStatus } from './_components/ResidualRiskPanel' import type { ResidualFilter } from './_components/ResidualRiskPanel' @@ -12,7 +13,7 @@ import { AutoSuggestPanel } from './_components/AutoSuggestPanel' import { CustomHazardModal } from './_components/CustomHazardModal' import { useHazards } from './_hooks/useHazards' -type ViewMode = 'list' | 'risk' +type ViewMode = 'list' | 'risk' | 'blocks' export default function HazardsPage() { const params = useParams() @@ -69,6 +70,10 @@ export default function HazardsPage() { className={`px-3 py-1.5 font-medium transition-colors border-l border-gray-200 dark:border-gray-600 ${view === 'risk' ? 'bg-purple-600 text-white' : 'bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-50'}`}> Risikobewertung +
@@ -172,6 +177,8 @@ export default function HazardsPage() { + ) : view === 'blocks' ? ( + ) : ( ) diff --git a/ai-compliance-sdk/internal/api/handlers/iace_handler_blocks.go b/ai-compliance-sdk/internal/api/handlers/iace_handler_blocks.go new file mode 100644 index 0000000..fa9452c --- /dev/null +++ b/ai-compliance-sdk/internal/api/handlers/iace_handler_blocks.go @@ -0,0 +1,67 @@ +package handlers + +import ( + "net/http" + + "github.com/breakpilot/ai-compliance-sdk/internal/iace" + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +// GetHazardBlocks handles GET /projects/:id/hazard-blocks +// Returns hazards grouped into parent-child blocks based on shared category, +// component, and zone. The parent hazard in each block has the highest risk. +// Children covered by the parent's measures are flagged accordingly. +func (h *IACEHandler) GetHazardBlocks(c *gin.Context) { + projectID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"}) + return + } + + ctx := c.Request.Context() + + hazards, err := h.store.ListHazards(ctx, projectID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load hazards"}) + return + } + + assessmentMap, _ := h.store.GetLatestAssessmentsByProject(ctx, projectID) + var assessments []iace.RiskAssessment + for _, a := range assessmentMap { + assessments = append(assessments, a) + } + mitigations, _ := h.store.ListMitigationsByProject(ctx, projectID) + + blocks := iace.ComputeHazardBlocks(hazards, assessments, mitigations) + + // Compute summary stats + totalBlocks := len(blocks) + parentOnly := 0 + coveredChildren := 0 + uncoveredChildren := 0 + for _, b := range blocks { + if len(b.Children) == 0 { + parentOnly++ + } else if b.ChildrenCoveredByParent { + coveredChildren += len(b.Children) + } else { + uncoveredChildren += len(b.Children) + } + } + + c.JSON(http.StatusOK, gin.H{ + "blocks": blocks, + "summary": gin.H{ + "total_blocks": totalBlocks, + "parent_only_blocks": parentOnly, + "blocks_with_children": totalBlocks - parentOnly, + "total_hazards": len(hazards), + "covered_children": coveredChildren, + "uncovered_children": uncoveredChildren, + "assessments_needed": totalBlocks - parentOnly + uncoveredChildren + parentOnly, + "assessments_saved": coveredChildren, + }, + }) +} diff --git a/ai-compliance-sdk/internal/app/routes.go b/ai-compliance-sdk/internal/app/routes.go index 782b5ed..c9b0557 100644 --- a/ai-compliance-sdk/internal/app/routes.go +++ b/ai-compliance-sdk/internal/app/routes.go @@ -432,6 +432,7 @@ func registerIACERoutes(v1 *gin.RouterGroup, h *handlers.IACEHandler) { iaceRoutes.POST("/library-search", h.SearchLibrary) iaceRoutes.GET("/ce-corpus-documents", h.ListCECorpusDocuments) iaceRoutes.POST("/projects/:id/initialize", h.InitializeProject) + iaceRoutes.GET("/projects/:id/hazard-blocks", h.GetHazardBlocks) iaceRoutes.POST("/projects/:id/benchmark/import-gt", h.ImportGroundTruth) iaceRoutes.GET("/projects/:id/benchmark", h.RunBenchmark) iaceRoutes.GET("/projects/:id/benchmark/summary", h.GetBenchmarkSummary) diff --git a/ai-compliance-sdk/internal/iace/hazard_blocks.go b/ai-compliance-sdk/internal/iace/hazard_blocks.go new file mode 100644 index 0000000..1047573 --- /dev/null +++ b/ai-compliance-sdk/internal/iace/hazard_blocks.go @@ -0,0 +1,183 @@ +package iace + +import ( + "sort" + "strings" + + "github.com/google/uuid" +) + +// HazardBlock groups related hazards under a parent hazard. +// The parent is the hazard with the highest inherent risk in the group. +// Child hazards are covered by the same or similar protective measures. +type HazardBlock struct { + ParentHazard HazardBlockEntry `json:"parent_hazard"` + Children []HazardBlockEntry `json:"children"` + BlockKey string `json:"block_key"` + SharedMeasureCount int `json:"shared_measure_count"` + // If true, the parent's measures cover all children → children + // don't need individual risk assessment. + ChildrenCoveredByParent bool `json:"children_covered_by_parent"` +} + +// HazardBlockEntry is a hazard with its assessment and linked measures. +type HazardBlockEntry struct { + Hazard Hazard `json:"hazard"` + Assessment *RiskAssessment `json:"assessment,omitempty"` + MitigationIDs []uuid.UUID `json:"mitigation_ids"` +} + +// ComputeHazardBlocks groups hazards into blocks based on category + component. +// Within each block, the hazard with the highest risk becomes the parent. +// Children whose measures are a subset of the parent's measures are marked as covered. +func ComputeHazardBlocks( + hazards []Hazard, + assessments []RiskAssessment, + mitigations []Mitigation, +) []HazardBlock { + if len(hazards) == 0 { + return nil + } + + // Build assessment lookup: hazard_id → latest assessment + assessMap := make(map[uuid.UUID]*RiskAssessment) + for i := range assessments { + a := &assessments[i] + if existing, ok := assessMap[a.HazardID]; !ok || a.Version > existing.Version { + assessMap[a.HazardID] = a + } + } + + // Build mitigation lookup: hazard_id → []mitigation_ids + mitsByHazard := make(map[uuid.UUID][]uuid.UUID) + for _, m := range mitigations { + mitsByHazard[m.HazardID] = append(mitsByHazard[m.HazardID], m.ID) + } + + // Group by blockKey = category + ":" + componentID + groups := make(map[string][]HazardBlockEntry) + for _, h := range hazards { + key := buildBlockKey(h) + entry := HazardBlockEntry{ + Hazard: h, + Assessment: assessMap[h.ID], + MitigationIDs: mitsByHazard[h.ID], + } + groups[key] = append(groups[key], entry) + } + + // Build blocks: sort each group by risk, first is parent + var blocks []HazardBlock + for key, entries := range groups { + sortByRiskDesc(entries, assessMap) + + parent := entries[0] + children := entries[1:] + + // Check if parent's measures cover children + parentMitSet := toUUIDSet(parent.MitigationIDs) + allCovered := true + for _, child := range children { + if !mitigationsCoveredBy(child, parent, mitigations) { + allCovered = false + break + } + } + + block := HazardBlock{ + ParentHazard: parent, + Children: children, + BlockKey: key, + SharedMeasureCount: len(parentMitSet), + ChildrenCoveredByParent: allCovered && len(children) > 0, + } + blocks = append(blocks, block) + } + + // Sort blocks: largest (most children) first, then by parent risk + sort.Slice(blocks, func(i, j int) bool { + ri := inherentRisk(blocks[i].ParentHazard, assessMap) + rj := inherentRisk(blocks[j].ParentHazard, assessMap) + if len(blocks[i].Children) != len(blocks[j].Children) { + return len(blocks[i].Children) > len(blocks[j].Children) + } + return ri > rj + }) + + return blocks +} + +func buildBlockKey(h Hazard) string { + comp := h.ComponentID.String() + zone := NormalizeDEPublic(h.HazardousZone) + // Use first 3 significant words of zone for grouping + words := strings.Fields(zone) + var sig []string + for _, w := range words { + w = strings.Trim(w, ".,;:!?()/-") + if len(w) >= 4 { + sig = append(sig, w) + } + if len(sig) >= 2 { + break + } + } + zoneKey := strings.Join(sig, "_") + return h.Category + ":" + comp + ":" + zoneKey +} + +func sortByRiskDesc(entries []HazardBlockEntry, assessMap map[uuid.UUID]*RiskAssessment) { + sort.Slice(entries, func(i, j int) bool { + ri := inherentRisk(entries[i], assessMap) + rj := inherentRisk(entries[j], assessMap) + return ri > rj + }) +} + +func inherentRisk(entry HazardBlockEntry, assessMap map[uuid.UUID]*RiskAssessment) float64 { + if entry.Assessment != nil { + return entry.Assessment.InherentRisk + } + if a, ok := assessMap[entry.Hazard.ID]; ok { + return a.InherentRisk + } + return 0 +} + +// mitigationsCoveredBy checks if child's measures are functionally covered +// by parent's measures (same reduction type and hazard category). +func mitigationsCoveredBy(child, parent HazardBlockEntry, allMits []Mitigation) bool { + if len(child.MitigationIDs) == 0 { + return true // No measures needed → covered by default + } + + mitMap := make(map[uuid.UUID]Mitigation) + for _, m := range allMits { + mitMap[m.ID] = m + } + + // Check: for each child mitigation type, parent has same type + parentTypes := make(map[ReductionType]bool) + for _, mid := range parent.MitigationIDs { + if m, ok := mitMap[mid]; ok { + parentTypes[m.ReductionType] = true + } + } + + for _, mid := range child.MitigationIDs { + if m, ok := mitMap[mid]; ok { + if !parentTypes[m.ReductionType] { + return false + } + } + } + return true +} + +func toUUIDSet(ids []uuid.UUID) map[uuid.UUID]bool { + s := make(map[uuid.UUID]bool, len(ids)) + for _, id := range ids { + s[id] = true + } + return s +}