feat(iace): expandable detail comparison in benchmark tab
Build + Deploy / build-admin-compliance (push) Successful in 1m50s
Build + Deploy / build-backend-compliance (push) Successful in 10s
Build + Deploy / build-ai-sdk (push) Successful in 41s
Build + Deploy / build-developer-portal (push) Successful in 10s
Build + Deploy / build-tts (push) Successful in 14s
Build + Deploy / build-document-crawler (push) Successful in 9s
Build + Deploy / build-dsms-gateway (push) Successful in 10s
Build + Deploy / build-dsms-node (push) Successful in 11s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / loc-budget (push) Failing after 17s
CI / secret-scan (push) Has been skipped
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Successful in 2m45s
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / test-go (push) Failing after 43s
CI / test-python-backend (push) Successful in 39s
CI / test-python-document-crawler (push) Successful in 25s
CI / test-python-dsms-gateway (push) Successful in 23s
CI / validate-canonical-controls (push) Successful in 15s
Build + Deploy / trigger-orca (push) Successful in 2m29s

Backend: HazardSummary now includes description, scenario, possible_harm,
trigger_event, and mitigations[] for side-by-side comparison.

Frontend: Each matched pair row is now clickable/expandable showing
two-column detail view:
- Left (GT): hazard type, cause, zone, lifecycle phases, risk values
  (F/W/P/S->R), residual risk, measures, type (KM/TM/BI), norms, comment
- Right (Engine): name, scenario, zone, possible harm, trigger, measures

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-05-13 15:36:18 +02:00
parent 5e317d2f0f
commit 6940271672
4 changed files with 121 additions and 38 deletions
@@ -53,6 +53,7 @@ export function HazardComparisonTable({ matched, missing, extra }: Props) {
} }
function MatchedTable({ pairs }: { pairs: HazardMatchPair[] }) { function MatchedTable({ pairs }: { pairs: HazardMatchPair[] }) {
const [expanded, setExpanded] = useState<Record<number, boolean>>({})
if (pairs.length === 0) return <EmptyState text="Keine Zuordnungen gefunden" /> if (pairs.length === 0) return <EmptyState text="Keine Zuordnungen gefunden" />
return ( return (
<table className="w-full text-xs"> <table className="w-full text-xs">
@@ -60,10 +61,10 @@ function MatchedTable({ pairs }: { pairs: HazardMatchPair[] }) {
<tr className="bg-gray-50 dark:bg-gray-700/50"> <tr className="bg-gray-50 dark:bg-gray-700/50">
<th className="px-3 py-2 text-left font-medium text-gray-500">Nr.</th> <th className="px-3 py-2 text-left font-medium text-gray-500">Nr.</th>
<th className="px-3 py-2 text-left font-medium text-gray-500">Ground Truth</th> <th className="px-3 py-2 text-left font-medium text-gray-500">Ground Truth</th>
<th className="px-3 py-2 text-left font-medium text-gray-500">R(GT)</th> <th className="px-3 py-2 text-left font-medium text-gray-500">R</th>
<th className="px-3 py-2 text-left font-medium text-gray-500">Engine</th> <th className="px-3 py-2 text-left font-medium text-gray-500">Engine</th>
<th className="px-3 py-2 text-center font-medium text-gray-500">Score</th> <th className="px-3 py-2 text-center font-medium text-gray-500">Score</th>
<th className="px-3 py-2 text-left font-medium text-gray-500">Match</th> <th className="px-3 py-2 text-left font-medium text-gray-500">Qualitaet</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-700"> <tbody className="divide-y divide-gray-100 dark:divide-gray-700">
@@ -71,32 +72,41 @@ function MatchedTable({ pairs }: { pairs: HazardMatchPair[] }) {
const quality = p.match_score >= 0.7 ? 'green' : p.match_score >= 0.4 ? 'yellow' : 'red' const quality = p.match_score >= 0.7 ? 'green' : p.match_score >= 0.4 ? 'yellow' : 'red'
const rowBg = quality === 'green' ? 'bg-green-50/30 dark:bg-green-900/5' const rowBg = quality === 'green' ? 'bg-green-50/30 dark:bg-green-900/5'
: quality === 'yellow' ? 'bg-yellow-50/30 dark:bg-yellow-900/5' : '' : quality === 'yellow' ? 'bg-yellow-50/30 dark:bg-yellow-900/5' : ''
const gtRiskLevel = p.gt_entry.risk_in.r >= 30 ? 'hoch' : p.gt_entry.risk_in.r >= 15 ? 'mittel' : 'niedrig' const isOpen = expanded[i]
return ( return (
<tr key={i} className={`hover:bg-gray-50 dark:hover:bg-gray-700/30 ${rowBg}`}> <React.Fragment key={i}>
<td className="px-3 py-2 text-gray-400">{p.gt_entry.nr}</td> <tr className={`hover:bg-gray-50 dark:hover:bg-gray-700/30 cursor-pointer ${rowBg}`}
<td className="px-3 py-2"> onClick={() => setExpanded(prev => ({ ...prev, [i]: !prev[i] }))}>
<div className="font-medium text-gray-800 dark:text-gray-200">{p.gt_entry.hazard_type}</div> <td className="px-3 py-2 text-gray-400">
<div className="text-gray-400 truncate max-w-[250px]">{p.gt_entry.component_zone}</div> <div className="flex items-center gap-1">
</td> <svg className={`w-3 h-3 text-gray-400 transition-transform ${isOpen ? 'rotate-90' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<td className="px-3 py-2 text-center"> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
<RiskBadge risk={p.gt_entry.risk_in.r} /> </svg>
<div className="text-[9px] text-gray-400 mt-0.5"> {p.gt_entry.nr}
F{p.gt_entry.risk_in.f} W{p.gt_entry.risk_in.w} P{p.gt_entry.risk_in.p} S{p.gt_entry.risk_in.s} </div>
</div> </td>
</td> <td className="px-3 py-2">
<td className="px-3 py-2"> <div className="font-medium text-gray-800 dark:text-gray-200">{p.gt_entry.hazard_type}</div>
<div className="font-medium text-gray-800 dark:text-gray-200">{p.engine_hazard.name}</div> <div className="text-gray-400 truncate max-w-[250px]">{p.gt_entry.component_zone}</div>
<div className="text-gray-400">{p.engine_hazard.category}</div> </td>
</td> <td className="px-3 py-2 text-center">
<td className="px-3 py-2 text-center"> <RiskBadge risk={p.gt_entry.risk_in.r} />
<ScoreBadge score={p.match_score} /> </td>
</td> <td className="px-3 py-2">
<td className="px-3 py-2"> <div className="font-medium text-gray-800 dark:text-gray-200">{p.engine_hazard.name}</div>
<QualityBadge quality={quality} /> <div className="text-gray-400">{p.engine_hazard.category}</div>
<div className="text-[10px] text-gray-400 mt-0.5">{p.match_reason}</div> </td>
</td> <td className="px-3 py-2 text-center"><ScoreBadge score={p.match_score} /></td>
</tr> <td className="px-3 py-2"><QualityBadge quality={quality} /></td>
</tr>
{isOpen && (
<tr className="bg-gray-50/70 dark:bg-gray-850">
<td colSpan={6} className="px-4 py-3">
<DetailComparison gt={p.gt_entry} engine={p.engine_hazard} />
</td>
</tr>
)}
</React.Fragment>
) )
})} })}
</tbody> </tbody>
@@ -104,6 +114,60 @@ function MatchedTable({ pairs }: { pairs: HazardMatchPair[] }) {
) )
} }
/** Side-by-side detail comparison of GT entry vs. Engine hazard */
function DetailComparison({ gt, engine }: { gt: GroundTruthEntry; engine: HazardSummary }) {
return (
<div className="grid grid-cols-2 gap-4 text-xs">
{/* Left: Ground Truth */}
<div className="space-y-2">
<div className="font-semibold text-red-700 dark:text-red-400 uppercase text-[10px]">Ground Truth (Fachmann)</div>
<DetailRow label="Gefaehrdung" gt={gt.hazard_type} />
<DetailRow label="Ursache" gt={gt.hazard_cause} />
<DetailRow label="Gefahrenstelle" gt={gt.component_zone} />
<DetailRow label="Lebensphasen" gt={gt.lifecycle_phases?.join(', ') || '-'} />
<DetailRow label="Risiko" gt={`F=${gt.risk_in.f} W=${gt.risk_in.w} P=${gt.risk_in.p} S=${gt.risk_in.s} => R=${gt.risk_in.r}`} />
{gt.risk_out.r > 0 && (
<DetailRow label="Restrisiko" gt={`F=${gt.risk_out.f} W=${gt.risk_out.w} P=${gt.risk_out.p} S=${gt.risk_out.s} => R=${gt.risk_out.r}`} />
)}
<DetailRow label="Massnahmen" gt={gt.measures?.join('\n') || '-'} multiline />
<DetailRow label="Typ" gt={gt.measure_type || '-'} />
{gt.norm_references?.length > 0 && (
<DetailRow label="Normen" gt={gt.norm_references.join(', ')} />
)}
<DetailRow label="Hinreichend" gt={gt.sufficient ? 'JA' : 'NEIN'} />
{gt.comment && <DetailRow label="Kommentar" gt={gt.comment} />}
</div>
{/* Right: Engine */}
<div className="space-y-2">
<div className="font-semibold text-purple-700 dark:text-purple-400 uppercase text-[10px]">Engine (automatisch)</div>
<DetailRow label="Gefaehrdung" gt={engine.name} />
<DetailRow label="Szenario" gt={engine.scenario || engine.description || '-'} />
<DetailRow label="Gefahrenstelle" gt={engine.zone || '-'} />
<DetailRow label="Moeglicher Schaden" gt={engine.possible_harm || '-'} />
<DetailRow label="Trigger" gt={engine.trigger_event || '-'} />
{engine.mitigations && engine.mitigations.length > 0 ? (
<DetailRow label="Massnahmen" gt={engine.mitigations.join('\n')} multiline />
) : (
<DetailRow label="Massnahmen" gt="(keine zugeordnet)" />
)}
</div>
</div>
)
}
function DetailRow({ label, gt, multiline }: { label: string; gt: string; multiline?: boolean }) {
return (
<div>
<div className="text-[10px] font-medium text-gray-500 uppercase">{label}</div>
{multiline ? (
<pre className="text-xs text-gray-700 dark:text-gray-300 whitespace-pre-wrap font-sans mt-0.5">{gt}</pre>
) : (
<div className="text-xs text-gray-700 dark:text-gray-300 mt-0.5">{gt}</div>
)}
</div>
)
}
function MissingTable({ entries }: { entries: GroundTruthEntry[] }) { function MissingTable({ entries }: { entries: GroundTruthEntry[] }) {
if (entries.length === 0) return <EmptyState text="Keine fehlenden Gefaehrdungen" /> if (entries.length === 0) return <EmptyState text="Keine fehlenden Gefaehrdungen" />
return ( return (
@@ -31,6 +31,9 @@ export interface GroundTruthEntry {
export interface HazardSummary { export interface HazardSummary {
id: string; name: string; category: string id: string; name: string; category: string
component?: string; zone?: string; risk_level?: string component?: string; zone?: string; risk_level?: string
description?: string; scenario?: string
possible_harm?: string; trigger_event?: string
mitigations?: string[]
} }
export interface HazardMatchPair { export interface HazardMatchPair {
@@ -59,13 +59,24 @@ func CompareBenchmark(gt *GroundTruth, hazards []Hazard, mitigations []Mitigatio
return &BenchmarkResult{} return &BenchmarkResult{}
} }
// Build mitigation names per hazard
mitNamesByHazard := make(map[string][]string)
for _, m := range mitigations {
mitNamesByHazard[m.HazardID.String()] = append(mitNamesByHazard[m.HazardID.String()], m.Name)
}
engineSummaries := make([]HazardSummary, len(hazards)) engineSummaries := make([]HazardSummary, len(hazards))
for i, h := range hazards { for i, h := range hazards {
engineSummaries[i] = HazardSummary{ engineSummaries[i] = HazardSummary{
ID: h.ID.String(), ID: h.ID.String(),
Name: h.Name, Name: h.Name,
Category: h.Category, Category: h.Category,
Zone: h.HazardousZone, Zone: h.HazardousZone,
Description: h.Description,
Scenario: h.Scenario,
PossibleHarm: h.PossibleHarm,
TriggerEvent: h.TriggerEvent,
Mitigations: mitNamesByHazard[h.ID.String()],
} }
} }
@@ -90,14 +90,19 @@ type HazardMatchPair struct {
MatchReason string `json:"match_reason"` MatchReason string `json:"match_reason"`
} }
// HazardSummary is a lightweight hazard representation for benchmark results. // HazardSummary is a hazard representation for benchmark results with detail fields.
type HazardSummary struct { type HazardSummary struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Category string `json:"category"` Category string `json:"category"`
Component string `json:"component,omitempty"` Component string `json:"component,omitempty"`
Zone string `json:"zone,omitempty"` Zone string `json:"zone,omitempty"`
RiskLevel string `json:"risk_level,omitempty"` RiskLevel string `json:"risk_level,omitempty"`
Description string `json:"description,omitempty"`
Scenario string `json:"scenario,omitempty"`
PossibleHarm string `json:"possible_harm,omitempty"`
TriggerEvent string `json:"trigger_event,omitempty"`
Mitigations []string `json:"mitigations,omitempty"`
} }
// CategoryScore shows coverage per ISO 12100 hazard group. // CategoryScore shows coverage per ISO 12100 hazard group.