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>
This commit is contained in:
@@ -0,0 +1,344 @@
|
||||
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),
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user