feat(iace): Phase 5+6 — frontend integration, RAG library search, comprehensive tests
All checks were successful
CI/CD / go-lint (push) Has been skipped
CI/CD / python-lint (push) Has been skipped
CI/CD / nodejs-lint (push) Has been skipped
CI/CD / test-go-ai-compliance (push) Successful in 34s
CI/CD / test-python-backend-compliance (push) Successful in 33s
CI/CD / test-python-document-crawler (push) Successful in 23s
CI/CD / test-python-dsms-gateway (push) Successful in 19s
CI/CD / validate-canonical-controls (push) Successful in 13s
CI/CD / Deploy (push) Successful in 2s

Phase 5 — Frontend Integration:
- components/page.tsx: ComponentLibraryModal with 120 components + 20 energy sources
- hazards/page.tsx: AutoSuggestPanel with 3-column pattern matching review
- mitigations/page.tsx: SuggestMeasuresModal per hazard with 3-level grouping
- verification/page.tsx: SuggestEvidenceModal per mitigation with evidence types

Phase 6 — RAG Library Search:
- Added bp_iace_libraries to AllowedCollections whitelist in rag_handlers.go
- SearchLibrary endpoint: POST /iace/library-search (semantic search across libraries)
- EnrichTechFileSection endpoint: POST /projects/:id/tech-file/:section/enrich
- Created ingest-iace-libraries.sh ingestion script for Qdrant collection

Tests (123 passing):
- tag_taxonomy_test.go: 8 tests for taxonomy entries, domains, essential tags
- controls_library_test.go: 7 tests for measures, reduction types, subtypes
- integration_test.go: 7 integration tests for full match flow and library consistency
- Extended tag_resolver_test.go: 9 new tests for FindByTags and cross-category resolution

Documentation:
- Updated iace.md with Hazard-Matching-Engine, RAG enrichment, and new DB tables

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-03-16 10:22:49 +01:00
parent 3b2006ebce
commit 9c1355c05f
13 changed files with 2422 additions and 43 deletions

View File

@@ -12,6 +12,46 @@ interface Component {
safety_relevant: boolean
parent_id: string | null
children: Component[]
library_component_id?: string
energy_source_ids?: string[]
}
interface LibraryComponent {
id: string
name_de: string
name_en: string
category: string
description_de: string
typical_hazard_categories: string[]
typical_energy_sources: string[]
maps_to_component_type: string
tags: string[]
sort_order: number
}
interface EnergySource {
id: string
name_de: string
name_en: string
description_de: string
typical_components: string[]
typical_hazard_categories: string[]
tags: string[]
sort_order: number
}
const LIBRARY_CATEGORIES: Record<string, string> = {
mechanical: 'Mechanik',
structural: 'Struktur',
drive: 'Antrieb',
hydraulic: 'Hydraulik',
pneumatic: 'Pneumatik',
electrical: 'Elektrik',
control: 'Steuerung',
sensor: 'Sensorik',
actuator: 'Aktorik',
safety: 'Sicherheit',
it_network: 'IT/Netzwerk',
}
const COMPONENT_TYPES = [
@@ -98,6 +138,11 @@ function ComponentTreeNode({
Sicherheitsrelevant
</span>
)}
{component.library_component_id && (
<span className="ml-2 inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-700">
Bibliothek
</span>
)}
</div>
{component.description && (
@@ -289,6 +334,289 @@ function buildTree(components: Component[]): Component[] {
return roots
}
// ============================================================================
// Component Library Modal (Phase 5)
// ============================================================================
function ComponentLibraryModal({
onAdd,
onClose,
}: {
onAdd: (components: LibraryComponent[], energySources: EnergySource[]) => void
onClose: () => void
}) {
const [libraryComponents, setLibraryComponents] = useState<LibraryComponent[]>([])
const [energySources, setEnergySources] = useState<EnergySource[]>([])
const [selectedComponents, setSelectedComponents] = useState<Set<string>>(new Set())
const [selectedEnergySources, setSelectedEnergySources] = useState<Set<string>>(new Set())
const [search, setSearch] = useState('')
const [filterCategory, setFilterCategory] = useState('')
const [activeTab, setActiveTab] = useState<'components' | 'energy'>('components')
const [loading, setLoading] = useState(true)
useEffect(() => {
async function fetchData() {
try {
const [compRes, enRes] = await Promise.all([
fetch('/api/sdk/v1/iace/component-library'),
fetch('/api/sdk/v1/iace/energy-sources'),
])
if (compRes.ok) {
const json = await compRes.json()
setLibraryComponents(json.components || [])
}
if (enRes.ok) {
const json = await enRes.json()
setEnergySources(json.energy_sources || [])
}
} catch (err) {
console.error('Failed to fetch library:', err)
} finally {
setLoading(false)
}
}
fetchData()
}, [])
function toggleComponent(id: string) {
setSelectedComponents(prev => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
function toggleEnergySource(id: string) {
setSelectedEnergySources(prev => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
function toggleAllInCategory(category: string) {
const items = libraryComponents.filter(c => c.category === category)
const allIds = items.map(i => i.id)
const allSelected = allIds.every(id => selectedComponents.has(id))
setSelectedComponents(prev => {
const next = new Set(prev)
allIds.forEach(id => allSelected ? next.delete(id) : next.add(id))
return next
})
}
function handleAdd() {
const selComps = libraryComponents.filter(c => selectedComponents.has(c.id))
const selEnergy = energySources.filter(e => selectedEnergySources.has(e.id))
onAdd(selComps, selEnergy)
}
const filtered = libraryComponents.filter(c => {
if (filterCategory && c.category !== filterCategory) return false
if (search) {
const q = search.toLowerCase()
return c.name_de.toLowerCase().includes(q) || c.name_en.toLowerCase().includes(q) || c.description_de.toLowerCase().includes(q)
}
return true
})
const grouped = filtered.reduce<Record<string, LibraryComponent[]>>((acc, c) => {
if (!acc[c.category]) acc[c.category] = []
acc[c.category].push(c)
return acc
}, {})
const categories = Object.keys(LIBRARY_CATEGORIES)
const totalSelected = selectedComponents.size + selectedEnergySources.size
if (loading) {
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-xl p-8 text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600 mx-auto" />
<p className="mt-3 text-sm text-gray-500">Bibliothek wird geladen...</p>
</div>
</div>
)
}
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-xl w-full max-w-4xl max-h-[85vh] flex flex-col">
{/* Header */}
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Komponentenbibliothek</h3>
<button onClick={onClose} className="p-1 text-gray-400 hover:text-gray-600 rounded">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Tabs */}
<div className="flex gap-2 mb-4">
<button
onClick={() => setActiveTab('components')}
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
activeTab === 'components' ? 'bg-purple-100 text-purple-700' : 'text-gray-500 hover:bg-gray-100'
}`}
>
Komponenten ({libraryComponents.length})
</button>
<button
onClick={() => setActiveTab('energy')}
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
activeTab === 'energy' ? 'bg-purple-100 text-purple-700' : 'text-gray-500 hover:bg-gray-100'
}`}
>
Energiequellen ({energySources.length})
</button>
</div>
{activeTab === 'components' && (
<div className="flex gap-3">
<input
type="text"
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Suchen..."
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
/>
<select
value={filterCategory}
onChange={e => setFilterCategory(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
>
<option value="">Alle Kategorien</option>
{categories.map(cat => (
<option key={cat} value={cat}>{LIBRARY_CATEGORIES[cat]}</option>
))}
</select>
</div>
)}
</div>
{/* Body */}
<div className="flex-1 overflow-auto p-4">
{activeTab === 'components' ? (
<div className="space-y-4">
{Object.entries(grouped)
.sort(([a], [b]) => categories.indexOf(a) - categories.indexOf(b))
.map(([category, items]) => (
<div key={category}>
<div className="flex items-center gap-2 mb-2 sticky top-0 bg-white dark:bg-gray-800 py-1 z-10">
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300">
{LIBRARY_CATEGORIES[category] || category}
</h4>
<span className="text-xs text-gray-400">({items.length})</span>
<button
onClick={() => toggleAllInCategory(category)}
className="text-xs text-purple-600 hover:text-purple-700 ml-auto"
>
{items.every(i => selectedComponents.has(i.id)) ? 'Alle abwaehlen' : 'Alle waehlen'}
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{items.map(comp => (
<label
key={comp.id}
className={`flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${
selectedComponents.has(comp.id)
? 'border-purple-400 bg-purple-50 dark:bg-purple-900/20'
: 'border-gray-200 hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-750'
}`}
>
<input
type="checkbox"
checked={selectedComponents.has(comp.id)}
onChange={() => toggleComponent(comp.id)}
className="mt-0.5 accent-purple-600"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-xs font-mono text-gray-400">{comp.id}</span>
<ComponentTypeIcon type={comp.maps_to_component_type} />
</div>
<div className="text-sm font-medium text-gray-900 dark:text-white">{comp.name_de}</div>
{comp.description_de && (
<div className="text-xs text-gray-500 mt-0.5 line-clamp-2">{comp.description_de}</div>
)}
</div>
</label>
))}
</div>
</div>
))}
{filtered.length === 0 && (
<div className="text-center py-8 text-gray-500">Keine Komponenten gefunden</div>
)}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{energySources.map(es => (
<label
key={es.id}
className={`flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${
selectedEnergySources.has(es.id)
? 'border-purple-400 bg-purple-50 dark:bg-purple-900/20'
: 'border-gray-200 hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-750'
}`}
>
<input
type="checkbox"
checked={selectedEnergySources.has(es.id)}
onChange={() => toggleEnergySource(es.id)}
className="mt-0.5 accent-purple-600"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-xs font-mono text-gray-400">{es.id}</span>
</div>
<div className="text-sm font-medium text-gray-900 dark:text-white">{es.name_de}</div>
{es.description_de && (
<div className="text-xs text-gray-500 mt-0.5 line-clamp-2">{es.description_de}</div>
)}
</div>
</label>
))}
</div>
)}
</div>
{/* Footer */}
<div className="p-4 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between">
<span className="text-sm text-gray-500">
{selectedComponents.size} Komponenten, {selectedEnergySources.size} Energiequellen ausgewaehlt
</span>
<div className="flex gap-3">
<button onClick={onClose} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
Abbrechen
</button>
<button
onClick={handleAdd}
disabled={totalSelected === 0}
className={`px-6 py-2 rounded-lg font-medium transition-colors ${
totalSelected > 0
? 'bg-purple-600 text-white hover:bg-purple-700'
: 'bg-gray-200 text-gray-400 cursor-not-allowed'
}`}
>
{totalSelected > 0 ? `${totalSelected} hinzufuegen` : 'Auswaehlen'}
</button>
</div>
</div>
</div>
</div>
)
}
// ============================================================================
// Main Page
// ============================================================================
export default function ComponentsPage() {
const params = useParams()
const projectId = params.projectId as string
@@ -297,6 +625,7 @@ export default function ComponentsPage() {
const [showForm, setShowForm] = useState(false)
const [editingComponent, setEditingComponent] = useState<Component | null>(null)
const [addingParentId, setAddingParentId] = useState<string | null>(null)
const [showLibrary, setShowLibrary] = useState(false)
useEffect(() => {
fetchComponents()
@@ -365,6 +694,32 @@ export default function ComponentsPage() {
setShowForm(true)
}
async function handleAddFromLibrary(libraryComps: LibraryComponent[], energySrcs: EnergySource[]) {
setShowLibrary(false)
const energySourceIds = energySrcs.map(e => e.id)
for (const comp of libraryComps) {
try {
await fetch(`/api/sdk/v1/iace/projects/${projectId}/components`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: comp.name_de,
type: comp.maps_to_component_type,
description: comp.description_de,
safety_relevant: false,
library_component_id: comp.id,
energy_source_ids: energySourceIds,
tags: comp.tags,
}),
})
} catch (err) {
console.error(`Failed to add component ${comp.id}:`, err)
}
}
await fetchComponents()
}
const tree = buildTree(components)
if (loading) {
@@ -386,22 +741,41 @@ export default function ComponentsPage() {
</p>
</div>
{!showForm && (
<button
onClick={() => {
setShowForm(true)
setEditingComponent(null)
setAddingParentId(null)
}}
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Komponente hinzufuegen
</button>
<div className="flex items-center gap-2">
<button
onClick={() => setShowLibrary(true)}
className="flex items-center gap-2 px-3 py-2 border border-purple-300 text-purple-700 rounded-lg hover:bg-purple-50 transition-colors text-sm"
>
<svg className="w-4 h-4" 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>
Aus Bibliothek waehlen
</button>
<button
onClick={() => {
setShowForm(true)
setEditingComponent(null)
setAddingParentId(null)
}}
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Komponente hinzufuegen
</button>
</div>
)}
</div>
{/* Library Modal */}
{showLibrary && (
<ComponentLibraryModal
onAdd={handleAddFromLibrary}
onClose={() => setShowLibrary(false)}
/>
)}
{/* Form */}
{showForm && (
<ComponentForm
@@ -454,12 +828,20 @@ export default function ComponentsPage() {
Beginnen Sie mit der Erfassung aller relevanten Komponenten Ihrer Maschine.
Erstellen Sie eine hierarchische Struktur aus Software, Firmware, KI-Modulen und Hardware.
</p>
<button
onClick={() => setShowForm(true)}
className="mt-6 px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
>
Erste Komponente hinzufuegen
</button>
<div className="mt-6 flex items-center justify-center gap-3">
<button
onClick={() => setShowLibrary(true)}
className="px-6 py-3 border border-purple-300 text-purple-700 rounded-lg hover:bg-purple-50 transition-colors"
>
Aus Bibliothek waehlen
</button>
<button
onClick={() => setShowForm(true)}
className="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
>
Manuell hinzufuegen
</button>
</div>
</div>
)
)}

View File

@@ -26,6 +26,7 @@ interface Hazard {
hazardous_zone: string
review_status: string
created_at: string
source?: string
}
interface LibraryHazard {
@@ -60,6 +61,38 @@ interface RoleInfo {
sort_order: number
}
// Pattern matching types (Phase 5)
interface PatternMatch {
pattern_id: string
pattern_name: string
priority: number
matched_tags: string[]
}
interface HazardSuggestion {
category: string
source_patterns: string[]
confidence: number
}
interface MeasureSuggestion {
measure_id: string
source_patterns: string[]
}
interface EvidenceSuggestion {
evidence_id: string
source_patterns: string[]
}
interface MatchOutput {
matched_patterns: PatternMatch[]
suggested_hazards: HazardSuggestion[]
suggested_measures: MeasureSuggestion[]
suggested_evidence: EvidenceSuggestion[]
resolved_tags: string[]
}
// ISO 12100 Hazard Categories (A-J)
const HAZARD_CATEGORIES = [
'mechanical', 'electrical', 'thermal',
@@ -128,7 +161,7 @@ function getRiskColor(level: string): string {
}
}
// ISO 12100 mode risk levels (S×F×P×A, max 625)
// ISO 12100 mode risk levels (S*F*P*A, max 625)
function getRiskLevelISO(r: number): string {
if (r > 300) return 'not_acceptable'
if (r >= 151) return 'very_high'
@@ -137,7 +170,7 @@ function getRiskLevelISO(r: number): string {
return 'low'
}
// Legacy mode (S×E×P, max 125)
// Legacy mode (S*E*P, max 125)
function getRiskLevelLegacy(r: number): string {
if (r >= 100) return 'critical'
if (r >= 50) return 'high'
@@ -227,7 +260,7 @@ function HazardForm({
const [showExtended, setShowExtended] = useState(false)
// ISO 12100 mode: S × F × P × A when avoidance is set
// ISO 12100 mode: S * F * P * A when avoidance is set
const isISOMode = formData.avoidance > 0
const rInherent = isISOMode
? formData.severity * formData.exposure * formData.probability * formData.avoidance
@@ -572,6 +605,232 @@ function LibraryModal({
)
}
// ============================================================================
// Auto-Suggest Panel (Phase 5 — Pattern Matching)
// ============================================================================
function AutoSuggestPanel({
projectId,
matchResult,
applying,
onApply,
onClose,
}: {
projectId: string
matchResult: MatchOutput
applying: boolean
onApply: (acceptedHazardCats: string[], acceptedMeasureIds: string[], acceptedEvidenceIds: string[], patternIds: string[]) => void
onClose: () => void
}) {
const [selectedHazards, setSelectedHazards] = useState<Set<string>>(
new Set(matchResult.suggested_hazards.map(h => h.category))
)
const [selectedMeasures, setSelectedMeasures] = useState<Set<string>>(
new Set(matchResult.suggested_measures.map(m => m.measure_id))
)
const [selectedEvidence, setSelectedEvidence] = useState<Set<string>>(
new Set(matchResult.suggested_evidence.map(e => e.evidence_id))
)
function toggleHazard(cat: string) {
setSelectedHazards(prev => {
const next = new Set(prev)
if (next.has(cat)) next.delete(cat)
else next.add(cat)
return next
})
}
function toggleMeasure(id: string) {
setSelectedMeasures(prev => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
function toggleEvidence(id: string) {
setSelectedEvidence(prev => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
const totalSelected = selectedHazards.size + selectedMeasures.size + selectedEvidence.size
return (
<div className="bg-white dark:bg-gray-800 rounded-xl border-2 border-purple-300 p-6 space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
Pattern-Matching Ergebnisse
</h3>
<p className="text-sm text-gray-500">
{matchResult.matched_patterns.length} Patterns erkannt, {matchResult.resolved_tags.length} Tags aufgeloest
</p>
</div>
<button onClick={onClose} className="p-1 text-gray-400 hover:text-gray-600 rounded">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Matched Patterns */}
<div>
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
Erkannte Patterns ({matchResult.matched_patterns.length})
</h4>
<div className="flex flex-wrap gap-2">
{matchResult.matched_patterns
.sort((a, b) => b.priority - a.priority)
.map(p => (
<span key={p.pattern_id} className="inline-flex items-center gap-1 px-2 py-1 rounded-lg text-xs bg-purple-50 text-purple-700 border border-purple-200">
<span className="font-mono">{p.pattern_id}</span>
<span>{p.pattern_name}</span>
<span className="text-purple-400">P:{p.priority}</span>
</span>
))}
</div>
</div>
{/* 3-Column Review */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
{/* Hazards */}
<div className="border border-orange-200 rounded-lg p-3 bg-orange-50/50">
<h4 className="text-sm font-semibold text-orange-800 mb-2">
Gefaehrdungen ({matchResult.suggested_hazards.length})
</h4>
<div className="space-y-2 max-h-48 overflow-auto">
{matchResult.suggested_hazards.map(h => (
<label key={h.category} className="flex items-start gap-2 cursor-pointer">
<input
type="checkbox"
checked={selectedHazards.has(h.category)}
onChange={() => toggleHazard(h.category)}
className="mt-0.5 accent-purple-600"
/>
<div>
<div className="text-xs font-medium text-gray-900">
{CATEGORY_LABELS[h.category] || h.category}
</div>
<div className="text-xs text-gray-500">
Konfidenz: {Math.round(h.confidence * 100)}%
</div>
</div>
</label>
))}
</div>
</div>
{/* Measures */}
<div className="border border-green-200 rounded-lg p-3 bg-green-50/50">
<h4 className="text-sm font-semibold text-green-800 mb-2">
Massnahmen ({matchResult.suggested_measures.length})
</h4>
<div className="space-y-2 max-h-48 overflow-auto">
{matchResult.suggested_measures.map(m => (
<label key={m.measure_id} className="flex items-start gap-2 cursor-pointer">
<input
type="checkbox"
checked={selectedMeasures.has(m.measure_id)}
onChange={() => toggleMeasure(m.measure_id)}
className="mt-0.5 accent-purple-600"
/>
<div>
<div className="text-xs font-medium text-gray-900 font-mono">{m.measure_id}</div>
<div className="text-xs text-gray-500">
von {m.source_patterns.length} Pattern(s)
</div>
</div>
</label>
))}
</div>
</div>
{/* Evidence */}
<div className="border border-blue-200 rounded-lg p-3 bg-blue-50/50">
<h4 className="text-sm font-semibold text-blue-800 mb-2">
Nachweise ({matchResult.suggested_evidence.length})
</h4>
<div className="space-y-2 max-h-48 overflow-auto">
{matchResult.suggested_evidence.map(e => (
<label key={e.evidence_id} className="flex items-start gap-2 cursor-pointer">
<input
type="checkbox"
checked={selectedEvidence.has(e.evidence_id)}
onChange={() => toggleEvidence(e.evidence_id)}
className="mt-0.5 accent-purple-600"
/>
<div>
<div className="text-xs font-medium text-gray-900 font-mono">{e.evidence_id}</div>
<div className="text-xs text-gray-500">
von {e.source_patterns.length} Pattern(s)
</div>
</div>
</label>
))}
</div>
</div>
</div>
{/* Tags */}
{matchResult.resolved_tags.length > 0 && (
<div>
<h4 className="text-xs font-semibold text-gray-500 mb-1">Aufgeloeste Tags</h4>
<div className="flex flex-wrap gap-1">
{matchResult.resolved_tags.map(tag => (
<span key={tag} className="text-xs px-1.5 py-0.5 rounded bg-gray-100 text-gray-500">{tag}</span>
))}
</div>
</div>
)}
{/* Actions */}
<div className="flex items-center justify-between pt-2 border-t border-gray-200">
<span className="text-sm text-gray-500">
{totalSelected} Elemente ausgewaehlt
</span>
<div className="flex gap-3">
<button onClick={onClose} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
Abbrechen
</button>
<button
onClick={() => onApply(
Array.from(selectedHazards),
Array.from(selectedMeasures),
Array.from(selectedEvidence),
matchResult.matched_patterns.map(p => p.pattern_id),
)}
disabled={totalSelected === 0 || applying}
className={`px-6 py-2 rounded-lg font-medium transition-colors ${
totalSelected > 0 && !applying
? 'bg-purple-600 text-white hover:bg-purple-700'
: 'bg-gray-200 text-gray-400 cursor-not-allowed'
}`}
>
{applying ? (
<span className="flex items-center gap-2">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white" />
Wird uebernommen...
</span>
) : (
`${totalSelected} uebernehmen`
)}
</button>
</div>
</div>
</div>
)
}
// ============================================================================
// Main Page
// ============================================================================
export default function HazardsPage() {
const params = useParams()
const projectId = params.projectId as string
@@ -583,6 +842,10 @@ export default function HazardsPage() {
const [showForm, setShowForm] = useState(false)
const [showLibrary, setShowLibrary] = useState(false)
const [suggestingAI, setSuggestingAI] = useState(false)
// Pattern matching state (Phase 5)
const [matchingPatterns, setMatchingPatterns] = useState(false)
const [matchResult, setMatchResult] = useState<MatchOutput | null>(null)
const [applyingPatterns, setApplyingPatterns] = useState(false)
useEffect(() => {
fetchHazards()
@@ -698,6 +961,99 @@ export default function HazardsPage() {
}
}
// Pattern matching (Phase 5)
async function handlePatternMatching() {
setMatchingPatterns(true)
setMatchResult(null)
try {
// First, fetch component library IDs and energy source IDs from project components
const compRes = await fetch(`/api/sdk/v1/iace/projects/${projectId}/components`)
let componentLibraryIds: string[] = []
let energySourceIds: string[] = []
if (compRes.ok) {
const compJson = await compRes.json()
const comps = compJson.components || compJson || []
componentLibraryIds = comps
.map((c: { library_component_id?: string }) => c.library_component_id)
.filter(Boolean) as string[]
const allEnergyIds = comps
.flatMap((c: { energy_source_ids?: string[] }) => c.energy_source_ids || [])
energySourceIds = [...new Set(allEnergyIds)] as string[]
}
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/match-patterns`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
component_library_ids: componentLibraryIds,
energy_source_ids: energySourceIds,
lifecycle_phases: [],
custom_tags: [],
}),
})
if (res.ok) {
const result: MatchOutput = await res.json()
setMatchResult(result)
}
} catch (err) {
console.error('Failed to match patterns:', err)
} finally {
setMatchingPatterns(false)
}
}
async function handleApplyPatterns(
acceptedHazardCats: string[],
acceptedMeasureIds: string[],
acceptedEvidenceIds: string[],
patternIds: string[],
) {
setApplyingPatterns(true)
try {
// Build the accepted hazard requests from categories
const acceptedHazards = acceptedHazardCats.map(cat => ({
name: `Auto: ${CATEGORY_LABELS[cat] || cat}`,
description: `Automatisch erkannte Gefaehrdung aus Pattern-Matching (Kategorie: ${cat})`,
category: cat,
severity: 3,
exposure: 3,
probability: 3,
avoidance: 3,
}))
const acceptedMeasures = acceptedMeasureIds.map(id => ({
name: `Auto: Massnahme ${id}`,
description: `Automatisch vorgeschlagene Massnahme aus Pattern-Matching`,
reduction_type: 'design',
}))
const acceptedEvidence = acceptedEvidenceIds.map(id => ({
title: `Auto: Nachweis ${id}`,
description: `Automatisch vorgeschlagener Nachweis aus Pattern-Matching`,
method: 'test_report',
}))
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/apply-patterns`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
accepted_hazards: acceptedHazards,
accepted_measures: acceptedMeasures,
accepted_evidence: acceptedEvidence,
source_pattern_ids: patternIds,
}),
})
if (res.ok) {
setMatchResult(null)
await fetchHazards()
}
} catch (err) {
console.error('Failed to apply patterns:', err)
} finally {
setApplyingPatterns(false)
}
}
async function handleDelete(id: string) {
if (!confirm('Gefaehrdung wirklich loeschen?')) return
try {
@@ -729,6 +1085,20 @@ export default function HazardsPage() {
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={handlePatternMatching}
disabled={matchingPatterns}
className="flex items-center gap-2 px-3 py-2 border border-green-300 text-green-700 rounded-lg hover:bg-green-50 transition-colors disabled:opacity-50 text-sm"
>
{matchingPatterns ? (
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-green-600" />
) : (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
</svg>
)}
Auto-Erkennung
</button>
<button
onClick={handleAISuggestions}
disabled={suggestingAI}
@@ -764,6 +1134,35 @@ export default function HazardsPage() {
</div>
</div>
{/* Pattern Match Results (Phase 5) */}
{matchResult && matchResult.matched_patterns.length > 0 && (
<AutoSuggestPanel
projectId={projectId}
matchResult={matchResult}
applying={applyingPatterns}
onApply={handleApplyPatterns}
onClose={() => setMatchResult(null)}
/>
)}
{/* No patterns matched info */}
{matchResult && matchResult.matched_patterns.length === 0 && (
<div className="bg-yellow-50 border border-yellow-200 rounded-xl p-4 flex items-start gap-3">
<svg className="w-5 h-5 text-yellow-600 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div>
<p className="text-sm text-yellow-800">
Keine Patterns erkannt. Fuegen Sie zuerst Komponenten aus der Bibliothek hinzu,
damit die automatische Erkennung Gefaehrdungen ableiten kann.
</p>
<button onClick={() => setMatchResult(null)} className="text-xs text-yellow-600 hover:text-yellow-700 mt-1">
Schliessen
</button>
</div>
</div>
)}
{/* Stats */}
{hazards.length > 0 && (
<div className="grid grid-cols-2 md:grid-cols-7 gap-3">
@@ -843,7 +1242,14 @@ export default function HazardsPage() {
.map((hazard) => (
<tr key={hazard.id} className="hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors">
<td className="px-4 py-3">
<div className="text-sm font-medium text-gray-900 dark:text-white">{hazard.name}</div>
<div className="flex items-center gap-2">
<div className="text-sm font-medium text-gray-900 dark:text-white">{hazard.name}</div>
{hazard.name.startsWith('Auto:') && (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-green-100 text-green-700">
Auto
</span>
)}
</div>
{hazard.description && (
<div className="text-xs text-gray-500 truncate max-w-[250px]">{hazard.description}</div>
)}
@@ -890,10 +1296,17 @@ export default function HazardsPage() {
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Kein Hazard Log vorhanden</h3>
<p className="mt-2 text-gray-500 max-w-md mx-auto">
Beginnen Sie mit der systematischen Erfassung von Gefaehrdungen. Nutzen Sie die Bibliothek
oder KI-Vorschlaege als Ausgangspunkt.
Beginnen Sie mit der systematischen Erfassung von Gefaehrdungen. Nutzen Sie die Bibliothek,
KI-Vorschlaege oder die automatische Erkennung als Ausgangspunkt.
</p>
<div className="mt-6 flex items-center justify-center gap-3">
<button
onClick={handlePatternMatching}
disabled={matchingPatterns}
className="px-6 py-3 border border-green-300 text-green-700 rounded-lg hover:bg-green-50 transition-colors disabled:opacity-50"
>
{matchingPatterns ? 'Erkennung laeuft...' : 'Auto-Erkennung starten'}
</button>
<button
onClick={() => setShowForm(true)}
className="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"

View File

@@ -14,6 +14,7 @@ interface Mitigation {
created_at: string
verified_at: string | null
verified_by: string | null
source?: string
}
interface Hazard {
@@ -33,6 +34,17 @@ interface ProtectiveMeasure {
examples: string[]
}
interface SuggestedMeasure {
id: string
reduction_type: string
sub_type: string
name: string
description: string
hazard_category: string
examples: string[]
tags?: string[]
}
const REDUCTION_TYPES = {
design: {
label: 'Stufe 1: Design',
@@ -240,6 +252,155 @@ function MeasuresLibraryModal({
)
}
// ============================================================================
// Suggest Measures Modal (Phase 5)
// ============================================================================
function SuggestMeasuresModal({
hazards,
projectId,
onAddMeasure,
onClose,
}: {
hazards: Hazard[]
projectId: string
onAddMeasure: (title: string, description: string, reductionType: string, hazardId: string) => void
onClose: () => void
}) {
const [selectedHazard, setSelectedHazard] = useState<string>('')
const [suggested, setSuggested] = useState<SuggestedMeasure[]>([])
const [loadingSuggestions, setLoadingSuggestions] = useState(false)
const riskColors: Record<string, string> = {
not_acceptable: 'border-red-400 bg-red-50',
very_high: 'border-red-300 bg-red-50',
critical: 'border-red-300 bg-red-50',
high: 'border-orange-300 bg-orange-50',
medium: 'border-yellow-300 bg-yellow-50',
low: 'border-green-300 bg-green-50',
}
async function handleSelectHazard(hazardId: string) {
setSelectedHazard(hazardId)
setSuggested([])
if (!hazardId) return
setLoadingSuggestions(true)
try {
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/hazards/${hazardId}/suggest-measures`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
})
if (res.ok) {
const json = await res.json()
setSuggested(json.suggested_measures || [])
}
} catch (err) {
console.error('Failed to suggest measures:', err)
} finally {
setLoadingSuggestions(false)
}
}
const groupedByType = {
design: suggested.filter(m => m.reduction_type === 'design'),
protection: suggested.filter(m => m.reduction_type === 'protection'),
information: suggested.filter(m => m.reduction_type === 'information'),
}
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-xl w-full max-w-3xl max-h-[85vh] flex flex-col">
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Massnahmen-Vorschlaege</h3>
<button onClick={onClose} className="p-1 text-gray-400 hover:text-gray-600 rounded">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<p className="text-sm text-gray-500 mb-3">
Waehlen Sie eine Gefaehrdung, um passende Massnahmen vorgeschlagen zu bekommen.
</p>
<div className="flex flex-wrap gap-2">
{hazards.map(h => (
<button
key={h.id}
onClick={() => handleSelectHazard(h.id)}
className={`px-3 py-1.5 text-xs rounded-lg border transition-colors ${
selectedHazard === h.id
? 'border-purple-400 bg-purple-50 text-purple-700 font-medium'
: `${riskColors[h.risk_level] || 'border-gray-200 bg-white'} text-gray-700 hover:border-purple-300`
}`}
>
{h.name}
</button>
))}
</div>
</div>
<div className="flex-1 overflow-auto p-6">
{loadingSuggestions ? (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
</div>
) : suggested.length > 0 ? (
<div className="space-y-6">
{(['design', 'protection', 'information'] as const).map(type => {
const items = groupedByType[type]
if (items.length === 0) return null
const config = REDUCTION_TYPES[type]
return (
<div key={type}>
<div className={`flex items-center gap-2 px-3 py-2 rounded-lg ${config.headerColor} mb-3`}>
{config.icon}
<span className="text-sm font-semibold">{config.label}</span>
<span className="ml-auto text-sm font-bold">{items.length}</span>
</div>
<div className="space-y-2">
{items.map(m => (
<div key={m.id} className="border border-gray-200 rounded-lg p-3 hover:bg-gray-50 transition-colors">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-mono text-gray-400">{m.id}</span>
{m.sub_type && (
<span className="text-xs px-1.5 py-0.5 rounded bg-gray-100 text-gray-600">{m.sub_type}</span>
)}
</div>
<div className="text-sm font-medium text-gray-900 dark:text-white">{m.name}</div>
<div className="text-xs text-gray-500 mt-0.5">{m.description}</div>
</div>
<button
onClick={() => onAddMeasure(m.name, m.description, m.reduction_type, selectedHazard)}
className="ml-3 px-3 py-1.5 text-xs bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors flex-shrink-0"
>
Uebernehmen
</button>
</div>
</div>
))}
</div>
</div>
)
})}
</div>
) : selectedHazard ? (
<div className="text-center py-12 text-gray-500">
Keine Vorschlaege fuer diese Gefaehrdung gefunden.
</div>
) : (
<div className="text-center py-12 text-gray-500">
Waehlen Sie eine Gefaehrdung aus, um Vorschlaege zu erhalten.
</div>
)}
</div>
</div>
</div>
)
}
interface MitigationFormData {
title: string
description: string
@@ -375,7 +536,14 @@ function MitigationCard({
return (
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<div className="flex items-start justify-between mb-2">
<h4 className="text-sm font-medium text-gray-900 dark:text-white">{mitigation.title}</h4>
<div className="flex items-center gap-2">
<h4 className="text-sm font-medium text-gray-900 dark:text-white">{mitigation.title}</h4>
{mitigation.title.startsWith('Auto:') && (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-green-100 text-green-700">
Auto
</span>
)}
</div>
<StatusBadge status={mitigation.status} />
</div>
{mitigation.description && (
@@ -424,6 +592,8 @@ export default function MitigationsPage() {
const [showLibrary, setShowLibrary] = useState(false)
const [libraryFilter, setLibraryFilter] = useState<string | undefined>()
const [measures, setMeasures] = useState<ProtectiveMeasure[]>([])
// Phase 5: Suggest measures
const [showSuggest, setShowSuggest] = useState(false)
useEffect(() => {
fetchData()
@@ -500,7 +670,6 @@ export default function MitigationsPage() {
setShowLibrary(false)
setShowForm(true)
setPreselectedType(measure.reduction_type as 'design' | 'protection' | 'information')
// The form will be pre-filled with the type; user can edit title/description
}
async function handleSubmit(data: MitigationFormData) {
@@ -520,6 +689,26 @@ export default function MitigationsPage() {
}
}
async function handleAddSuggestedMeasure(title: string, description: string, reductionType: string, hazardId: string) {
try {
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title,
description,
reduction_type: reductionType,
linked_hazard_ids: [hazardId],
}),
})
if (res.ok) {
await fetchData()
}
} catch (err) {
console.error('Failed to add suggested measure:', err)
}
}
async function handleVerify(id: string) {
try {
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations/${id}/verify`, {
@@ -576,6 +765,17 @@ export default function MitigationsPage() {
</p>
</div>
<div className="flex items-center gap-3">
{hazards.length > 0 && (
<button
onClick={() => setShowSuggest(true)}
className="flex items-center gap-2 px-3 py-2 border border-green-300 text-green-700 rounded-lg hover:bg-green-50 transition-colors text-sm"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
</svg>
Vorschlaege
</button>
)}
<button
onClick={() => handleOpenLibrary()}
className="flex items-center gap-2 px-4 py-2 bg-white border border-purple-300 text-purple-700 rounded-lg hover:bg-purple-50 transition-colors"
@@ -629,6 +829,16 @@ export default function MitigationsPage() {
/>
)}
{/* Suggest Measures Modal (Phase 5) */}
{showSuggest && (
<SuggestMeasuresModal
hazards={hazards}
projectId={projectId}
onAddMeasure={handleAddSuggestedMeasure}
onClose={() => setShowSuggest(false)}
/>
)}
{/* 3-Column Layout */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{(['design', 'protection', 'information'] as const).map((type) => {

View File

@@ -19,6 +19,14 @@ interface VerificationItem {
created_at: string
}
interface SuggestedEvidence {
id: string
name: string
description: string
method: string
tags?: string[]
}
const VERIFICATION_METHODS = [
{ value: 'design_review', label: 'Design-Review', description: 'Systematische Pruefung der Konstruktionsunterlagen' },
{ value: 'calculation', label: 'Berechnung', description: 'Rechnerischer Nachweis (FEM, Festigkeit, Thermik)' },
@@ -241,6 +249,130 @@ function CompleteModal({
)
}
// ============================================================================
// Suggest Evidence Modal (Phase 5)
// ============================================================================
function SuggestEvidenceModal({
mitigations,
projectId,
onAddEvidence,
onClose,
}: {
mitigations: { id: string; title: string }[]
projectId: string
onAddEvidence: (title: string, description: string, method: string, mitigationId: string) => void
onClose: () => void
}) {
const [selectedMitigation, setSelectedMitigation] = useState<string>('')
const [suggested, setSuggested] = useState<SuggestedEvidence[]>([])
const [loadingSuggestions, setLoadingSuggestions] = useState(false)
async function handleSelectMitigation(mitigationId: string) {
setSelectedMitigation(mitigationId)
setSuggested([])
if (!mitigationId) return
setLoadingSuggestions(true)
try {
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations/${mitigationId}/suggest-evidence`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
})
if (res.ok) {
const json = await res.json()
setSuggested(json.suggested_evidence || [])
}
} catch (err) {
console.error('Failed to suggest evidence:', err)
} finally {
setLoadingSuggestions(false)
}
}
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-xl w-full max-w-3xl max-h-[85vh] flex flex-col">
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Nachweise vorschlagen</h3>
<button onClick={onClose} className="p-1 text-gray-400 hover:text-gray-600 rounded">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<p className="text-sm text-gray-500 mb-3">
Waehlen Sie eine Massnahme, um passende Nachweismethoden vorgeschlagen zu bekommen.
</p>
<div className="flex flex-wrap gap-2">
{mitigations.map(m => (
<button
key={m.id}
onClick={() => handleSelectMitigation(m.id)}
className={`px-3 py-1.5 text-xs rounded-lg border transition-colors ${
selectedMitigation === m.id
? 'border-purple-400 bg-purple-50 text-purple-700 font-medium'
: 'border-gray-200 bg-white text-gray-700 hover:border-purple-300'
}`}
>
{m.title}
</button>
))}
</div>
</div>
<div className="flex-1 overflow-auto p-6">
{loadingSuggestions ? (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
</div>
) : suggested.length > 0 ? (
<div className="space-y-3">
{suggested.map(ev => (
<div key={ev.id} className="border border-gray-200 rounded-lg p-4 hover:bg-gray-50 transition-colors">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-mono text-gray-400">{ev.id}</span>
{ev.method && (
<span className="text-xs px-1.5 py-0.5 rounded bg-blue-50 text-blue-600">
{VERIFICATION_METHODS.find(m => m.value === ev.method)?.label || ev.method}
</span>
)}
</div>
<div className="text-sm font-medium text-gray-900 dark:text-white">{ev.name}</div>
<div className="text-xs text-gray-500 mt-0.5">{ev.description}</div>
</div>
<button
onClick={() => onAddEvidence(ev.name, ev.description, ev.method || 'test_report', selectedMitigation)}
className="ml-3 px-3 py-1.5 text-xs bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors flex-shrink-0"
>
Uebernehmen
</button>
</div>
</div>
))}
</div>
) : selectedMitigation ? (
<div className="text-center py-12 text-gray-500">
Keine Vorschlaege fuer diese Massnahme gefunden.
</div>
) : (
<div className="text-center py-12 text-gray-500">
Waehlen Sie eine Massnahme aus, um Nachweise vorgeschlagen zu bekommen.
</div>
)}
</div>
</div>
</div>
)
}
// ============================================================================
// Main Page
// ============================================================================
export default function VerificationPage() {
const params = useParams()
const projectId = params.projectId as string
@@ -250,6 +382,8 @@ export default function VerificationPage() {
const [loading, setLoading] = useState(true)
const [showForm, setShowForm] = useState(false)
const [completingItem, setCompletingItem] = useState<VerificationItem | null>(null)
// Phase 5: Suggest evidence
const [showSuggest, setShowSuggest] = useState(false)
useEffect(() => {
fetchData()
@@ -297,6 +431,26 @@ export default function VerificationPage() {
}
}
async function handleAddSuggestedEvidence(title: string, description: string, method: string, mitigationId: string) {
try {
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/verifications`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title,
description,
method,
linked_mitigation_id: mitigationId,
}),
})
if (res.ok) {
await fetchData()
}
} catch (err) {
console.error('Failed to add suggested evidence:', err)
}
}
async function handleComplete(id: string, result: string, passed: boolean) {
try {
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/verifications/${id}/complete`, {
@@ -347,15 +501,28 @@ export default function VerificationPage() {
Nachweisfuehrung fuer alle Schutzmassnahmen und Sicherheitsanforderungen.
</p>
</div>
<button
onClick={() => setShowForm(true)}
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Verifikation hinzufuegen
</button>
<div className="flex items-center gap-2">
{mitigations.length > 0 && (
<button
onClick={() => setShowSuggest(true)}
className="flex items-center gap-2 px-3 py-2 border border-green-300 text-green-700 rounded-lg hover:bg-green-50 transition-colors text-sm"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
</svg>
Nachweise vorschlagen
</button>
)}
<button
onClick={() => setShowForm(true)}
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Verifikation hinzufuegen
</button>
</div>
</div>
{/* Stats */}
@@ -399,6 +566,16 @@ export default function VerificationPage() {
/>
)}
{/* Suggest Evidence Modal (Phase 5) */}
{showSuggest && (
<SuggestEvidenceModal
mitigations={mitigations}
projectId={projectId}
onAddEvidence={handleAddSuggestedEvidence}
onClose={() => setShowSuggest(false)}
/>
)}
{/* Table */}
{items.length > 0 ? (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
@@ -472,12 +649,22 @@ export default function VerificationPage() {
Definieren Sie Verifikationsschritte fuer Ihre Schutzmassnahmen.
Jede Massnahme sollte durch mindestens eine Verifikation abgedeckt sein.
</p>
<button
onClick={() => setShowForm(true)}
className="mt-6 px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
>
Erste Verifikation anlegen
</button>
<div className="mt-6 flex items-center justify-center gap-3">
{mitigations.length > 0 && (
<button
onClick={() => setShowSuggest(true)}
className="px-6 py-3 border border-green-300 text-green-700 rounded-lg hover:bg-green-50 transition-colors"
>
Nachweise vorschlagen
</button>
)}
<button
onClick={() => setShowForm(true)}
className="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
>
Erste Verifikation anlegen
</button>
</div>
</div>
)
)}

View File

@@ -605,6 +605,10 @@ func main() {
// Audit Trail
iaceRoutes.GET("/projects/:id/audit-trail", iaceHandler.GetAuditTrail)
// RAG Library Search (Phase 6)
iaceRoutes.POST("/library-search", iaceHandler.SearchLibrary)
iaceRoutes.POST("/projects/:id/tech-file/:section/enrich", iaceHandler.EnrichTechFileSection)
}
}

View File

@@ -8,6 +8,7 @@ import (
"github.com/breakpilot/ai-compliance-sdk/internal/iace"
"github.com/breakpilot/ai-compliance-sdk/internal/rbac"
"github.com/breakpilot/ai-compliance-sdk/internal/ucca"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
@@ -25,6 +26,7 @@ type IACEHandler struct {
engine *iace.RiskEngine
classifier *iace.Classifier
checker *iace.CompletenessChecker
ragClient *ucca.LegalRAGClient
}
// NewIACEHandler creates a new IACEHandler with all required dependencies.
@@ -34,6 +36,7 @@ func NewIACEHandler(store *iace.Store) *IACEHandler {
engine: iace.NewRiskEngine(),
classifier: iace.NewClassifier(),
checker: iace.NewCompletenessChecker(),
ragClient: ucca.NewLegalRAGClient(),
}
}
@@ -2325,6 +2328,138 @@ func (h *IACEHandler) SuggestEvidenceForMitigation(c *gin.Context) {
})
}
// ============================================================================
// RAG Library Search (Phase 6)
// ============================================================================
// IACELibrarySearchRequest represents a semantic search against the IACE library corpus.
type IACELibrarySearchRequest struct {
Query string `json:"query" binding:"required"`
Category string `json:"category,omitempty"`
TopK int `json:"top_k,omitempty"`
Filters []string `json:"filters,omitempty"`
}
// SearchLibrary handles POST /iace/library-search
// Performs semantic search across the IACE hazard/component/measure library in Qdrant.
func (h *IACEHandler) SearchLibrary(c *gin.Context) {
var req IACELibrarySearchRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
topK := req.TopK
if topK <= 0 || topK > 50 {
topK = 10
}
// Use regulation filter for category-based search within the IACE collection
var filters []string
if req.Category != "" {
filters = append(filters, req.Category)
}
filters = append(filters, req.Filters...)
results, err := h.ragClient.SearchCollection(
c.Request.Context(),
"bp_iace_libraries",
req.Query,
filters,
topK,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "RAG search failed",
"details": err.Error(),
})
return
}
if results == nil {
results = []ucca.LegalSearchResult{}
}
c.JSON(http.StatusOK, gin.H{
"query": req.Query,
"results": results,
"total": len(results),
})
}
// EnrichTechFileSection handles POST /projects/:id/tech-file/:section/enrich
// Uses RAG to find relevant library content for a specific tech file section.
func (h *IACEHandler) EnrichTechFileSection(c *gin.Context) {
projectID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"})
return
}
sectionType := c.Param("section")
if sectionType == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "section type required"})
return
}
project, err := h.store.GetProject(c.Request.Context(), projectID)
if err != nil || project == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "project not found"})
return
}
// Build a contextual query based on section type and project data
queryParts := []string{project.MachineName, project.MachineType}
switch sectionType {
case "risk_assessment_report", "hazard_log_combined":
queryParts = append(queryParts, "Gefaehrdungen", "Risikobewertung", "ISO 12100")
case "essential_requirements":
queryParts = append(queryParts, "Sicherheitsanforderungen", "Maschinenrichtlinie")
case "design_specifications":
queryParts = append(queryParts, "Konstruktionsspezifikation", "Sicherheitskonzept")
case "test_reports":
queryParts = append(queryParts, "Pruefbericht", "Verifikation", "Nachweis")
case "standards_applied":
queryParts = append(queryParts, "harmonisierte Normen", "EN ISO")
case "ai_risk_management":
queryParts = append(queryParts, "KI-Risikomanagement", "AI Act", "Algorithmen")
case "ai_human_oversight":
queryParts = append(queryParts, "menschliche Aufsicht", "Human Oversight", "KI-Transparenz")
default:
queryParts = append(queryParts, sectionType)
}
query := strings.Join(queryParts, " ")
results, err := h.ragClient.SearchCollection(
c.Request.Context(),
"bp_iace_libraries",
query,
nil,
5,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "RAG enrichment failed",
"details": err.Error(),
})
return
}
if results == nil {
results = []ucca.LegalSearchResult{}
}
c.JSON(http.StatusOK, gin.H{
"project_id": projectID.String(),
"section_type": sectionType,
"query": query,
"context": results,
"total": len(results),
})
}
// mustMarshalJSON marshals the given value to json.RawMessage.
func mustMarshalJSON(v interface{}) json.RawMessage {
data, err := json.Marshal(v)

View File

@@ -33,6 +33,7 @@ var AllowedCollections = map[string]bool{
"bp_dsfa_templates": true,
"bp_dsfa_risks": true,
"bp_legal_templates": true,
"bp_iace_libraries": true,
}
// SearchRequest represents a RAG search request.

View File

@@ -0,0 +1,95 @@
package iace
import "testing"
// TestControlsLibrary_UniqueIDs verifies all control IDs are unique.
func TestControlsLibrary_UniqueIDs(t *testing.T) {
seen := make(map[string]bool)
for _, e := range GetControlsLibrary() {
if e.ID == "" {
t.Errorf("control has empty ID")
continue
}
if seen[e.ID] {
t.Errorf("duplicate control ID: %s", e.ID)
}
seen[e.ID] = true
}
}
// TestProtectiveMeasures_HasExamples verifies measures have examples.
func TestProtectiveMeasures_HasExamples(t *testing.T) {
withExamples := 0
for _, e := range GetProtectiveMeasureLibrary() {
if len(e.Examples) > 0 {
withExamples++
}
}
total := len(GetProtectiveMeasureLibrary())
threshold := total * 80 / 100
if withExamples < threshold {
t.Errorf("only %d/%d measures have examples, want at least %d", withExamples, total, threshold)
}
}
// TestProtectiveMeasures_ThreeReductionTypesPresent verifies all 3 types exist.
func TestProtectiveMeasures_ThreeReductionTypesPresent(t *testing.T) {
types := make(map[string]int)
for _, e := range GetProtectiveMeasureLibrary() {
types[e.ReductionType]++
}
// Accept both naming variants
designCount := types["design"]
protectiveCount := types["protective"] + types["protection"]
infoCount := types["information"]
if designCount == 0 {
t.Error("no measures with reduction type design")
}
if protectiveCount == 0 {
t.Error("no measures with reduction type protective/protection")
}
if infoCount == 0 {
t.Error("no measures with reduction type information")
}
}
// TestProtectiveMeasures_TagFieldAccessible verifies the Tags field is accessible.
func TestProtectiveMeasures_TagFieldAccessible(t *testing.T) {
measures := GetProtectiveMeasureLibrary()
if len(measures) == 0 {
t.Fatal("no measures returned")
}
// Tags field exists but may not be populated yet
_ = measures[0].Tags
}
// TestProtectiveMeasures_HazardCategoryNotEmpty verifies HazardCategory is populated.
func TestProtectiveMeasures_HazardCategoryNotEmpty(t *testing.T) {
for _, e := range GetProtectiveMeasureLibrary() {
if e.HazardCategory == "" {
t.Errorf("measure %s (%s): HazardCategory is empty", e.ID, e.Name)
}
}
}
// TestProtectiveMeasures_Count160 verifies at least 160 measures exist.
func TestProtectiveMeasures_Count160(t *testing.T) {
entries := GetProtectiveMeasureLibrary()
if len(entries) < 160 {
t.Fatalf("got %d protective measures, want at least 160", len(entries))
}
}
// TestProtectiveMeasures_SubTypesPresent verifies subtypes are used.
func TestProtectiveMeasures_SubTypesPresent(t *testing.T) {
subtypes := make(map[string]int)
for _, e := range GetProtectiveMeasureLibrary() {
if e.SubType != "" {
subtypes[e.SubType]++
}
}
if len(subtypes) < 3 {
t.Errorf("expected at least 3 different subtypes, got %d: %v", len(subtypes), subtypes)
}
}

View File

@@ -0,0 +1,257 @@
package iace
import (
"testing"
)
// TestIntegration_FullMatchFlow tests the complete pattern matching flow:
// components → tags → patterns → hazards/measures/evidence
func TestIntegration_FullMatchFlow(t *testing.T) {
engine := NewPatternEngine()
// Simulate a robot arm with electrical components and kinetic energy
input := MatchInput{
ComponentLibraryIDs: []string{"C001", "C061", "C071"}, // Roboterarm, Schaltschrank, SPS
EnergySourceIDs: []string{"EN01", "EN04"}, // Kinetic, Electrical
LifecyclePhases: []string{},
CustomTags: []string{},
}
output := engine.Match(input)
// Should have matched patterns
if len(output.MatchedPatterns) == 0 {
t.Fatal("expected matched patterns for robot arm + electrical + SPS setup, got none")
}
// Should have suggested hazards
if len(output.SuggestedHazards) == 0 {
t.Fatal("expected suggested hazards, got none")
}
// Should have suggested measures
if len(output.SuggestedMeasures) == 0 {
t.Fatal("expected suggested measures, got none")
}
// Should have suggested evidence
if len(output.SuggestedEvidence) == 0 {
t.Fatal("expected suggested evidence, got none")
}
// Should have resolved tags
if len(output.ResolvedTags) == 0 {
t.Fatal("expected resolved tags, got none")
}
// Verify mechanical hazards are present (robot arm has moving_part, rotating_part)
hasMechanical := false
for _, h := range output.SuggestedHazards {
if h.Category == "mechanical" || h.Category == "mechanical_hazard" {
hasMechanical = true
break
}
}
if !hasMechanical {
cats := make(map[string]bool)
for _, h := range output.SuggestedHazards {
cats[h.Category] = true
}
t.Errorf("expected mechanical hazards for robot arm, got categories: %v", cats)
}
// Verify electrical hazards are present (Schaltschrank has high_voltage)
hasElectrical := false
for _, h := range output.SuggestedHazards {
if h.Category == "electrical" || h.Category == "electrical_hazard" {
hasElectrical = true
break
}
}
if !hasElectrical {
cats := make(map[string]bool)
for _, h := range output.SuggestedHazards {
cats[h.Category] = true
}
t.Errorf("expected electrical hazards for Schaltschrank, got categories: %v", cats)
}
}
// TestIntegration_TagResolverToPatternEngine verifies the tag resolver output
// feeds correctly into the pattern engine.
func TestIntegration_TagResolverToPatternEngine(t *testing.T) {
resolver := NewTagResolver()
engine := NewPatternEngine()
// Resolve tags for a hydraulic setup
componentTags := resolver.ResolveComponentTags([]string{"C041"}) // Hydraulikpumpe
energyTags := resolver.ResolveEnergyTags([]string{"EN05"}) // Hydraulische Energie
allTags := resolver.ResolveTags([]string{"C041"}, []string{"EN05"}, nil)
// All tags should be non-empty
if len(componentTags) == 0 {
t.Error("expected component tags for C041")
}
if len(energyTags) == 0 {
t.Error("expected energy tags for EN05")
}
// Merged tags should include both
tagSet := toSet(allTags)
if !tagSet["hydraulic_part"] {
t.Error("expected 'hydraulic_part' in merged tags")
}
if !tagSet["hydraulic_pressure"] {
t.Error("expected 'hydraulic_pressure' in merged tags")
}
// Feed into pattern engine
output := engine.Match(MatchInput{
ComponentLibraryIDs: []string{"C041"},
EnergySourceIDs: []string{"EN05"},
})
if len(output.MatchedPatterns) == 0 {
t.Error("expected patterns to match for hydraulic setup")
}
}
// TestIntegration_AllComponentCategoriesProduceMatches verifies that every
// component category, when paired with its typical energy source, produces
// at least one pattern match.
func TestIntegration_AllComponentCategoriesProduceMatches(t *testing.T) {
engine := NewPatternEngine()
tests := []struct {
name string
componentIDs []string
energyIDs []string
}{
{"mechanical", []string{"C001"}, []string{"EN01"}}, // Roboterarm + Kinetic
{"drive", []string{"C031"}, []string{"EN02"}}, // Elektromotor + Rotational
{"hydraulic", []string{"C041"}, []string{"EN05"}}, // Hydraulikpumpe + Hydraulic
{"pneumatic", []string{"C051"}, []string{"EN06"}}, // Pneumatikzylinder + Pneumatic
{"electrical", []string{"C061"}, []string{"EN04"}}, // Schaltschrank + Electrical
{"control", []string{"C071"}, []string{"EN04"}}, // SPS + Electrical
{"safety", []string{"C101"}, []string{"EN04"}}, // Not-Halt + Electrical
{"it_network", []string{"C111"}, []string{"EN04", "EN19"}}, // Switch + Electrical + Data
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
output := engine.Match(MatchInput{
ComponentLibraryIDs: tt.componentIDs,
EnergySourceIDs: tt.energyIDs,
})
if len(output.MatchedPatterns) == 0 {
t.Errorf("category %s: expected at least one pattern match, got none (resolved tags: %v)",
tt.name, output.ResolvedTags)
}
})
}
}
// TestIntegration_PatternsSuggestHazardCategories verifies that pattern-suggested
// hazard categories cover the main safety domains.
func TestIntegration_PatternsSuggestHazardCategories(t *testing.T) {
engine := NewPatternEngine()
// Full industrial setup: robot arm + electrical panel + PLC + network
output := engine.Match(MatchInput{
ComponentLibraryIDs: []string{"C001", "C061", "C071", "C111"},
EnergySourceIDs: []string{"EN01", "EN04"},
})
categories := make(map[string]bool)
for _, h := range output.SuggestedHazards {
categories[h.Category] = true
}
// Should cover mechanical and electrical hazards (naming may use _hazard suffix)
hasMech := categories["mechanical"] || categories["mechanical_hazard"]
hasElec := categories["electrical"] || categories["electrical_hazard"]
if !hasMech {
t.Errorf("expected mechanical hazard category in suggestions, got: %v", categories)
}
if !hasElec {
t.Errorf("expected electrical hazard category in suggestions, got: %v", categories)
}
}
// TestIntegration_EvidenceSuggestionsPerReductionType tests that evidence
// can be found for each reduction type.
func TestIntegration_EvidenceSuggestionsPerReductionType(t *testing.T) {
resolver := NewTagResolver()
tests := []struct {
reductionType string
evidenceTags []string
}{
{"design", []string{"design_evidence", "analysis_evidence"}},
{"protective", []string{"test_evidence", "inspection_evidence"}},
{"information", []string{"training_evidence", "operational_evidence"}},
}
for _, tt := range tests {
t.Run(tt.reductionType, func(t *testing.T) {
evidence := resolver.FindEvidenceByTags(tt.evidenceTags)
if len(evidence) == 0 {
t.Errorf("no evidence found for %s reduction type (tags: %v)", tt.reductionType, tt.evidenceTags)
}
})
}
}
// TestIntegration_LibraryConsistency verifies components and energy sources have tags.
func TestIntegration_LibraryConsistency(t *testing.T) {
components := GetComponentLibrary()
energySources := GetEnergySources()
taxonomy := GetTagTaxonomy()
// Taxonomy should be populated
if len(taxonomy) == 0 {
t.Fatal("tag taxonomy is empty")
}
// All components should have at least one tag
for _, comp := range components {
if len(comp.Tags) == 0 {
t.Errorf("component %s has no tags", comp.ID)
}
}
// All energy sources should have at least one tag
for _, es := range energySources {
if len(es.Tags) == 0 {
t.Errorf("energy source %s has no tags", es.ID)
}
}
// Component tags should mostly exist in taxonomy (allow some flexibility)
taxonomyIDs := toSet(func() []string {
ids := make([]string, len(taxonomy))
for i, tag := range taxonomy {
ids[i] = tag.ID
}
return ids
}())
missingCount := 0
totalTags := 0
for _, comp := range components {
for _, tag := range comp.Tags {
totalTags++
if !taxonomyIDs[tag] {
missingCount++
}
}
}
// At least 90% of component tags should be in taxonomy
if totalTags > 0 {
coverage := float64(totalTags-missingCount) / float64(totalTags) * 100
if coverage < 90 {
t.Errorf("only %.0f%% of component tags exist in taxonomy (%d/%d)", coverage, totalTags-missingCount, totalTags)
}
}
}

View File

@@ -1,6 +1,9 @@
package iace
import "testing"
import (
"fmt"
"testing"
)
func TestTagResolver_ResolveComponentTags_Roboterarm(t *testing.T) {
tr := NewTagResolver()
@@ -90,3 +93,78 @@ func TestTagResolver_ResolveComponentTags_Empty(t *testing.T) {
t.Errorf("expected no tags for nil input, got %v", tags)
}
}
func TestTagResolver_FindHazardsByTags_Empty(t *testing.T) {
tr := NewTagResolver()
hazards := tr.FindHazardsByTags(nil)
if len(hazards) != 0 {
t.Errorf("expected no hazards for nil tags, got %d", len(hazards))
}
}
func TestTagResolver_FindHazardsByTags_NonexistentTag(t *testing.T) {
tr := NewTagResolver()
hazards := tr.FindHazardsByTags([]string{"nonexistent_tag_xyz"})
if len(hazards) != 0 {
t.Errorf("expected no hazards for nonexistent tag, got %d", len(hazards))
}
}
func TestTagResolver_FindMeasuresByTags_Empty(t *testing.T) {
tr := NewTagResolver()
measures := tr.FindMeasuresByTags(nil)
if len(measures) != 0 {
t.Errorf("expected no measures for nil tags, got %d", len(measures))
}
}
func TestTagResolver_FindEvidenceByTags_DesignEvidence(t *testing.T) {
tr := NewTagResolver()
evidence := tr.FindEvidenceByTags([]string{"design_evidence"})
if len(evidence) == 0 {
t.Fatal("expected evidence for 'design_evidence' tag, got none")
}
}
func TestTagResolver_FindEvidenceByTags_Empty(t *testing.T) {
tr := NewTagResolver()
evidence := tr.FindEvidenceByTags(nil)
if len(evidence) != 0 {
t.Errorf("expected no evidence for nil tags, got %d", len(evidence))
}
}
func TestTagResolver_ResolveEnergyTags_AllSources(t *testing.T) {
tr := NewTagResolver()
// Test all 20 energy sources
allIDs := make([]string, 20)
for i := 0; i < 20; i++ {
allIDs[i] = fmt.Sprintf("EN%02d", i+1)
}
tags := tr.ResolveEnergyTags(allIDs)
if len(tags) < 10 {
t.Errorf("expected at least 10 unique tags for all 20 energy sources, got %d", len(tags))
}
}
func TestTagResolver_ResolveComponentTags_AllCategories(t *testing.T) {
tr := NewTagResolver()
// Test one component from each category
sampleIDs := []string{
"C001", // mechanical
"C021", // structural
"C031", // drive
"C041", // hydraulic
"C051", // pneumatic
"C061", // electrical
"C071", // control
"C081", // sensor
"C091", // actuator
"C101", // safety
"C111", // it_network
}
tags := tr.ResolveComponentTags(sampleIDs)
if len(tags) < 15 {
t.Errorf("expected at least 15 unique tags for 11 category samples, got %d", len(tags))
}
}

View File

@@ -0,0 +1,117 @@
package iace
import "testing"
// TestGetTagTaxonomy_EntryCount verifies the taxonomy has entries.
func TestGetTagTaxonomy_EntryCount(t *testing.T) {
tags := GetTagTaxonomy()
if len(tags) < 80 {
t.Fatalf("GetTagTaxonomy returned %d entries, want at least 80", len(tags))
}
}
// TestGetTagTaxonomy_UniqueIDs verifies all tag IDs are unique.
func TestGetTagTaxonomy_UniqueIDs(t *testing.T) {
tags := GetTagTaxonomy()
seen := make(map[string]bool)
for _, tag := range tags {
if tag.ID == "" {
t.Error("tag with empty ID found")
continue
}
if seen[tag.ID] {
t.Errorf("duplicate tag ID: %s", tag.ID)
}
seen[tag.ID] = true
}
}
// TestGetTagTaxonomy_ValidDomains verifies all tags have valid domains.
func TestGetTagTaxonomy_ValidDomains(t *testing.T) {
validDomains := make(map[string]bool)
for _, d := range ValidTagDomains() {
validDomains[d] = true
}
for _, tag := range GetTagTaxonomy() {
if !validDomains[tag.Domain] {
t.Errorf("tag %s has invalid domain %q", tag.ID, tag.Domain)
}
}
}
// TestGetTagTaxonomy_NonEmptyFields verifies required fields are filled.
func TestGetTagTaxonomy_NonEmptyFields(t *testing.T) {
for _, tag := range GetTagTaxonomy() {
if tag.DescriptionDE == "" {
t.Errorf("tag %s: DescriptionDE is empty", tag.ID)
}
if tag.DescriptionEN == "" {
t.Errorf("tag %s: DescriptionEN is empty", tag.ID)
}
}
}
// TestGetTagTaxonomy_DomainDistribution verifies each domain has entries.
func TestGetTagTaxonomy_DomainDistribution(t *testing.T) {
counts := make(map[string]int)
for _, tag := range GetTagTaxonomy() {
counts[tag.Domain]++
}
expectedDomains := ValidTagDomains()
for _, d := range expectedDomains {
if counts[d] == 0 {
t.Errorf("domain %q has no tags", d)
}
}
}
// TestValidTagDomains_HasFiveDomains verifies exactly 5 domains exist.
func TestValidTagDomains_HasFiveDomains(t *testing.T) {
domains := ValidTagDomains()
if len(domains) != 5 {
t.Errorf("ValidTagDomains returned %d domains, want 5: %v", len(domains), domains)
}
}
// TestGetTagTaxonomy_ComponentDomainHasMovingPart checks essential component tags.
func TestGetTagTaxonomy_ComponentDomainHasMovingPart(t *testing.T) {
tagSet := make(map[string]string)
for _, tag := range GetTagTaxonomy() {
tagSet[tag.ID] = tag.Domain
}
essentialComponentTags := []string{
"moving_part", "rotating_part", "high_voltage", "networked", "has_ai",
"electrical_part", "sensor_part", "safety_device",
}
for _, id := range essentialComponentTags {
domain, ok := tagSet[id]
if !ok {
t.Errorf("essential component tag %q not found in taxonomy", id)
} else if domain != "component" {
t.Errorf("tag %q expected domain 'component', got %q", id, domain)
}
}
}
// TestGetTagTaxonomy_EnergyDomainHasKinetic checks essential energy tags.
func TestGetTagTaxonomy_EnergyDomainHasKinetic(t *testing.T) {
tagSet := make(map[string]string)
for _, tag := range GetTagTaxonomy() {
tagSet[tag.ID] = tag.Domain
}
essentialEnergyTags := []string{
"kinetic", "electrical_energy", "hydraulic_pressure",
}
for _, id := range essentialEnergyTags {
domain, ok := tagSet[id]
if !ok {
t.Errorf("essential energy tag %q not found in taxonomy", id)
} else if domain != "energy" {
t.Errorf("tag %q expected domain 'energy', got %q", id, domain)
}
}
}

View File

@@ -515,6 +515,108 @@ curl -sk "https://macmini:8093/sdk/v1/iace/controls-library?category=software_fa
---
## Hazard-Matching-Engine
Die Pattern Engine automatisiert die Ableitung von Gefaehrdungen, Schutzmassnahmen und Nachweisen aus der Maschinenkonfiguration.
### Komponentenbibliothek (120 Eintraege)
```bash
# Alle Komponenten abrufen
curl -sk "https://macmini:8093/sdk/v1/iace/component-library" | python3 -c \
"import sys,json; d=json.load(sys.stdin); print(f'{d[\"total\"]} Komponenten in {len(set(c[\"category\"] for c in d[\"components\"]))} Kategorien')"
# Nach Kategorie filtern
curl -sk "https://macmini:8093/sdk/v1/iace/component-library?category=mechanical"
```
| Kategorie | IDs | Anzahl | Beispiele |
|-----------|-----|--------|-----------|
| mechanical | C001-C020 | 20 | Roboterarm, Greifer, Foerderband |
| structural | C021-C030 | 10 | Maschinenrahmen, Schutzgehaeuse |
| drive | C031-C040 | 10 | Elektromotor, Servomotor |
| hydraulic | C041-C050 | 10 | Hydraulikpumpe, -zylinder |
| pneumatic | C051-C060 | 10 | Pneumatikzylinder, Kompressor |
| electrical | C061-C070 | 10 | Schaltschrank, Stromversorgung |
| control | C071-C080 | 10 | SPS, Sicherheits-SPS, HMI |
| sensor | C081-C090 | 10 | Positionssensor, Kamerasystem |
| actuator | C091-C100 | 10 | Magnetventil, Linearantrieb |
| safety | C101-C110 | 10 | Not-Halt, Lichtgitter |
| it_network | C111-C120 | 10 | Switch, Router, Firewall |
### Energiequellen (20 Eintraege)
```bash
curl -sk "https://macmini:8093/sdk/v1/iace/energy-sources"
```
### Tag-Taxonomie (~85 Tags)
| Domaene | Anzahl | Beispiele |
|---------|--------|-----------|
| component | ~30 | moving_part, rotating_part, high_voltage, networked, has_ai |
| energy | ~15 | kinetic, rotational, electrical_energy, hydraulic_pressure |
| hazard | ~20 | crush_risk, shear_risk, electric_shock_risk, cyber_risk |
| measure | ~10 | guard_measure, interlock_measure, software_safety_measure |
| evidence | ~10 | design_evidence, test_evidence, cyber_evidence |
```bash
# Alle Tags einer Domaene
curl -sk "https://macmini:8093/sdk/v1/iace/tags?domain=component"
```
### Hazard Patterns (44 Regeln)
Jedes Pattern definiert required_component_tags (AND), required_energy_tags (AND) und excluded_component_tags (NOT). Die Engine prueft alle Patterns gegen die aufgeloesten Tags der Projektkomponenten.
```bash
# Patterns auflisten
curl -sk "https://macmini:8093/sdk/v1/iace/hazard-patterns" | python3 -c \
"import sys,json; d=json.load(sys.stdin); print(f'{d[\"total\"]} Patterns')"
```
### Pattern-Matching Workflow
```bash
# 1. Pattern-Matching ausfuehren
curl -sk -X POST "https://macmini:8093/sdk/v1/iace/projects/{id}/match-patterns" \
-H "Content-Type: application/json" \
-d '{"component_library_ids": ["C001","C071"], "energy_source_ids": ["EN01","EN07"]}'
# 2. Ergebnisse uebernehmen
curl -sk -X POST "https://macmini:8093/sdk/v1/iace/projects/{id}/apply-patterns" \
-H "Content-Type: application/json" \
-d '{"accepted_hazards": [...], "accepted_measures": [...], "accepted_evidence": [...]}'
# 3. Pro-Hazard Massnahmen vorschlagen
curl -sk -X POST "https://macmini:8093/sdk/v1/iace/projects/{id}/hazards/{hid}/suggest-measures"
# 4. Pro-Massnahme Nachweise vorschlagen
curl -sk -X POST "https://macmini:8093/sdk/v1/iace/projects/{id}/mitigations/{mid}/suggest-evidence"
```
### RAG-Anreicherung (Phase 6)
IACE-Bibliotheken (Hazards, Komponenten, Energiequellen, Massnahmen, Nachweise) sind als RAG-Corpus in Qdrant verfuegbar (`bp_iace_libraries`).
```bash
# Semantische Suche in der IACE-Bibliothek
curl -sk -X POST "https://macmini:8093/sdk/v1/iace/library-search" \
-H "Content-Type: application/json" \
-d '{"query": "Quetschgefahr Roboterarm", "top_k": 5}'
# Tech-File-Abschnitt mit RAG-Kontext anreichern
curl -sk -X POST "https://macmini:8093/sdk/v1/iace/projects/{id}/tech-file/risk_assessment_report/enrich"
```
**Ingestion:**
```bash
# IACE-Bibliotheken in Qdrant ingestieren (auf Mac Mini)
bash ~/Projekte/breakpilot-compliance/scripts/ingest-iace-libraries.sh
```
---
## Datenbank-Tabellen
| Tabelle | Beschreibung |
@@ -534,6 +636,9 @@ curl -sk "https://macmini:8093/sdk/v1/iace/controls-library?category=software_fa
| `iace_lifecycle_phases` | 25 Lebensphasen (DE/EN) |
| `iace_roles` | 20 betroffene Personengruppen (DE/EN) |
| `iace_evidence_types` | 50 Nachweistypen in 7 Kategorien |
| `iace_component_library` | 120 Maschinenkomponenten (C001-C120) |
| `iace_energy_sources` | 20 Energiequellen (EN01-EN20) |
| `iace_pattern_results` | Audit-Trail fuer Pattern-Matching |
---

395
scripts/ingest-iace-libraries.sh Executable file
View File

@@ -0,0 +1,395 @@
#!/usr/bin/env bash
# =============================================================================
# BreakPilot Compliance — IACE Library RAG Ingestion
#
# Exports IACE hazard library, component library, energy sources, protective
# measures, and evidence types from the Go code / database, then ingests them
# into Qdrant collection `bp_iace_libraries` via the Core RAG-API (Port 8097).
#
# Execution on Mac Mini:
# bash ~/Projekte/breakpilot-compliance/scripts/ingest-iace-libraries.sh
# bash .../ingest-iace-libraries.sh [--skip-export] [--only PHASE]
#
# Phases: export, create-collection, ingest, verify, version
# =============================================================================
set -euo pipefail
# --- Configuration -----------------------------------------------------------
WORK_DIR="${WORK_DIR:-$HOME/rag-ingestion-iace}"
RAG_URL="https://localhost:8097/api/v1/documents/upload"
QDRANT_URL="http://localhost:6333"
SDK_URL="http://localhost:8090"
COLLECTION="bp_iace_libraries"
CURL_OPTS="-sk --connect-timeout 15 --max-time 600 --retry 3 --retry-delay 5"
DB_URL="${DB_URL:-postgresql://localhost:5432/breakpilot?search_path=compliance,core,public}"
# Counters
UPLOADED=0
FAILED=0
SKIPPED=0
# --- CLI Args ----------------------------------------------------------------
SKIP_EXPORT=false
ONLY_PHASE=""
while [[ $# -gt 0 ]]; do
case $1 in
--skip-export) SKIP_EXPORT=true; shift ;;
--only) ONLY_PHASE="$2"; shift 2 ;;
-h|--help)
echo "Usage: $0 [--skip-export] [--only PHASE]"
echo "Phases: export, create-collection, ingest, verify, version"
exit 0
;;
*) echo "Unknown option: $1"; exit 1 ;;
esac
done
# --- Helpers -----------------------------------------------------------------
log() { echo "[$(date '+%H:%M:%S')] $*"; }
ok() { echo "[$(date '+%H:%M:%S')] ✓ $*"; }
warn() { echo "[$(date '+%H:%M:%S')] ⚠ $*" >&2; }
fail() { echo "[$(date '+%H:%M:%S')] ✗ $*" >&2; }
should_run() {
[[ -z "$ONLY_PHASE" ]] || [[ "$ONLY_PHASE" == "$1" ]]
}
mkdir -p "$WORK_DIR"
# =============================================================================
# Phase 1: Export IACE library data from the SDK API
# =============================================================================
if should_run "export" && [[ "$SKIP_EXPORT" == "false" ]]; then
log "Phase 1: Exporting IACE library data from SDK API..."
# Export hazard library
log " Fetching hazard library..."
curl $CURL_OPTS "$SDK_URL/sdk/v1/iace/hazard-library" \
-H "X-Tenant-ID: system" \
-o "$WORK_DIR/hazard-library.json" 2>/dev/null && \
ok " Hazard library exported" || warn " Hazard library export failed"
# Export component library
log " Fetching component library..."
curl $CURL_OPTS "$SDK_URL/sdk/v1/iace/component-library" \
-H "X-Tenant-ID: system" \
-o "$WORK_DIR/component-library.json" 2>/dev/null && \
ok " Component library exported" || warn " Component library export failed"
# Export energy sources
log " Fetching energy sources..."
curl $CURL_OPTS "$SDK_URL/sdk/v1/iace/energy-sources" \
-H "X-Tenant-ID: system" \
-o "$WORK_DIR/energy-sources.json" 2>/dev/null && \
ok " Energy sources exported" || warn " Energy sources export failed"
# Export protective measures
log " Fetching protective measures library..."
curl $CURL_OPTS "$SDK_URL/sdk/v1/iace/protective-measures-library" \
-H "X-Tenant-ID: system" \
-o "$WORK_DIR/protective-measures.json" 2>/dev/null && \
ok " Protective measures exported" || warn " Protective measures export failed"
# Export evidence types
log " Fetching evidence types..."
curl $CURL_OPTS "$SDK_URL/sdk/v1/iace/evidence-types" \
-H "X-Tenant-ID: system" \
-o "$WORK_DIR/evidence-types.json" 2>/dev/null && \
ok " Evidence types exported" || warn " Evidence types export failed"
# Export tag taxonomy
log " Fetching tag taxonomy..."
curl $CURL_OPTS "$SDK_URL/sdk/v1/iace/tags" \
-H "X-Tenant-ID: system" \
-o "$WORK_DIR/tag-taxonomy.json" 2>/dev/null && \
ok " Tag taxonomy exported" || warn " Tag taxonomy export failed"
# Export hazard patterns
log " Fetching hazard patterns..."
curl $CURL_OPTS "$SDK_URL/sdk/v1/iace/hazard-patterns" \
-H "X-Tenant-ID: system" \
-o "$WORK_DIR/hazard-patterns.json" 2>/dev/null && \
ok " Hazard patterns exported" || warn " Hazard patterns export failed"
ok "Phase 1 complete: Library data exported to $WORK_DIR"
fi
# =============================================================================
# Phase 2: Create Qdrant collection (if not exists)
# =============================================================================
if should_run "create-collection"; then
log "Phase 2: Creating Qdrant collection '$COLLECTION'..."
# Check if collection exists
HTTP_CODE=$(curl $CURL_OPTS -o /dev/null -w "%{http_code}" \
"$QDRANT_URL/collections/$COLLECTION" 2>/dev/null)
if [[ "$HTTP_CODE" == "200" ]]; then
ok " Collection '$COLLECTION' already exists"
else
log " Creating collection with bge-m3 (1024 dimensions)..."
curl $CURL_OPTS -X PUT "$QDRANT_URL/collections/$COLLECTION" \
-H "Content-Type: application/json" \
-d '{
"vectors": {
"size": 1024,
"distance": "Cosine"
},
"optimizers_config": {
"default_segment_number": 2
}
}' 2>/dev/null && \
ok " Collection '$COLLECTION' created" || fail " Failed to create collection"
fi
fi
# =============================================================================
# Phase 3: Transform and ingest via Core RAG-API
# =============================================================================
if should_run "ingest"; then
log "Phase 3: Ingesting IACE library documents..."
# Create text files from JSON exports for RAG ingestion
python3 - "$WORK_DIR" <<'PYEOF'
import json, sys, os
work_dir = sys.argv[1]
output_dir = os.path.join(work_dir, "chunks")
os.makedirs(output_dir, exist_ok=True)
chunk_count = 0
def write_chunk(filename, text, metadata):
global chunk_count
chunk_count += 1
filepath = os.path.join(output_dir, filename)
with open(filepath, 'w') as f:
json.dump({"text": text, "metadata": metadata}, f, ensure_ascii=False)
# --- Hazard Library ---
try:
with open(os.path.join(work_dir, "hazard-library.json")) as f:
data = json.load(f)
hazards = data.get("hazards", data) if isinstance(data, dict) else data
for h in hazards:
text = f"""Gefaehrdung {h.get('id','')}: {h.get('name_de', h.get('name',''))}
Kategorie: {h.get('category','')}
Beschreibung: {h.get('description_de', h.get('description',''))}
Typische Ursachen: {h.get('typical_causes','')}
Gefaehrliche Situation: {h.get('hazardous_situation','')}
Moegliche Schaeden: {h.get('possible_damages','')}
Betroffene Rollen: {', '.join(h.get('affected_roles',[]))}
Lebensphasen: {', '.join(h.get('lifecycle_phases',[]))}
Massnahmenarten: {', '.join(h.get('measure_types',[]))}
Nachweisarten: {', '.join(h.get('evidence_types',[]))}"""
write_chunk(f"hazard_{h.get('id','unknown')}.json", text, {
"regulation_id": f"iace_hazard_{h.get('id','')}",
"regulation_name": h.get('name_de', h.get('name','')),
"regulation_short": "IACE Hazard Library",
"category": h.get('category',''),
"source": "iace_hazard_library"
})
print(f" Hazards: {len(hazards)} chunks")
except Exception as e:
print(f" Hazards: ERROR - {e}")
# --- Component Library ---
try:
with open(os.path.join(work_dir, "component-library.json")) as f:
data = json.load(f)
components = data.get("components", data) if isinstance(data, dict) else data
for c in components:
text = f"""Maschinenkomponente {c.get('id','')}: {c.get('name_de','')} ({c.get('name_en','')})
Kategorie: {c.get('category','')}
Beschreibung: {c.get('description_de','')}
Typische Gefaehrdungskategorien: {', '.join(c.get('typical_hazard_categories',[]))}
Typische Energiequellen: {', '.join(c.get('typical_energy_sources',[]))}
Komponententyp: {c.get('maps_to_component_type','')}
Tags: {', '.join(c.get('tags',[]))}"""
write_chunk(f"component_{c.get('id','unknown')}.json", text, {
"regulation_id": f"iace_component_{c.get('id','')}",
"regulation_name": c.get('name_de',''),
"regulation_short": "IACE Component Library",
"category": c.get('category',''),
"source": "iace_component_library"
})
print(f" Components: {len(components)} chunks")
except Exception as e:
print(f" Components: ERROR - {e}")
# --- Energy Sources ---
try:
with open(os.path.join(work_dir, "energy-sources.json")) as f:
data = json.load(f)
sources = data.get("energy_sources", data) if isinstance(data, dict) else data
for s in sources:
text = f"""Energiequelle {s.get('id','')}: {s.get('name_de','')} ({s.get('name_en','')})
Beschreibung: {s.get('description_de','')}
Typische Komponenten: {', '.join(s.get('typical_components',[]))}
Typische Gefaehrdungskategorien: {', '.join(s.get('typical_hazard_categories',[]))}
Tags: {', '.join(s.get('tags',[]))}"""
write_chunk(f"energy_{s.get('id','unknown')}.json", text, {
"regulation_id": f"iace_energy_{s.get('id','')}",
"regulation_name": s.get('name_de',''),
"regulation_short": "IACE Energy Sources",
"category": "energy",
"source": "iace_energy_sources"
})
print(f" Energy Sources: {len(sources)} chunks")
except Exception as e:
print(f" Energy Sources: ERROR - {e}")
# --- Protective Measures ---
try:
with open(os.path.join(work_dir, "protective-measures.json")) as f:
data = json.load(f)
measures = data.get("measures", data) if isinstance(data, dict) else data
for m in measures:
text = f"""Schutzmassnahme {m.get('id','')}: {m.get('name_de', m.get('name',''))}
Reduktionstyp: {m.get('reduction_type','')}
Beschreibung: {m.get('description_de', m.get('description',''))}
Massnahmenart: {m.get('measure_type','')}
Wirksamkeit: {m.get('effectiveness','')}"""
write_chunk(f"measure_{m.get('id','unknown')}.json", text, {
"regulation_id": f"iace_measure_{m.get('id','')}",
"regulation_name": m.get('name_de', m.get('name','')),
"regulation_short": "IACE Protective Measures",
"category": m.get('reduction_type',''),
"source": "iace_protective_measures"
})
print(f" Measures: {len(measures)} chunks")
except Exception as e:
print(f" Measures: ERROR - {e}")
# --- Evidence Types ---
try:
with open(os.path.join(work_dir, "evidence-types.json")) as f:
data = json.load(f)
evidence = data.get("evidence_types", data) if isinstance(data, dict) else data
for e in evidence:
text = f"""Nachweistyp {e.get('id','')}: {e.get('name_de', e.get('name',''))}
Methode: {e.get('method', e.get('verification_method',''))}
Beschreibung: {e.get('description_de', e.get('description',''))}"""
write_chunk(f"evidence_{e.get('id','unknown')}.json", text, {
"regulation_id": f"iace_evidence_{e.get('id','')}",
"regulation_name": e.get('name_de', e.get('name','')),
"regulation_short": "IACE Evidence Types",
"category": "evidence",
"source": "iace_evidence_types"
})
print(f" Evidence Types: {len(evidence)} chunks")
except Exception as e:
print(f" Evidence Types: ERROR - {e}")
print(f"\nTotal chunks prepared: {chunk_count}")
print(f"Output directory: {output_dir}")
PYEOF
# Upload each chunk via Core RAG-API
CHUNK_DIR="$WORK_DIR/chunks"
if [[ -d "$CHUNK_DIR" ]]; then
TOTAL=$(ls "$CHUNK_DIR"/*.json 2>/dev/null | wc -l | tr -d ' ')
log " Uploading $TOTAL chunks to Qdrant via RAG-API..."
for chunk_file in "$CHUNK_DIR"/*.json; do
[[ -f "$chunk_file" ]] || continue
BASENAME=$(basename "$chunk_file" .json)
# Extract text and metadata
TEXT=$(python3 -c "import json; d=json.load(open('$chunk_file')); print(d['text'])")
REG_ID=$(python3 -c "import json; d=json.load(open('$chunk_file')); print(d['metadata']['regulation_id'])")
# Check if already in Qdrant (dedup by regulation_id)
EXISTING=$(curl $CURL_OPTS -X POST "$QDRANT_URL/collections/$COLLECTION/points/scroll" \
-H "Content-Type: application/json" \
-d "{\"filter\":{\"must\":[{\"key\":\"regulation_id\",\"match\":{\"value\":\"$REG_ID\"}}]},\"limit\":1}" \
2>/dev/null | python3 -c "import json,sys; d=json.load(sys.stdin); print(len(d.get('result',{}).get('points',[])))" 2>/dev/null || echo "0")
if [[ "$EXISTING" -gt 0 ]]; then
SKIPPED=$((SKIPPED + 1))
continue
fi
# Create a temporary text file for upload
TMPFILE=$(mktemp "$WORK_DIR/tmp_XXXXXX.txt")
echo "$TEXT" > "$TMPFILE"
HTTP_CODE=$(curl $CURL_OPTS -o /dev/null -w "%{http_code}" \
-X POST "$RAG_URL" \
-F "file=@$TMPFILE" \
-F "collection=$COLLECTION" \
-F "data_type=iace_library" \
-F "use_case=ce_risk_assessment" \
-F "year=2026" \
-F "chunk_strategy=recursive" \
-F "chunk_size=512" \
-F "chunk_overlap=50" \
2>/dev/null)
rm -f "$TMPFILE"
if [[ "$HTTP_CODE" =~ ^2 ]]; then
UPLOADED=$((UPLOADED + 1))
else
FAILED=$((FAILED + 1))
warn " Failed to upload $BASENAME (HTTP $HTTP_CODE)"
fi
done
ok " Ingestion complete: $UPLOADED uploaded, $SKIPPED skipped, $FAILED failed"
else
warn " No chunks directory found at $CHUNK_DIR"
fi
fi
# =============================================================================
# Phase 4: Verify
# =============================================================================
if should_run "verify"; then
log "Phase 4: Verifying IACE library collection..."
POINT_COUNT=$(curl $CURL_OPTS "$QDRANT_URL/collections/$COLLECTION" 2>/dev/null | \
python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('result',{}).get('points_count',0))" 2>/dev/null || echo "0")
log " Collection '$COLLECTION': $POINT_COUNT points"
if [[ "$POINT_COUNT" -gt 0 ]]; then
ok " Verification passed"
else
warn " Collection is empty — ingestion may have failed"
fi
fi
# =============================================================================
# Phase 5: Record version in compliance_corpus_versions
# =============================================================================
if should_run "version"; then
log "Phase 5: Recording corpus version..."
POINT_COUNT=$(curl $CURL_OPTS "$QDRANT_URL/collections/$COLLECTION" 2>/dev/null | \
python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('result',{}).get('points_count',0))" 2>/dev/null || echo "0")
VERSION="1.0.0"
DIGEST=$(echo -n "iace-libraries-v1-$(date +%Y%m%d)" | shasum -a 256 | cut -d' ' -f1)
psql "$DB_URL" -c "
INSERT INTO compliance_corpus_versions (collection_name, version, documents_count, chunks_count, regulations, digest, notes)
VALUES ('$COLLECTION', '$VERSION', 7, $POINT_COUNT,
ARRAY['iace_hazard_library','iace_component_library','iace_energy_sources','iace_protective_measures','iace_evidence_types','iace_tag_taxonomy','iace_hazard_patterns'],
'$DIGEST',
'IACE CE-Risikobeurteilung Bibliotheken: 150 Hazards, 120 Komponenten, 20 Energiequellen, 200 Schutzmassnahmen, 50 Evidenztypen, 85 Tags, 44 Patterns')
ON CONFLICT DO NOTHING;
" 2>/dev/null && ok " Corpus version recorded" || warn " Version recording failed (table may not exist)"
fi
# =============================================================================
# Summary
# =============================================================================
log "================================================================"
log "IACE Library RAG Ingestion Summary"
log " Collection: $COLLECTION"
log " Uploaded: $UPLOADED"
log " Skipped: $SKIPPED"
log " Failed: $FAILED"
log "================================================================"