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:
@@ -0,0 +1,191 @@
|
||||
'use client'
|
||||
|
||||
import React, { useMemo, useState, useRef, useEffect } from 'react'
|
||||
import { SearchInput, FilterDropdown, Pagination, ExternalLinkIcon } from './LibraryTable'
|
||||
|
||||
export interface CEDocument {
|
||||
regulation_id: string
|
||||
name_de: string
|
||||
name_en: string
|
||||
category: string
|
||||
source_url: string
|
||||
source_org: string
|
||||
chunk_count: number
|
||||
}
|
||||
|
||||
const PER_PAGE = 50
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
trbs: 'TRBS — Betriebssicherheit',
|
||||
trgs: 'TRGS — Gefahrstoffe',
|
||||
asr: 'ASR — Arbeitsstaetten',
|
||||
osha: 'OSHA — US Occupational Safety',
|
||||
ce_machinery: 'EU — Maschinenrecht',
|
||||
ce_machinery_guidance: 'EU — Leitfaeden',
|
||||
ce_electrical: 'EU — Niederspannung',
|
||||
ce_emc: 'EU — EMV',
|
||||
ce_radio: 'EU — Funkanlagen',
|
||||
ce_ai: 'EU — KI-Verordnung',
|
||||
ce_ai_safety: 'KI-Sicherheit',
|
||||
ce_software_safety: 'Software-Sicherheit',
|
||||
ce_software_security: 'Software-Security',
|
||||
ce_software_weaknesses: 'Software-Schwachstellen',
|
||||
ce_ot_cybersecurity: 'OT-Cybersecurity',
|
||||
eu_recht: 'EU-Recht',
|
||||
eu_datenschutz: 'EU-Datenschutz',
|
||||
guidance: 'Guidance',
|
||||
}
|
||||
|
||||
function categoryLabel(cat: string): string {
|
||||
return CATEGORY_LABELS[cat] || cat.replace(/_/g, ' ')
|
||||
}
|
||||
|
||||
function categoryColor(cat: string): string {
|
||||
if (cat.startsWith('trbs')) return 'bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-300'
|
||||
if (cat.startsWith('trgs')) return 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300'
|
||||
if (cat.startsWith('asr')) return 'bg-teal-100 text-teal-800 dark:bg-teal-900/40 dark:text-teal-300'
|
||||
if (cat === 'osha') return 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300'
|
||||
if (cat.startsWith('ce_')) return 'bg-purple-100 text-purple-800 dark:bg-purple-900/40 dark:text-purple-300'
|
||||
if (cat.startsWith('eu_')) return 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900/40 dark:text-indigo-300'
|
||||
return 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
|
||||
}
|
||||
|
||||
interface Props {
|
||||
documents: CEDocument[]
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export default function DokumenteTab({ documents, loading }: Props) {
|
||||
const [search, setSearch] = useState('')
|
||||
const [debounced, setDebounced] = useState('')
|
||||
const [categoryFilter, setCategoryFilter] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
timer.current = setTimeout(() => setDebounced(search), 300)
|
||||
return () => { if (timer.current) clearTimeout(timer.current) }
|
||||
}, [search])
|
||||
|
||||
useEffect(() => { setPage(1) }, [debounced, categoryFilter])
|
||||
|
||||
const categories = useMemo(() => {
|
||||
const cats = new Set(documents.map((d) => d.category))
|
||||
const opts = [{ value: '', label: 'Alle Kategorien' }]
|
||||
Array.from(cats).sort().forEach((c) => opts.push({ value: c, label: categoryLabel(c) }))
|
||||
return opts
|
||||
}, [documents])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = debounced.toLowerCase()
|
||||
return documents.filter((d) => {
|
||||
if (categoryFilter && d.category !== categoryFilter) return false
|
||||
if (q) {
|
||||
const hay = `${d.regulation_id} ${d.name_de} ${d.name_en} ${d.source_org}`.toLowerCase()
|
||||
if (!hay.includes(q)) return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
}, [documents, debounced, categoryFilter])
|
||||
|
||||
const totalPages = Math.ceil(filtered.length / PER_PAGE)
|
||||
const pageItems = filtered.slice((page - 1) * PER_PAGE, page * PER_PAGE)
|
||||
|
||||
const totalChunks = filtered.reduce((sum, d) => sum + d.chunk_count, 0)
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12 gap-3">
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-purple-600" />
|
||||
<span className="text-sm text-gray-500">Lade Dokumentenindex aus Qdrant...</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Stats bar */}
|
||||
<div className="flex flex-wrap items-center gap-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
<span className="font-medium">{documents.length} Dokumente</span>
|
||||
<span>|</span>
|
||||
<span>{documents.reduce((s, d) => s + d.chunk_count, 0).toLocaleString()} Chunks im Vektorspeicher</span>
|
||||
<span>|</span>
|
||||
<span>Quellen: BAuA, OSHA, EUR-Lex, NIST, ENISA</span>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<SearchInput value={search} onChange={setSearch} placeholder="Dokument suchen (z.B. TRBS 2111, Maschinenverordnung)..." />
|
||||
</div>
|
||||
<FilterDropdown label="Kategorie" value={categoryFilter} options={categories} onChange={setCategoryFilter} />
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400 ml-auto">
|
||||
{filtered.length !== documents.length && `${filtered.length} gefiltert | `}{totalChunks.toLocaleString()} Chunks
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<table className="w-full text-left">
|
||||
<thead className="bg-gray-100 dark:bg-gray-800">
|
||||
<tr>
|
||||
{['Kennung', 'Bezeichnung', 'Kategorie', 'Quelle', 'Chunks'].map((h) => (
|
||||
<th key={h} className="px-4 py-2.5 text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pageItems.map((d) => (
|
||||
<tr
|
||||
key={d.regulation_id}
|
||||
className="hover:bg-purple-50/50 dark:hover:bg-purple-900/10 transition-colors even:bg-gray-50/50 dark:even:bg-gray-800/30"
|
||||
>
|
||||
<td className="px-4 py-2.5 text-sm font-mono text-gray-700 dark:text-gray-300 whitespace-nowrap">
|
||||
{d.regulation_id}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300">
|
||||
<div className="max-w-md">
|
||||
<div className="truncate">{d.name_de || d.name_en || d.regulation_id}</div>
|
||||
{d.source_url && (
|
||||
<a
|
||||
href={d.source_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-purple-600 hover:text-purple-800 dark:text-purple-400"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
Quelle<ExternalLinkIcon />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 whitespace-nowrap">
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${categoryColor(d.category)}`}>
|
||||
{categoryLabel(d.category)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-sm text-gray-500 whitespace-nowrap">
|
||||
{d.source_org || '-'}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-sm font-mono text-gray-500 text-right whitespace-nowrap">
|
||||
{d.chunk_count.toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{pageItems.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-8 text-center text-sm text-gray-400">
|
||||
{documents.length === 0
|
||||
? 'Keine Dokumente im CE-Corpus gefunden. Qdrant-Verbindung pruefen.'
|
||||
: 'Keine Dokumente fuer diesen Filter gefunden'}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Pagination page={page} totalPages={totalPages} onPageChange={setPage} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -5,13 +5,15 @@ import Link from 'next/link'
|
||||
import NormenTab, { type Norm } from './_components/NormenTab'
|
||||
import PatternsTab, { type HazardPattern } from './_components/PatternsTab'
|
||||
import MeasuresTab, { type ProtectiveMeasure } from './_components/MeasuresTab'
|
||||
import DokumenteTab, { type CEDocument } from './_components/DokumenteTab'
|
||||
|
||||
type TabId = 'normen' | 'patterns' | 'measures'
|
||||
type TabId = 'normen' | 'patterns' | 'measures' | 'dokumente'
|
||||
|
||||
const TABS: { id: TabId; label: string }[] = [
|
||||
{ id: 'normen', label: 'Normen' },
|
||||
{ id: 'patterns', label: 'Patterns' },
|
||||
{ id: 'measures', label: 'Massnahmen' },
|
||||
{ id: 'dokumente', label: 'Dokumente' },
|
||||
]
|
||||
|
||||
export default function IACELibraryPage() {
|
||||
@@ -19,6 +21,9 @@ export default function IACELibraryPage() {
|
||||
const [norms, setNorms] = useState<Norm[]>([])
|
||||
const [patterns, setPatterns] = useState<HazardPattern[]>([])
|
||||
const [measures, setMeasures] = useState<ProtectiveMeasure[]>([])
|
||||
const [documents, setDocuments] = useState<CEDocument[]>([])
|
||||
const [docsLoading, setDocsLoading] = useState(false)
|
||||
const [docsFetched, setDocsFetched] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -50,10 +55,22 @@ export default function IACELibraryPage() {
|
||||
load()
|
||||
}, [])
|
||||
|
||||
// Lazy-load documents on tab switch
|
||||
useEffect(() => {
|
||||
if (activeTab !== 'dokumente' || docsFetched) return
|
||||
setDocsLoading(true)
|
||||
fetch('/api/sdk/v1/iace/ce-corpus-documents')
|
||||
.then((r) => r.ok ? r.json() : null)
|
||||
.then((data) => { if (data) setDocuments(data.documents || []) })
|
||||
.catch(() => {})
|
||||
.finally(() => { setDocsLoading(false); setDocsFetched(true) })
|
||||
}, [activeTab, docsFetched])
|
||||
|
||||
const counts: Record<TabId, number> = {
|
||||
normen: norms.length,
|
||||
patterns: patterns.length,
|
||||
measures: measures.length,
|
||||
dokumente: documents.length,
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
@@ -116,6 +133,7 @@ export default function IACELibraryPage() {
|
||||
{activeTab === 'normen' && <NormenTab norms={norms} />}
|
||||
{activeTab === 'patterns' && <PatternsTab patterns={patterns} />}
|
||||
{activeTab === 'measures' && <MeasuresTab measures={measures} />}
|
||||
{activeTab === 'dokumente' && <DokumenteTab documents={documents} loading={docsLoading} />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user