Files
breakpilot-compliance/ai-compliance-sdk/internal/api/handlers/iace_handler_enrich.go
T
Benjamin Admin 2e29b611c9 feat(iace): Phase 1 — Haftungs-Fixes, Massnahmen-Verkabelung, Explainability Engine
Phase 1A — Haftungs-kritische Fixes:
- SIL/PL-Badges als "Vorab-Einschaetzung" mit Tooltip gekennzeichnet
- Coverage-Disclaimer in CE-Akte, Projekt-Uebersicht und Print-Export
- Norm-Referenzen: 42 Kapitelverweise durch Themen-Deskriptoren ersetzt

Phase 1B — Massnahmen-Verkabelung:
- 16 neue Massnahmen (M201-M216) fuer bisher unabgedeckte Kategorien
  (communication_failure, hmi_error, firmware_corruption, maintenance,
  sensor_fault, mode_confusion)
- Kategorie-Fallback im Initialize-Endpoint: ordnet Massnahmen aus der
  Bibliothek automatisch per HazardCategory zu (max 8 pro Kategorie)
- Total: 225 → 241 Massnahmen, 0 Kategorien ohne Massnahmen

Phase 1C — Explainability Engine:
- MatchReason Struct in PatternMatch (type, tag, met)
- Pattern Engine schreibt fuer jeden Match strukturierte Begruendungen
- Frontend zeigt "Erkannt weil: Komponente X, Energie Y, Kein Ausschluss Z"

Weitere Aenderungen:
- BAuA/OSHA Regulatory Hints: 3 Enrich-Endpoints (per Hazard, per Measure, Batch)
- Dokumente-Tab in IACE-Bibliothek (36.708 Chunks aus Qdrant)
- Varianten-UX: Basis-Projekt-Summary auf Varianten-Seite
- Projekt-Initialisierung: POST /initialize kettet Parse→Komponenten→Patterns→Hazards→Massnahmen→Normen
- 18 pre-existing TS-Fehler gefixt, Route-Konflikt behoben
- Component-Library + Measures-Library Tests aktualisiert

Tests: Go alle bestanden, TS 0 Fehler, Playwright 141+ bestanden

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-09 21:32:23 +02:00

345 lines
11 KiB
Go

package handlers
import (
"fmt"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// RegulatoryHint represents a relevant passage from TRBS/TRGS/ASR/OSHA.
type RegulatoryHint struct {
RegulationID string `json:"regulation_id"`
RegulationShort string `json:"regulation_short"`
Category string `json:"category"`
Text string `json:"text"`
Pages []int `json:"pages,omitempty"`
SourceURL string `json:"source_url,omitempty"`
Score float64 `json:"score"`
}
// categoryToSearchTerms maps hazard categories to German search terms
// that match TRBS/TRGS/ASR/OSHA content.
var categoryToSearchTerms = map[string]string{
"mechanical_hazard": "mechanische Gefaehrdung Quetschstelle Scherstelle Stossstelle",
"electrical_hazard": "elektrische Gefaehrdung Stromschlag Lichtbogen Kurzschluss",
"thermal_hazard": "thermische Gefaehrdung Verbrennung Erfrierung heisse Oberflaeche",
"noise_hazard": "Laerm Gehoerschutz Schalldruckpegel Laermexposition",
"vibration_hazard": "Vibration Hand-Arm Ganzkoerper Schwingungsbelastung",
"radiation_hazard": "Strahlung ionisierend nichtionisierend Laser UV",
"chemical_hazard": "Gefahrstoff chemische Gefaehrdung Exposition Grenzwert",
"ergonomic_hazard": "Ergonomie Zwangshaltung Lasthandhabung Koerperbelastung",
"hydraulic_hazard": "Hydraulik Druckbehaelter Druck Bersten Leckage",
"pneumatic_hazard": "Pneumatik Druckluft Druckbehaelter Belueftung",
"software_hazard": "Software Sicherheitsfunktion Fehlfunktion Programmierung",
"safety_function_failure": "Sicherheitsfunktion Ausfall SIL Performance Level",
"fire_explosion_hazard": "Brand Explosion explosionsfaehige Atmosphaere Zuendschutz",
"falling_hazard": "Absturz herabfallende Gegenstaende Sturzgefahr",
"trip_slip_hazard": "Stolpern Rutschen Ausrutschen Fussboden Verkehrsweg",
"entrapment_hazard": "Einzugsstelle Fangstelle rotierende Teile Wickelgefahr",
"crush_hazard": "Quetschgefahr Quetschstelle Einklemmen Andruckkraft",
"cut_hazard": "Schneiden Schneidwerkzeug Schnittverletzung scharfe Kante",
"stabbing_hazard": "Stechen Stichverletzung spitze Teile Injektionsgefahr",
"high_pressure_hazard": "Hochdruck Fluessigkeitsstrahl Druckbehaelter Ueberdruck",
"collision_hazard": "Kollision Zusammenstoss Anfahren fahrerlose Transportsysteme",
"lack_of_stability_hazard": "Standsicherheit Umkippen Kippen Stabilitaet",
"unexpected_start_hazard": "unerwarteter Anlauf Wiederanlauf Energietrennung Lockout",
"control_system_failure": "Steuerungsausfall Steuerung Fehler Ausfall Sicherheitssteuerung",
"ppe_hazard": "PSA persoenliche Schutzausruestung Schutzkleidung",
}
// EnrichHazardWithRegulations returns regulatory hints for a specific hazard.
// GET /projects/:id/hazards/:hid/regulatory-hints
func (h *IACEHandler) EnrichHazardWithRegulations(c *gin.Context) {
projectID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"})
return
}
hazardID, err := uuid.Parse(c.Param("hid"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid hazard ID"})
return
}
// Fetch hazard
hazard, err := h.store.GetHazard(c.Request.Context(), hazardID)
if err != nil || hazard == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "hazard not found"})
return
}
if hazard.ProjectID != projectID {
c.JSON(http.StatusNotFound, gin.H{"error": "hazard not in this project"})
return
}
// Fetch project for machine context
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 search query from hazard context
query := buildHazardSearchQuery(hazard.Category, hazard.Name, hazard.Scenario, project.MachineName, project.MachineType)
// Search bp_compliance_ce (TRBS/TRGS/ASR/OSHA)
results, err := h.ragClient.SearchCollection(
c.Request.Context(),
"bp_compliance_ce",
query,
nil, // no regulation filter — search all
7,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "regulatory search failed: " + err.Error(),
})
return
}
hints := make([]RegulatoryHint, 0, len(results))
for _, r := range results {
if r.Score < 0.3 {
continue // skip low-relevance results
}
hints = append(hints, RegulatoryHint{
RegulationID: r.RegulationCode,
RegulationShort: r.RegulationShort,
Category: r.Category,
Text: truncateHintText(r.Text, 500),
Pages: r.Pages,
SourceURL: r.SourceURL,
Score: r.Score,
})
}
c.JSON(http.StatusOK, gin.H{
"hazard_id": hazardID.String(),
"hazard_name": hazard.Name,
"category": hazard.Category,
"query": query,
"hints": hints,
"total": len(hints),
})
}
// EnrichMitigationWithRegulations returns regulatory hints for a mitigation.
// GET /projects/:id/mitigations/:mid/regulatory-hints
func (h *IACEHandler) EnrichMitigationWithRegulations(c *gin.Context) {
projectID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"})
return
}
mitigationID, err := uuid.Parse(c.Param("mid"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid mitigation ID"})
return
}
// Fetch mitigation
mitigation, err := h.store.GetMitigation(c.Request.Context(), mitigationID)
if err != nil || mitigation == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "mitigation not found"})
return
}
// Fetch the hazard to get category context
hazard, err := h.store.GetHazard(c.Request.Context(), mitigation.HazardID)
if err != nil || hazard == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "linked hazard not found"})
return
}
if hazard.ProjectID != projectID {
c.JSON(http.StatusNotFound, gin.H{"error": "mitigation not in this project"})
return
}
// Build search query from mitigation + hazard context
queryParts := []string{mitigation.Name}
if mitigation.Description != "" {
queryParts = append(queryParts, mitigation.Description)
}
queryParts = append(queryParts, "Schutzmassnahme")
if terms, ok := categoryToSearchTerms[hazard.Category]; ok {
queryParts = append(queryParts, terms)
}
query := strings.Join(queryParts, " ")
if len(query) > 500 {
query = query[:500]
}
results, err := h.ragClient.SearchCollection(
c.Request.Context(),
"bp_compliance_ce",
query,
nil,
7,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "regulatory search failed: " + err.Error(),
})
return
}
hints := make([]RegulatoryHint, 0, len(results))
for _, r := range results {
if r.Score < 0.3 {
continue
}
hints = append(hints, RegulatoryHint{
RegulationID: r.RegulationCode,
RegulationShort: r.RegulationShort,
Category: r.Category,
Text: truncateHintText(r.Text, 500),
Pages: r.Pages,
SourceURL: r.SourceURL,
Score: r.Score,
})
}
c.JSON(http.StatusOK, gin.H{
"mitigation_id": mitigationID.String(),
"mitigation_name": mitigation.Name,
"reduction_type": mitigation.ReductionType,
"query": query,
"hints": hints,
"total": len(hints),
})
}
// buildHazardSearchQuery creates a contextual query for RAG search.
func buildHazardSearchQuery(category, name, scenario, machineName, machineType string) string {
parts := make([]string, 0, 5)
// Add category-specific German search terms
if terms, ok := categoryToSearchTerms[category]; ok {
parts = append(parts, terms)
}
// Add hazard name and scenario
if name != "" {
parts = append(parts, name)
}
if scenario != "" && len(scenario) < 200 {
parts = append(parts, scenario)
}
// Add machine context
if machineType != "" {
parts = append(parts, machineType)
}
if machineName != "" && len(parts) < 4 {
parts = append(parts, machineName)
}
query := strings.Join(parts, " ")
if len(query) > 500 {
query = query[:500]
}
return query
}
func truncateHintText(text string, maxLen int) string {
if len(text) <= maxLen {
return text
}
// Find last sentence boundary
truncated := text[:maxLen]
if lastDot := strings.LastIndex(truncated, ". "); lastDot > maxLen/2 {
return truncated[:lastDot+1]
}
return truncated + "..."
}
// ============================================================================
// Batch: Enrich all hazards at once (for overview display)
// ============================================================================
// EnrichProjectHazardsBatch returns top regulatory hint per hazard category.
// GET /projects/:id/regulatory-hints
func (h *IACEHandler) EnrichProjectHazardsBatch(c *gin.Context) {
projectID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"})
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
}
// Get all hazards to extract unique categories
hazards, err := h.store.ListHazards(c.Request.Context(), projectID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list hazards"})
return
}
// Deduplicate categories
seen := make(map[string]bool)
var categories []string
for _, hz := range hazards {
if !seen[hz.Category] {
seen[hz.Category] = true
categories = append(categories, hz.Category)
}
}
// One RAG search per unique category (typically 5-10 categories, not 160 hazards)
type CategoryHints struct {
Category string `json:"category"`
Hints []RegulatoryHint `json:"hints"`
}
result := make([]CategoryHints, 0, len(categories))
for _, cat := range categories {
query := buildHazardSearchQuery(cat, "", "", project.MachineName, project.MachineType)
results, err := h.ragClient.SearchCollection(
c.Request.Context(),
"bp_compliance_ce",
query,
nil,
3,
)
if err != nil {
continue
}
hints := make([]RegulatoryHint, 0, len(results))
for _, r := range results {
if r.Score < 0.3 {
continue
}
hints = append(hints, RegulatoryHint{
RegulationID: r.RegulationCode,
RegulationShort: r.RegulationShort,
Category: r.Category,
Text: truncateHintText(r.Text, 300),
Pages: r.Pages,
SourceURL: r.SourceURL,
Score: r.Score,
})
}
if len(hints) > 0 {
result = append(result, CategoryHints{Category: cat, Hints: hints})
}
}
c.JSON(http.StatusOK, gin.H{
"project_id": projectID.String(),
"categories": len(categories),
"total_hazards": len(hazards),
"regulatory_hints": result,
"sources": fmt.Sprintf("TRBS/TRGS/ASR (%d BAuA) + OSHA Technical Manual", 126),
})
}