feat(iace): Phase 1 — Haftungs-Fixes, Massnahmen-Verkabelung, Explainability Engine
Phase 1A — Haftungs-kritische Fixes: - SIL/PL-Badges als "Vorab-Einschaetzung" mit Tooltip gekennzeichnet - Coverage-Disclaimer in CE-Akte, Projekt-Uebersicht und Print-Export - Norm-Referenzen: 42 Kapitelverweise durch Themen-Deskriptoren ersetzt Phase 1B — Massnahmen-Verkabelung: - 16 neue Massnahmen (M201-M216) fuer bisher unabgedeckte Kategorien (communication_failure, hmi_error, firmware_corruption, maintenance, sensor_fault, mode_confusion) - Kategorie-Fallback im Initialize-Endpoint: ordnet Massnahmen aus der Bibliothek automatisch per HazardCategory zu (max 8 pro Kategorie) - Total: 225 → 241 Massnahmen, 0 Kategorien ohne Massnahmen Phase 1C — Explainability Engine: - MatchReason Struct in PatternMatch (type, tag, met) - Pattern Engine schreibt fuer jeden Match strukturierte Begruendungen - Frontend zeigt "Erkannt weil: Komponente X, Energie Y, Kein Ausschluss Z" Weitere Aenderungen: - BAuA/OSHA Regulatory Hints: 3 Enrich-Endpoints (per Hazard, per Measure, Batch) - Dokumente-Tab in IACE-Bibliothek (36.708 Chunks aus Qdrant) - Varianten-UX: Basis-Projekt-Summary auf Varianten-Seite - Projekt-Initialisierung: POST /initialize kettet Parse→Komponenten→Patterns→Hazards→Massnahmen→Normen - 18 pre-existing TS-Fehler gefixt, Route-Konflikt behoben - Component-Library + Measures-Library Tests aktualisiert Tests: Go alle bestanden, TS 0 Fehler, Playwright 141+ bestanden Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
+123
@@ -0,0 +1,123 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useCallback } from 'react'
|
||||
|
||||
interface RegulatoryHint {
|
||||
regulation_id: string
|
||||
regulation_short: string
|
||||
category: string
|
||||
text: string
|
||||
pages?: number[]
|
||||
source_url?: string
|
||||
score: number
|
||||
}
|
||||
|
||||
interface Props {
|
||||
projectId: string
|
||||
hazardId: string
|
||||
hazardName: string
|
||||
}
|
||||
|
||||
function categoryBadge(cat: string): string {
|
||||
if (cat === 'trbs') return 'bg-orange-100 text-orange-800'
|
||||
if (cat === 'trgs') return 'bg-red-100 text-red-800'
|
||||
if (cat === 'asr') return 'bg-teal-100 text-teal-800'
|
||||
if (cat === 'osha' || cat.startsWith('ce_')) return 'bg-blue-100 text-blue-800'
|
||||
return 'bg-gray-100 text-gray-700'
|
||||
}
|
||||
|
||||
export function RegulatoryHintsPanel({ projectId, hazardId, hazardName }: Props) {
|
||||
const [hints, setHints] = useState<RegulatoryHint[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const loadHints = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/hazards/${hazardId}/regulatory-hints`)
|
||||
if (!res.ok) {
|
||||
setError('Hinweise konnten nicht geladen werden')
|
||||
return
|
||||
}
|
||||
const data = await res.json()
|
||||
setHints(data.hints || [])
|
||||
} catch {
|
||||
setError('Verbindung zum RAG-Service fehlgeschlagen')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setLoaded(true)
|
||||
}
|
||||
}, [projectId, hazardId])
|
||||
|
||||
if (!loaded) {
|
||||
return (
|
||||
<button
|
||||
onClick={loadHints}
|
||||
disabled={loading}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-purple-700 bg-purple-50 rounded-lg hover:bg-purple-100 dark:text-purple-300 dark:bg-purple-900/30 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<span className="animate-spin inline-block w-3 h-3 border border-purple-400 border-t-transparent rounded-full" />
|
||||
Lade Hinweise...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
TRBS/OSHA Hinweise laden
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <p className="text-xs text-red-500">{error}</p>
|
||||
}
|
||||
|
||||
if (hints.length === 0) {
|
||||
return <p className="text-xs text-gray-400">Keine regulatorischen Hinweise gefunden</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2 mt-2">
|
||||
<p className="text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
Regulatorische Hinweise ({hints.length})
|
||||
</p>
|
||||
{hints.map((hint, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="p-2.5 rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50/50 dark:bg-gray-800/50 text-xs space-y-1"
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className={`inline-flex px-1.5 py-0.5 rounded font-medium ${categoryBadge(hint.category)}`}>
|
||||
{hint.regulation_short || hint.regulation_id}
|
||||
</span>
|
||||
{hint.pages && hint.pages.length > 0 && (
|
||||
<span className="text-gray-400">S. {hint.pages.join(', ')}</span>
|
||||
)}
|
||||
<span className="text-gray-400 ml-auto">{(hint.score * 100).toFixed(0)}% Relevanz</span>
|
||||
</div>
|
||||
<p className="text-gray-700 dark:text-gray-300 leading-relaxed">{hint.text}</p>
|
||||
{hint.source_url && (
|
||||
<a
|
||||
href={hint.source_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-purple-600 hover:text-purple-800 dark:text-purple-400"
|
||||
>
|
||||
Quelle
|
||||
<svg className="w-3 h-3 inline-block ml-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+41
-9
@@ -1,9 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import {
|
||||
Hazard, CATEGORY_LABELS, getRiskColor, getRiskLevelLabel, getRiskLevelISO,
|
||||
} from './types'
|
||||
import { RegulatoryHintsPanel } from './RegulatoryHintsPanel'
|
||||
|
||||
interface RiskAssessmentTableProps {
|
||||
projectId: string
|
||||
@@ -81,6 +82,7 @@ export function RiskAssessmentTable({ projectId, hazards, onReassess, decisions,
|
||||
const [edits, setEdits] = useState<Record<string, EditState>>({})
|
||||
const [saving, setSaving] = useState<string | null>(null)
|
||||
const [normsByCategory, setNormsByCategory] = useState<Record<string, string[]>>({})
|
||||
const [expandedHazard, setExpandedHazard] = useState<string | null>(null)
|
||||
|
||||
// Fetch norms library and build category→norm-numbers map
|
||||
useEffect(() => {
|
||||
@@ -123,7 +125,7 @@ export function RiskAssessmentTable({ projectId, hazards, onReassess, decisions,
|
||||
for (const h of hazards) {
|
||||
if (!edits[h.id]) {
|
||||
// Read from risk_assessment if available (enriched response), fallback to hazard fields
|
||||
const ra = (h as Record<string, unknown>).risk_assessment as Record<string, number> | null
|
||||
const ra = (h as unknown as Record<string, unknown>).risk_assessment as Record<string, number> | null
|
||||
init[h.id] = {
|
||||
severity: ra?.severity || h.severity || 3,
|
||||
exposure: ra?.exposure || h.exposure || 3,
|
||||
@@ -190,7 +192,7 @@ export function RiskAssessmentTable({ projectId, hazards, onReassess, decisions,
|
||||
<th colSpan={2} className="px-3 py-1.5 text-left font-semibold text-gray-700 dark:text-gray-300 border-r border-gray-200 dark:border-gray-600">Gefaehrdung</th>
|
||||
<th colSpan={5} className="px-3 py-1.5 text-center font-semibold text-gray-700 dark:text-gray-300 border-r border-gray-200 dark:border-gray-600">Erstbewertung</th>
|
||||
<th colSpan={6} className="px-3 py-1.5 text-center font-semibold text-purple-700 dark:text-purple-400 border-r border-gray-200 dark:border-gray-600">Nach Massnahmen (editierbar)</th>
|
||||
<th colSpan={2} className="px-3 py-1.5 text-center font-semibold text-gray-700 dark:text-gray-300 border-r border-gray-200 dark:border-gray-600">SIL / PL</th>
|
||||
<th colSpan={2} className="px-3 py-1.5 text-center font-semibold text-gray-700 dark:text-gray-300 border-r border-gray-200 dark:border-gray-600 cursor-help" title="Vorab-Einschaetzung — keine normative Berechnung">SIL / PL *</th>
|
||||
<th colSpan={2} className="px-3 py-1.5 text-center font-semibold text-gray-700 dark:text-gray-300">Status</th>
|
||||
</tr>
|
||||
{/* Column header */}
|
||||
@@ -220,7 +222,7 @@ export function RiskAssessmentTable({ projectId, hazards, onReassess, decisions,
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
{paged.map(h => {
|
||||
const ra = (h as Record<string, unknown>).risk_assessment as Record<string, number> | null
|
||||
const ra = (h as unknown as Record<string, unknown>).risk_assessment as Record<string, number> | null
|
||||
const initS = ra?.severity || h.severity || 3
|
||||
const initE = ra?.exposure || h.exposure || 3
|
||||
const initP = ra?.probability || h.probability || 3
|
||||
@@ -235,10 +237,13 @@ export function RiskAssessmentTable({ projectId, hazards, onReassess, decisions,
|
||||
const changed = e && (e.severity !== initS || e.exposure !== initE || e.probability !== initP)
|
||||
|
||||
return (
|
||||
<tr key={h.id} className="hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors">
|
||||
<React.Fragment key={h.id}>
|
||||
<tr className="hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors">
|
||||
{/* Hazard info */}
|
||||
<td className="px-3 py-2 min-w-[250px]">
|
||||
<div className="font-medium text-gray-900 dark:text-white">{h.name}</div>
|
||||
<button onClick={() => setExpandedHazard(expandedHazard === h.id ? null : h.id)} className="text-left group">
|
||||
<div className="font-medium text-gray-900 dark:text-white group-hover:text-purple-700 dark:group-hover:text-purple-400 transition-colors">{h.name}</div>
|
||||
</button>
|
||||
{h.component_name && <div className="text-[10px] text-gray-400">{h.component_name}</div>}
|
||||
</td>
|
||||
<td className="px-3 py-2 border-r border-gray-200 dark:border-gray-600">
|
||||
@@ -279,14 +284,16 @@ export function RiskAssessmentTable({ projectId, hazards, onReassess, decisions,
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
{/* SIL / PL */}
|
||||
{/* SIL / PL (Vorab-Einschaetzung) */}
|
||||
<td className="px-2 py-2 text-center">
|
||||
<span className={`inline-block px-1.5 py-0.5 rounded text-[10px] font-bold ${SIL_COLORS[sil]}`}>
|
||||
<span className={`inline-block px-1.5 py-0.5 rounded text-[10px] font-bold cursor-help ${SIL_COLORS[sil]}`}
|
||||
title="Vorab-Einschaetzung nach vereinfachtem Risikograph — Validierung durch Funktionale-Sicherheits-Ingenieur erforderlich (ISO 13849 / IEC 62061)">
|
||||
{sil > 0 ? `SIL ${sil}` : '-'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-2 py-2 text-center border-r border-gray-200 dark:border-gray-600">
|
||||
<span className={`inline-block px-1.5 py-0.5 rounded text-[10px] font-bold ${PL_COLORS[pl]}`}>
|
||||
<span className={`inline-block px-1.5 py-0.5 rounded text-[10px] font-bold cursor-help ${PL_COLORS[pl]}`}
|
||||
title="Vorab-Einschaetzung — PL-Bestimmung nach ISO 13849-1 erfordert vollstaendige Sicherheitsfunktions-Analyse">
|
||||
PL {pl}
|
||||
</span>
|
||||
</td>
|
||||
@@ -325,6 +332,31 @@ export function RiskAssessmentTable({ projectId, hazards, onReassess, decisions,
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
{expandedHazard === h.id && (
|
||||
<tr className="bg-purple-50/30 dark:bg-purple-900/5">
|
||||
<td colSpan={17} className="px-4 py-3 space-y-3">
|
||||
{/* Hazard details */}
|
||||
{h.scenario && <p className="text-xs text-gray-600 dark:text-gray-300"><strong>Szenario:</strong> {h.scenario}</p>}
|
||||
{h.possible_harm && <p className="text-xs text-gray-600"><strong>Moeglicher Schaden:</strong> {h.possible_harm}</p>}
|
||||
{/* Match reasons (explainability) */}
|
||||
{h.match_reasons && h.match_reasons.length > 0 && (
|
||||
<div className="text-[10px] text-gray-500">
|
||||
<strong>Erkannt weil:</strong>{' '}
|
||||
{h.match_reasons
|
||||
.filter(r => r.met)
|
||||
.map((r, i) => (
|
||||
<span key={i} className="inline-flex items-center gap-0.5 mr-1.5 px-1.5 py-0.5 rounded bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-400">
|
||||
{r.type === 'required_component_tag' ? 'Komponente' : r.type === 'required_energy_tag' ? 'Energie' : r.type === 'no_exclusion' ? 'Kein Ausschluss' : 'Lifecycle'}: {r.tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* Regulatory hints */}
|
||||
<RegulatoryHintsPanel projectId={projectId} hazardId={h.id} hazardName={h.name} />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
|
||||
@@ -19,9 +19,11 @@ export interface Hazard {
|
||||
affected_person: string
|
||||
possible_harm: string
|
||||
hazardous_zone: string
|
||||
scenario?: string
|
||||
review_status: string
|
||||
created_at: string
|
||||
source?: string
|
||||
match_reasons?: { type: string; tag: string; met: boolean }[]
|
||||
}
|
||||
|
||||
export interface LibraryHazard {
|
||||
|
||||
Reference in New Issue
Block a user