@@ -843,7 +1242,14 @@ export default function HazardsPage() {
.map((hazard) => (
- {hazard.name}
+
+
{hazard.name}
+ {hazard.name.startsWith('Auto:') && (
+
+ Auto
+
+ )}
+
{hazard.description && (
{hazard.description}
)}
@@ -890,10 +1296,17 @@ export default function HazardsPage() {
Kein Hazard Log vorhanden
- 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.
+
+ {matchingPatterns ? 'Erkennung laeuft...' : 'Auto-Erkennung starten'}
+
setShowForm(true)}
className="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
diff --git a/admin-compliance/app/sdk/iace/[projectId]/mitigations/page.tsx b/admin-compliance/app/sdk/iace/[projectId]/mitigations/page.tsx
index 2ee587a..5c3cf8c 100644
--- a/admin-compliance/app/sdk/iace/[projectId]/mitigations/page.tsx
+++ b/admin-compliance/app/sdk/iace/[projectId]/mitigations/page.tsx
@@ -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('')
+ const [suggested, setSuggested] = useState([])
+ const [loadingSuggestions, setLoadingSuggestions] = useState(false)
+
+ const riskColors: Record = {
+ 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 (
+
+
+
+
+
Massnahmen-Vorschlaege
+
+
+
+
+
+
+
+ Waehlen Sie eine Gefaehrdung, um passende Massnahmen vorgeschlagen zu bekommen.
+
+
+ {hazards.map(h => (
+ 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}
+
+ ))}
+
+
+
+
+ {loadingSuggestions ? (
+
+ ) : suggested.length > 0 ? (
+
+ {(['design', 'protection', 'information'] as const).map(type => {
+ const items = groupedByType[type]
+ if (items.length === 0) return null
+ const config = REDUCTION_TYPES[type]
+ return (
+
+
+ {config.icon}
+ {config.label}
+ {items.length}
+
+
+ {items.map(m => (
+
+
+
+
+ {m.id}
+ {m.sub_type && (
+ {m.sub_type}
+ )}
+
+
{m.name}
+
{m.description}
+
+
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
+
+
+
+ ))}
+
+
+ )
+ })}
+
+ ) : selectedHazard ? (
+
+ Keine Vorschlaege fuer diese Gefaehrdung gefunden.
+
+ ) : (
+
+ Waehlen Sie eine Gefaehrdung aus, um Vorschlaege zu erhalten.
+
+ )}
+
+
+
+ )
+}
+
interface MitigationFormData {
title: string
description: string
@@ -375,7 +536,14 @@ function MitigationCard({
return (
-
{mitigation.title}
+
+
{mitigation.title}
+ {mitigation.title.startsWith('Auto:') && (
+
+ Auto
+
+ )}
+
{mitigation.description && (
@@ -424,6 +592,8 @@ export default function MitigationsPage() {
const [showLibrary, setShowLibrary] = useState(false)
const [libraryFilter, setLibraryFilter] = useState
()
const [measures, setMeasures] = useState([])
+ // 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() {
+ {hazards.length > 0 && (
+
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"
+ >
+
+
+
+ Vorschlaege
+
+ )}
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 && (
+ setShowSuggest(false)}
+ />
+ )}
+
{/* 3-Column Layout */}
{(['design', 'protection', 'information'] as const).map((type) => {
diff --git a/admin-compliance/app/sdk/iace/[projectId]/verification/page.tsx b/admin-compliance/app/sdk/iace/[projectId]/verification/page.tsx
index a0cadd8..de326c2 100644
--- a/admin-compliance/app/sdk/iace/[projectId]/verification/page.tsx
+++ b/admin-compliance/app/sdk/iace/[projectId]/verification/page.tsx
@@ -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
('')
+ const [suggested, setSuggested] = useState([])
+ 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 (
+
+
+
+
+
Nachweise vorschlagen
+
+
+
+
+
+
+
+ Waehlen Sie eine Massnahme, um passende Nachweismethoden vorgeschlagen zu bekommen.
+
+
+ {mitigations.map(m => (
+ 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}
+
+ ))}
+
+
+
+
+ {loadingSuggestions ? (
+
+ ) : suggested.length > 0 ? (
+
+ {suggested.map(ev => (
+
+
+
+
+ {ev.id}
+ {ev.method && (
+
+ {VERIFICATION_METHODS.find(m => m.value === ev.method)?.label || ev.method}
+
+ )}
+
+
{ev.name}
+
{ev.description}
+
+
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
+
+
+
+ ))}
+
+ ) : selectedMitigation ? (
+
+ Keine Vorschlaege fuer diese Massnahme gefunden.
+
+ ) : (
+
+ Waehlen Sie eine Massnahme aus, um Nachweise vorgeschlagen zu bekommen.
+
+ )}
+
+
+
+ )
+}
+
+// ============================================================================
+// 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(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.
- 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"
- >
-
-
-
- Verifikation hinzufuegen
-
+
+ {mitigations.length > 0 && (
+
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"
+ >
+
+
+
+ Nachweise vorschlagen
+
+ )}
+
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"
+ >
+
+
+
+ Verifikation hinzufuegen
+
+
{/* Stats */}
@@ -399,6 +566,16 @@ export default function VerificationPage() {
/>
)}
+ {/* Suggest Evidence Modal (Phase 5) */}
+ {showSuggest && (
+ setShowSuggest(false)}
+ />
+ )}
+
{/* Table */}
{items.length > 0 ? (
@@ -472,12 +649,22 @@ export default function VerificationPage() {
Definieren Sie Verifikationsschritte fuer Ihre Schutzmassnahmen.
Jede Massnahme sollte durch mindestens eine Verifikation abgedeckt sein.
-
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
-
+
+ {mitigations.length > 0 && (
+ setShowSuggest(true)}
+ className="px-6 py-3 border border-green-300 text-green-700 rounded-lg hover:bg-green-50 transition-colors"
+ >
+ Nachweise vorschlagen
+
+ )}
+ setShowForm(true)}
+ className="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
+ >
+ Erste Verifikation anlegen
+
+
)
)}
diff --git a/ai-compliance-sdk/cmd/server/main.go b/ai-compliance-sdk/cmd/server/main.go
index 805c5fa..e6ca81b 100644
--- a/ai-compliance-sdk/cmd/server/main.go
+++ b/ai-compliance-sdk/cmd/server/main.go
@@ -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)
}
}
diff --git a/ai-compliance-sdk/internal/api/handlers/iace_handler.go b/ai-compliance-sdk/internal/api/handlers/iace_handler.go
index 8f91d14..48db36c 100644
--- a/ai-compliance-sdk/internal/api/handlers/iace_handler.go
+++ b/ai-compliance-sdk/internal/api/handlers/iace_handler.go
@@ -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)
diff --git a/ai-compliance-sdk/internal/api/handlers/rag_handlers.go b/ai-compliance-sdk/internal/api/handlers/rag_handlers.go
index 9d997d8..ed0367f 100644
--- a/ai-compliance-sdk/internal/api/handlers/rag_handlers.go
+++ b/ai-compliance-sdk/internal/api/handlers/rag_handlers.go
@@ -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.
diff --git a/ai-compliance-sdk/internal/iace/controls_library_test.go b/ai-compliance-sdk/internal/iace/controls_library_test.go
new file mode 100644
index 0000000..358d638
--- /dev/null
+++ b/ai-compliance-sdk/internal/iace/controls_library_test.go
@@ -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)
+ }
+}
diff --git a/ai-compliance-sdk/internal/iace/integration_test.go b/ai-compliance-sdk/internal/iace/integration_test.go
new file mode 100644
index 0000000..95208b8
--- /dev/null
+++ b/ai-compliance-sdk/internal/iace/integration_test.go
@@ -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)
+ }
+ }
+}
diff --git a/ai-compliance-sdk/internal/iace/tag_resolver_test.go b/ai-compliance-sdk/internal/iace/tag_resolver_test.go
index cf1fad1..e40c1fc 100644
--- a/ai-compliance-sdk/internal/iace/tag_resolver_test.go
+++ b/ai-compliance-sdk/internal/iace/tag_resolver_test.go
@@ -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))
+ }
+}
diff --git a/ai-compliance-sdk/internal/iace/tag_taxonomy_test.go b/ai-compliance-sdk/internal/iace/tag_taxonomy_test.go
new file mode 100644
index 0000000..4231cd1
--- /dev/null
+++ b/ai-compliance-sdk/internal/iace/tag_taxonomy_test.go
@@ -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)
+ }
+ }
+}
diff --git a/docs-src/services/sdk-modules/iace.md b/docs-src/services/sdk-modules/iace.md
index a05607c..5b551ca 100644
--- a/docs-src/services/sdk-modules/iace.md
+++ b/docs-src/services/sdk-modules/iace.md
@@ -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 |
---
diff --git a/scripts/ingest-iace-libraries.sh b/scripts/ingest-iace-libraries.sh
new file mode 100755
index 0000000..2922447
--- /dev/null
+++ b/scripts/ingest-iace-libraries.sh
@@ -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 "================================================================"