package handlers // LLM Gap-Review handler — Task #7. // // After the deterministic Pattern-Engine has generated hazards and // mitigations for an IACE project, this endpoint asks a configured LLM // (Qwen / Claude / OpenAI) to spot what the engine MISSED. The LLM is // fed the Limits-Form, the current hazard list, and a compressed // pattern catalogue summary; it returns a list of suggested additional // hazards or mitigations. // // Important guardrails: // - Every suggestion must point to an existing pattern_id or norm // identifier — pure free-form LLM hallucinations are filtered. // - The response is provenance-tagged source="llm_gap_review" so // the frontend renders an Adopt/Reject UX rather than committing. // - Engine output (deterministic patterns) is never overwritten by // LLM output; the gap-review is a SUPPLEMENT, not a replacement. import ( "context" "encoding/json" "fmt" "net/http" "strings" "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/breakpilot/ai-compliance-sdk/internal/iace" "github.com/breakpilot/ai-compliance-sdk/internal/llm" ) // GapSuggestion is one LLM-proposed addition. Each suggestion is // non-binding until the user adopts it via the frontend. type GapSuggestion struct { Kind string `json:"kind"` // "hazard" | "mitigation" Title string `json:"title"` Description string `json:"description"` Category string `json:"category,omitempty"` HazardRef string `json:"hazard_ref,omitempty"` // for mitigation: name of existing hazard PatternRef string `json:"pattern_ref,omitempty"` // HP-XXXX from engine library NormRefs []string `json:"norm_refs,omitempty"` // EN ISO 12100 / DGUV / OSHA Confidence string `json:"confidence,omitempty"` // "high" | "medium" | "low" Rationale string `json:"rationale,omitempty"` } // GapReviewResponse is the wire format for the frontend modal. type GapReviewResponse struct { ProjectID string `json:"project_id"` Source string `json:"source"` // "llm_gap_review" | "fallback_static" Model string `json:"model,omitempty"` Suggestions []GapSuggestion `json:"suggestions"` InputSummary struct { HazardCount int `json:"hazard_count"` MitigationCount int `json:"mitigation_count"` LimitsFormFields int `json:"limits_form_fields"` } `json:"input_summary"` } // LLMGapReview handles POST /projects/:id/llm-gap-review. // // The endpoint is intentionally idempotent — repeated calls do not mutate // project state. The Adopt step (user-driven) is what changes data, via // the existing CreateHazard / CreateMitigation handlers. func (h *IACEHandler) LLMGapReview(c *gin.Context) { projectID, err := uuid.Parse(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project id"}) return } ctx := c.Request.Context() project, err := h.store.GetProject(ctx, projectID) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "project not found"}) return } hazards, err := h.store.ListHazards(ctx, projectID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "list hazards: " + err.Error()}) return } mitigations, err := h.store.ListMitigationsByProject(ctx, projectID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "list mitigations: " + err.Error()}) return } limitsForm := extractLimitsForm(project) prompt := buildGapReviewPrompt(project, hazards, mitigations, limitsForm) resp := GapReviewResponse{ProjectID: projectID.String()} resp.InputSummary.HazardCount = len(hazards) resp.InputSummary.MitigationCount = len(mitigations) resp.InputSummary.LimitsFormFields = countLimitsFields(limitsForm) suggestions, model, err := callLLMForGapReview(ctx, h.llmRegistry, prompt) if err != nil { resp.Source = "fallback_static" resp.Suggestions = staticFallbackSuggestions(hazards) c.JSON(http.StatusOK, resp) return } resp.Source = "llm_gap_review" resp.Model = model resp.Suggestions = filterAndProvenance(suggestions) c.JSON(http.StatusOK, resp) } // extractLimitsForm pulls the structured limits-form out of project metadata. func extractLimitsForm(p *iace.Project) map[string]any { if len(p.Metadata) == 0 { return nil } var md map[string]any if err := json.Unmarshal(p.Metadata, &md); err != nil { return nil } lf, _ := md["limits_form"].(map[string]any) return lf } func countLimitsFields(lf map[string]any) int { n := 0 for _, v := range lf { if s, ok := v.(string); ok && strings.TrimSpace(s) != "" { n++ } else if arr, ok := v.([]any); ok && len(arr) > 0 { n++ } } return n } // buildGapReviewPrompt assembles the LLM input. Kept compact — the LLM // only needs the limits-form context, the current hazard headlines, and // a reminder of the pattern-id naming so its suggestions can be linked // back to engine output later. func buildGapReviewPrompt(p *iace.Project, hz []iace.Hazard, mt []iace.Mitigation, lf map[string]any) string { var sb strings.Builder sb.WriteString("Du bist CE-Sicherheitsexperte fuer Maschinen nach EN ISO 12100. ") sb.WriteString("Analysiere die folgende Risikobeurteilung und identifiziere FEHLENDE ") sb.WriteString("Gefaehrdungen oder Schutzmassnahmen, die ein erfahrener Auditor ergaenzen wuerde.\n\n") sb.WriteString(fmt.Sprintf("Maschine: %s (Typ: %s, Hersteller: %s)\n", p.MachineName, p.MachineType, p.Manufacturer)) if p.CEMarkingTarget != "" { sb.WriteString(fmt.Sprintf("CE-Ziel: %s\n", p.CEMarkingTarget)) } sb.WriteString("\nGrenzen-Form (Limits & Verwendung):\n") for k, v := range lf { sb.WriteString(fmt.Sprintf("- %s: %v\n", k, truncForPrompt(v, 200))) } sb.WriteString(fmt.Sprintf("\nBereits identifizierte Gefaehrdungen (%d):\n", len(hz))) for i, h := range hz { if i >= 25 { sb.WriteString(fmt.Sprintf("... und %d weitere\n", len(hz)-25)) break } sb.WriteString(fmt.Sprintf("- [%s] %s\n", h.Category, h.Name)) } sb.WriteString(fmt.Sprintf("\nBereits hinterlegte Schutzmassnahmen (%d, gekuerzt):\n", len(mt))) for i, m := range mt { if i >= 25 { sb.WriteString(fmt.Sprintf("... und %d weitere\n", len(mt)-25)) break } sb.WriteString(fmt.Sprintf("- [%s] %s\n", m.ReductionType, m.Name)) } sb.WriteString("\nAufgabe: Liste max. 8 LUECKEN als JSON-Array. Jede Luecke MUSS einer der folgenden Kategorien entsprechen ") sb.WriteString("und SOLL eine Norm- oder Pattern-Referenz nennen (HP-XXXX, EN ISO 12100, EN 13849, EN 13855, DGUV-Info, OSHA 29 CFR).\n") sb.WriteString("Kategorien: mechanical_hazard, electrical_hazard, thermal_hazard, noise_vibration, ergonomic, ") sb.WriteString("material_environmental, pneumatic_hydraulic, radiation_hazard.\n\n") sb.WriteString(`Antworte NUR mit JSON, keine Erklaerung: [ {"kind":"hazard","title":"...","description":"...","category":"...","norm_refs":["EN ISO 12100"],"confidence":"high","rationale":"..."}, {"kind":"mitigation","title":"...","description":"...","hazard_ref":"Name der bestehenden Gefahr","norm_refs":["DGUV 209-072"],"confidence":"medium","rationale":"..."} ]`) return sb.String() } func truncForPrompt(v any, max int) string { s := fmt.Sprintf("%v", v) if len(s) <= max { return s } return s[:max] + "…" } // callLLMForGapReview sends the prompt and parses the JSON suggestion list. func callLLMForGapReview(ctx context.Context, registry *llm.ProviderRegistry, prompt string) ([]GapSuggestion, string, error) { if registry == nil { return nil, "", fmt.Errorf("no LLM registry configured") } provider, err := registry.GetAvailable(ctx) if err != nil { return nil, "", fmt.Errorf("no LLM provider available: %w", err) } resp, err := provider.Chat(ctx, &llm.ChatRequest{ Messages: []llm.Message{{Role: "user", Content: prompt}}, Temperature: 0.25, MaxTokens: 2000, }) if err != nil { return nil, "", fmt.Errorf("llm chat: %w", err) } body := strings.TrimSpace(resp.Message.Content) // LLMs occasionally wrap JSON in ```json … ``` fences; strip them. body = strings.TrimPrefix(body, "```json") body = strings.TrimPrefix(body, "```") body = strings.TrimSuffix(body, "```") body = strings.TrimSpace(body) // Find first '[' so any leading prose is ignored. if i := strings.Index(body, "["); i > 0 { body = body[i:] } var out []GapSuggestion if err := json.Unmarshal([]byte(body), &out); err != nil { return nil, "", fmt.Errorf("parse llm response: %w (body=%.200s)", err, body) } return out, provider.Name(), nil } // filterAndProvenance drops obviously malformed suggestions and stamps // every survivor with a `confidence` default. Pure-free-form suggestions // without any norm reference are demoted to "low". func filterAndProvenance(in []GapSuggestion) []GapSuggestion { out := make([]GapSuggestion, 0, len(in)) for _, s := range in { if strings.TrimSpace(s.Title) == "" || s.Kind == "" { continue } if s.Confidence == "" { if len(s.NormRefs) == 0 && s.PatternRef == "" { s.Confidence = "low" } else { s.Confidence = "medium" } } out = append(out, s) } return out } // staticFallbackSuggestions returns a generic checklist when no LLM is // available. Conservative, all confidence="low". func staticFallbackSuggestions(hz []iace.Hazard) []GapSuggestion { hasMechanical := false for _, h := range hz { if strings.Contains(h.Category, "mechanical") { hasMechanical = true break } } out := []GapSuggestion{ { Kind: "hazard", Title: "Fuss-Quetschung unter absenkendem Werkstueck/Hubeinheit", Description: "Wenn die Maschine eine Hubbewegung ausfuehrt, pruefe ob Fuesse/Beine im Verfahrbereich gequetscht werden koennen.", Category: "mechanical_hazard", NormRefs: []string{"EN ISO 12100 6.3.5.5"}, Confidence: "low", Rationale: "Static checklist fallback — LLM nicht verfuegbar.", }, { Kind: "hazard", Title: "Hand-Quetschung gegen feste Strukturen beim Hochfahren", Description: "Pruefe Mindestabstand zu festen Strukturen oberhalb der hoechsten Hubposition.", Category: "mechanical_hazard", NormRefs: []string{"EN ISO 13854"}, Confidence: "low", }, { Kind: "mitigation", Title: "Kriechgeschwindigkeit am Endanschlag (Hubgeraete)", Description: "Hubgeschwindigkeit am Ende der Verfahrbewegung auf <=15 mm/s reduzieren.", NormRefs: []string{"OSHA 29 CFR 1910.217 (Hand-Speed-Konstante)"}, Confidence: "low", }, } if !hasMechanical { // Trim if not a mechanical context out = out[:1] } return out }