feat(iace): benchmark system + erklaerteil + dedup-fix
Build + Deploy / build-admin-compliance (push) Successful in 2m7s
Build + Deploy / build-backend-compliance (push) Successful in 3m34s
Build + Deploy / build-ai-sdk (push) Successful in 1m6s
Build + Deploy / build-developer-portal (push) Successful in 1m7s
Build + Deploy / build-tts (push) Successful in 1m58s
Build + Deploy / build-document-crawler (push) Successful in 57s
Build + Deploy / build-dsms-gateway (push) Successful in 34s
Build + Deploy / build-dsms-node (push) Successful in 29s
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 2m28s
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / test-go (push) Successful in 42s
CI / test-python-backend (push) Successful in 37s
CI / test-python-document-crawler (push) Successful in 27s
CI / test-python-dsms-gateway (push) Successful in 22s
CI / validate-canonical-controls (push) Successful in 15s
Build + Deploy / trigger-orca (push) Successful in 3m10s
Build + Deploy / build-admin-compliance (push) Successful in 2m7s
Build + Deploy / build-backend-compliance (push) Successful in 3m34s
Build + Deploy / build-ai-sdk (push) Successful in 1m6s
Build + Deploy / build-developer-portal (push) Successful in 1m7s
Build + Deploy / build-tts (push) Successful in 1m58s
Build + Deploy / build-document-crawler (push) Successful in 57s
Build + Deploy / build-dsms-gateway (push) Successful in 34s
Build + Deploy / build-dsms-node (push) Successful in 29s
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 2m28s
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / test-go (push) Successful in 42s
CI / test-python-backend (push) Successful in 37s
CI / test-python-document-crawler (push) Successful in 27s
CI / test-python-dsms-gateway (push) Successful in 22s
CI / validate-canonical-controls (push) Successful in 15s
Build + Deploy / trigger-orca (push) Successful in 3m10s
- Erklaerteil-Template fuer Risikobeurteilungen (risk_assessment_template.go) in PDF-Export, Markdown-Export und Frontend ReportPrintView eingebaut - Ground Truth Benchmark-System: Datenmodell, Fuzzy-Matching-Engine, 3 API Endpoints (import-gt, benchmark, benchmark/summary) - Frontend Benchmark-Tab mit Score-Cards, Kategorie-Breakdown, Hazard-Vergleichstabelle (Zugeordnet/Fehlend/Extra), Business Impact - Erster Benchmark: 13.3% Coverage (Baseline) gegen 60 GT-Eintraege - Dedup-Fix: seenCat[cat] -> seenCatZone[cat+zone] erlaubt mehrere Gefaehrdungen pro Kategorie an verschiedenen Gefahrenstellen - Komponenten-spezifische Hazard-Namen und Zone-basierte Zuordnung Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import type { CategoryScore } from '../_hooks/useBenchmark'
|
||||
|
||||
interface Props { breakdown: CategoryScore[] }
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
'mechanische gefaehrdungen': 'Mechanisch',
|
||||
'elektrische gefaehrdungen': 'Elektrisch',
|
||||
'thermische gefaehrdungen': 'Thermisch',
|
||||
'laerm': 'Laerm',
|
||||
'vibration': 'Vibration',
|
||||
'strahlung': 'Strahlung',
|
||||
'materialien und substanzen': 'Materialien/Substanzen',
|
||||
'ergonomische gefaehrdungen': 'Ergonomie',
|
||||
'einsatzumgebung': 'Einsatzumgebung',
|
||||
}
|
||||
|
||||
export function CategoryBreakdown({ breakdown }: Props) {
|
||||
if (!breakdown || breakdown.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">Coverage nach Gefaehrdungsgruppe</h3>
|
||||
<div className="space-y-2">
|
||||
{breakdown.map((cat) => {
|
||||
const label = CATEGORY_LABELS[cat.category] || cat.category
|
||||
const pct = Math.round(cat.coverage * 100)
|
||||
const barColor = pct >= 80 ? 'bg-green-500' : pct >= 50 ? 'bg-yellow-500' : 'bg-red-500'
|
||||
return (
|
||||
<div key={cat.category}>
|
||||
<div className="flex justify-between text-xs text-gray-600 dark:text-gray-400 mb-0.5">
|
||||
<span>{label}</span>
|
||||
<span>{cat.match_count}/{cat.gt_count} ({pct}%)</span>
|
||||
</div>
|
||||
<div className="h-2 bg-gray-100 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||
<div className={`h-full ${barColor} rounded-full transition-all`} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useRef } from 'react'
|
||||
import type { GroundTruthEntry } from '../_hooks/useBenchmark'
|
||||
|
||||
interface Props {
|
||||
onImport: (gt: { entries: GroundTruthEntry[]; source_file?: string; description?: string }) => Promise<void>
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
export function GTImportForm({ onImport, loading }: Props) {
|
||||
const [jsonText, setJsonText] = useState('')
|
||||
const [parseError, setParseError] = useState<string | null>(null)
|
||||
const [preview, setPreview] = useState<{ count: number; groups: Record<string, number> } | null>(null)
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
function tryParse(text: string) {
|
||||
setJsonText(text)
|
||||
setParseError(null)
|
||||
setPreview(null)
|
||||
if (!text.trim()) return
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(text)
|
||||
const entries: GroundTruthEntry[] = parsed.entries || parsed
|
||||
if (!Array.isArray(entries) || entries.length === 0) {
|
||||
setParseError('JSON muss ein Array "entries" enthalten')
|
||||
return
|
||||
}
|
||||
// Validate first entry has required fields
|
||||
const first = entries[0]
|
||||
if (!first.hazard_type && !first.hazard_group) {
|
||||
setParseError('Eintraege muessen hazard_type oder hazard_group enthalten')
|
||||
return
|
||||
}
|
||||
// Build preview
|
||||
const groups: Record<string, number> = {}
|
||||
for (const e of entries) {
|
||||
const g = e.hazard_group || 'Unbekannt'
|
||||
groups[g] = (groups[g] || 0) + 1
|
||||
}
|
||||
setPreview({ count: entries.length, groups })
|
||||
} catch (err) {
|
||||
setParseError('Ungueltiges JSON: ' + (err instanceof Error ? err.message : String(err)))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImport() {
|
||||
if (!jsonText.trim()) return
|
||||
try {
|
||||
const parsed = JSON.parse(jsonText)
|
||||
const gt = parsed.entries ? parsed : { entries: parsed }
|
||||
await onImport(gt)
|
||||
setJsonText('')
|
||||
setPreview(null)
|
||||
} catch (err) {
|
||||
setParseError(err instanceof Error ? err.message : 'Import fehlgeschlagen')
|
||||
}
|
||||
}
|
||||
|
||||
function handleFileUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
const reader = new FileReader()
|
||||
reader.onload = (ev) => {
|
||||
const text = ev.target?.result as string
|
||||
tryParse(text)
|
||||
}
|
||||
reader.readAsText(file)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">Ground Truth importieren</h3>
|
||||
<p className="text-xs text-gray-500 mb-3">
|
||||
JSON-Datei mit der professionellen Risikobeurteilung einfuegen oder hochladen.
|
||||
</p>
|
||||
|
||||
<div className="flex gap-2 mb-3">
|
||||
<button
|
||||
onClick={() => fileRef.current?.click()}
|
||||
className="px-3 py-1.5 text-xs bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 rounded-md transition-colors"
|
||||
>
|
||||
JSON-Datei waehlen
|
||||
</button>
|
||||
<input ref={fileRef} type="file" accept=".json" onChange={handleFileUpload} className="hidden" />
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
value={jsonText}
|
||||
onChange={(e) => tryParse(e.target.value)}
|
||||
placeholder='{"entries": [...], "source_file": "...", "description": "..."}'
|
||||
rows={6}
|
||||
className="w-full text-xs font-mono border border-gray-300 dark:border-gray-600 rounded-md p-2 bg-gray-50 dark:bg-gray-900 text-gray-800 dark:text-gray-200 resize-y"
|
||||
/>
|
||||
|
||||
{parseError && (
|
||||
<div className="mt-2 px-3 py-2 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded text-xs text-red-600">
|
||||
{parseError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{preview && (
|
||||
<div className="mt-2 px-3 py-2 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded text-xs text-green-700 dark:text-green-400">
|
||||
<strong>{preview.count} Eintraege</strong> erkannt:
|
||||
{Object.entries(preview.groups).map(([g, c]) => (
|
||||
<span key={g} className="ml-2">{g}: {c}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleImport}
|
||||
disabled={loading || !preview}
|
||||
className="mt-3 w-full px-4 py-2 text-sm font-medium bg-purple-600 hover:bg-purple-700 disabled:bg-gray-300 text-white rounded-md transition-colors"
|
||||
>
|
||||
{loading ? 'Importiere...' : 'Ground Truth importieren'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import type { HazardMatchPair, GroundTruthEntry, HazardSummary } from '../_hooks/useBenchmark'
|
||||
|
||||
interface Props {
|
||||
matched: HazardMatchPair[]
|
||||
missing: GroundTruthEntry[]
|
||||
extra: HazardSummary[]
|
||||
}
|
||||
|
||||
type TabType = 'matched' | 'missing' | 'extra'
|
||||
|
||||
export function HazardComparisonTable({ matched, missing, extra }: Props) {
|
||||
const [tab, setTab] = useState<TabType>('matched')
|
||||
|
||||
const tabs: { id: TabType; label: string; count: number; color: string }[] = [
|
||||
{ id: 'matched', label: 'Zugeordnet', count: matched.length, color: 'text-green-600' },
|
||||
{ id: 'missing', label: 'Fehlend', count: missing.length, color: 'text-red-600' },
|
||||
{ id: 'extra', label: 'Zusaetzlich', count: extra.length, color: 'text-gray-500' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
{/* Tab bar */}
|
||||
<div className="flex border-b border-gray-200 dark:border-gray-700">
|
||||
{tabs.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => setTab(t.id)}
|
||||
className={`flex-1 px-4 py-2.5 text-xs font-medium transition-colors ${
|
||||
tab === t.id
|
||||
? 'border-b-2 border-purple-600 text-purple-700 dark:text-purple-400'
|
||||
: 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{t.label} <span className={t.color}>({t.count})</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
{tab === 'matched' && <MatchedTable pairs={matched} />}
|
||||
{tab === 'missing' && <MissingTable entries={missing} />}
|
||||
{tab === 'extra' && <ExtraTable entries={extra} />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MatchedTable({ pairs }: { pairs: HazardMatchPair[] }) {
|
||||
if (pairs.length === 0) return <EmptyState text="Keine Zuordnungen gefunden" />
|
||||
return (
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<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">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">Engine</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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
{pairs.map((p, i) => (
|
||||
<tr key={i} className="hover:bg-gray-50 dark:hover:bg-gray-700/30">
|
||||
<td className="px-3 py-2 text-gray-400">{p.gt_entry.nr}</td>
|
||||
<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="text-gray-400 truncate max-w-[250px]">{p.gt_entry.component_zone}</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<RiskBadge risk={p.gt_entry.risk_in.r} />
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="font-medium text-gray-800 dark:text-gray-200">{p.engine_hazard.name}</div>
|
||||
<div className="text-gray-400">{p.engine_hazard.category}</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<ScoreBadge score={p.match_score} />
|
||||
</td>
|
||||
<td className="px-3 py-2 text-gray-400">{p.match_reason}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)
|
||||
}
|
||||
|
||||
function MissingTable({ entries }: { entries: GroundTruthEntry[] }) {
|
||||
if (entries.length === 0) return <EmptyState text="Keine fehlenden Gefaehrdungen" />
|
||||
return (
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="bg-red-50 dark:bg-red-900/20">
|
||||
<th className="px-3 py-2 text-left font-medium text-red-600">Nr.</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-red-600">Gefaehrdung</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-red-600">Ursache</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-red-600">Zone</th>
|
||||
<th className="px-3 py-2 text-center font-medium text-red-600">R</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-red-600">Typ</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
{entries.map((e, i) => (
|
||||
<tr key={i} className="hover:bg-red-50/50">
|
||||
<td className="px-3 py-2 text-gray-400">{e.nr}</td>
|
||||
<td className="px-3 py-2 font-medium text-gray-800 dark:text-gray-200">{e.hazard_type}</td>
|
||||
<td className="px-3 py-2 text-gray-600 truncate max-w-[200px]">{e.hazard_cause}</td>
|
||||
<td className="px-3 py-2 text-gray-500">{e.component_zone}</td>
|
||||
<td className="px-3 py-2 text-center"><RiskBadge risk={e.risk_in.r} /></td>
|
||||
<td className="px-3 py-2 text-gray-500">{e.measure_type}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)
|
||||
}
|
||||
|
||||
function ExtraTable({ entries }: { entries: HazardSummary[] }) {
|
||||
if (entries.length === 0) return <EmptyState text="Keine zusaetzlichen Engine-Gefaehrdungen" />
|
||||
return (
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 dark:bg-gray-700/50">
|
||||
<th className="px-3 py-2 text-left font-medium text-gray-500">Name</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-gray-500">Kategorie</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-gray-500">Zone</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
{entries.map((e, i) => (
|
||||
<tr key={i} className="hover:bg-gray-50 dark:hover:bg-gray-700/30">
|
||||
<td className="px-3 py-2 text-gray-800 dark:text-gray-200">{e.name}</td>
|
||||
<td className="px-3 py-2 text-gray-500">{e.category}</td>
|
||||
<td className="px-3 py-2 text-gray-400">{e.zone || '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)
|
||||
}
|
||||
|
||||
function RiskBadge({ risk }: { risk: number }) {
|
||||
const color = risk >= 30 ? 'bg-red-100 text-red-700' : risk >= 15 ? 'bg-yellow-100 text-yellow-700' : 'bg-green-100 text-green-700'
|
||||
return <span className={`inline-block px-1.5 py-0.5 rounded text-[10px] font-bold ${color}`}>{risk}</span>
|
||||
}
|
||||
|
||||
function ScoreBadge({ score }: { score: number }) {
|
||||
const pct = Math.round(score * 100)
|
||||
const color = pct >= 70 ? 'text-green-600' : pct >= 50 ? 'text-yellow-600' : 'text-red-600'
|
||||
return <span className={`font-bold ${color}`}>{pct}%</span>
|
||||
}
|
||||
|
||||
function EmptyState({ text }: { text: string }) {
|
||||
return <div className="px-4 py-8 text-center text-sm text-gray-400">{text}</div>
|
||||
}
|
||||
Reference in New Issue
Block a user