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

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

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

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

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

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

View File

@@ -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"