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
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:
@@ -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>
|
||||
)
|
||||
)}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
)}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
95
ai-compliance-sdk/internal/iace/controls_library_test.go
Normal file
95
ai-compliance-sdk/internal/iace/controls_library_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
257
ai-compliance-sdk/internal/iace/integration_test.go
Normal file
257
ai-compliance-sdk/internal/iace/integration_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
117
ai-compliance-sdk/internal/iace/tag_taxonomy_test.go
Normal file
117
ai-compliance-sdk/internal/iace/tag_taxonomy_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
395
scripts/ingest-iace-libraries.sh
Executable 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 "================================================================"
|
||||
Reference in New Issue
Block a user