2e29b611c9
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>
345 lines
11 KiB
Go
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),
|
|
})
|
|
}
|