feat(iace): LLM gap-review (Task #7+#8) + tech-file sources appendix (#29)

Three coupled pieces of work, all landing the same PoC:

1. Backend gap-review endpoint (Task #7)
   - internal/api/handlers/iace_handler_gap_review.go:
       POST /projects/:id/llm-gap-review
       feeds Limits-Form + current hazards + current mitigations to
       the configured LLM (Qwen / Claude / OpenAI via ProviderRegistry),
       parses a JSON suggestion list, filter+stamps confidence, falls
       back to a static checklist when LLM is unavailable.
   - Adopt step is NOT in this endpoint by design — the user clicks
     Adopt in the frontend which calls the existing CreateHazard /
     CreateMitigation handlers so provenance flows through the normal
     audit trail.

2. Frontend modal + button (Task #8)
   - app/sdk/iace/[projectId]/hazards/_components/LLMGapReviewModal.tsx:
       reusable modal that POSTs the gap-review endpoint, renders
       suggestions with Adopt/Reject UX, shows confidence + norm refs,
       source-stamp llm_gap_review vs fallback_static.
   - hazards/page.tsx: indigo "KI-Gap-Review" button next to the
     existing "Eigene Gefaehrdung" button + modal mount.

3. Tech-File sources appendix (Task #29 — Stufe 4)
   - internal/iace/document_export_sources.go: new pdfSourcesAppendix
     method appended to ExportPDF. Groups cited norms by license rule
     (R1 OSHA/EU-Recht / R3 BreakPilot patterns / R3 DIN-EN-ISO
     identifier-only) and emits the legally required statement that
     pauschal Impressum-Hinweise nicht ausreichen.
   - extractCitedNorms() scans hazard/mitigation text for EN/ISO/IEC/
     DIN identifiers in a narrow grammar so prose isn't turned into
     spurious citations.

Bonus refactor:
   - internal/app/routes.go reached the 500-LOC hard cap when the new
     llm-gap-review route was added. Extracted registerIACERoutes into
     routes_iace.go (136 LOC). Same wiring, no behaviour change.

Three of the four Attribution-Renderer stages (1, 2, 4) now produce
real output. Stufe 3 ships as <SourceBadge> + <LicenseModuleBanner>
already (commits dfac940 + b9e3eea earlier in this branch).

The PoC is intentionally conservative: every LLM-Suggestion stays
unverbindlich until a human clicks Adopt, and Adopt goes through the
existing normal CreateHazard/CreateMitigation flow (not yet wired in
this commit — separate iteration). The endpoint, modal and provenance
chain are in place for the next iteration to wire Adopt → write path.
This commit is contained in:
Benjamin Admin
2026-05-22 00:21:49 +02:00
parent 6263462ba3
commit 94233b7c66
7 changed files with 798 additions and 111 deletions
@@ -0,0 +1,218 @@
'use client'
// LLM Gap-Review Modal — Task #8.
//
// Triggers POST /projects/:id/llm-gap-review on mount and lists the
// LLM's gap suggestions with an Adopt / Reject UX. Adoption goes through
// the regular CreateHazard / CreateMitigation endpoints — the modal
// itself never mutates project state on its own.
import { useEffect, useState } from 'react'
type Suggestion = {
kind: 'hazard' | 'mitigation'
title: string
description: string
category?: string
hazard_ref?: string
pattern_ref?: string
norm_refs?: string[]
confidence?: 'high' | 'medium' | 'low'
rationale?: string
}
type Response = {
project_id: string
source: 'llm_gap_review' | 'fallback_static'
model?: string
suggestions: Suggestion[]
input_summary: {
hazard_count: number
mitigation_count: number
limits_form_fields: number
}
}
const CONF_COLOR: Record<string, string> = {
high: 'bg-emerald-100 text-emerald-800 border-emerald-200',
medium: 'bg-amber-100 text-amber-800 border-amber-200',
low: 'bg-slate-100 text-slate-600 border-slate-200',
}
interface Props {
projectId: string
onClose: () => void
onAdoptHazard?: (s: Suggestion) => Promise<void>
onAdoptMitigation?: (s: Suggestion) => Promise<void>
}
export function LLMGapReviewModal({ projectId, onClose, onAdoptHazard, onAdoptMitigation }: Props) {
const [data, setData] = useState<Response | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [adopted, setAdopted] = useState<Set<number>>(new Set())
const [rejected, setRejected] = useState<Set<number>>(new Set())
const [adopting, setAdopting] = useState<number | null>(null)
useEffect(() => {
setLoading(true)
fetch(`/api/sdk/v1/iace/projects/${projectId}/llm-gap-review`, { method: 'POST' })
.then((r) => (r.ok ? r.json() : Promise.reject(`HTTP ${r.status}`)))
.then(setData)
.catch((e) => setError(String(e)))
.finally(() => setLoading(false))
}, [projectId])
async function adopt(idx: number) {
if (!data) return
const s = data.suggestions[idx]
setAdopting(idx)
try {
if (s.kind === 'hazard' && onAdoptHazard) await onAdoptHazard(s)
else if (s.kind === 'mitigation' && onAdoptMitigation) await onAdoptMitigation(s)
setAdopted((prev) => new Set(prev).add(idx))
} catch (e) {
setError(`Adopt fehlgeschlagen: ${e}`)
} finally {
setAdopting(null)
}
}
function reject(idx: number) {
setRejected((prev) => new Set(prev).add(idx))
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-white rounded-xl shadow-2xl w-full max-w-3xl max-h-[90vh] overflow-hidden flex flex-col">
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between flex-shrink-0">
<div>
<h2 className="text-lg font-semibold text-gray-900">KI-Gap-Review</h2>
<p className="text-xs text-gray-500 mt-0.5">
LLM-gestuetzte Suche nach fehlenden Gefaehrdungen und Schutzmassnahmen Vorschlaege sind unverbindlich bis explizit uebernommen.
</p>
</div>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-2xl leading-none">&times;</button>
</div>
<div className="flex-1 overflow-y-auto p-6 space-y-3">
{loading && (
<div className="text-center py-12">
<div className="animate-spin rounded-full h-10 w-10 border-b-2 border-purple-600 mx-auto" />
<p className="text-sm text-gray-500 mt-3">LLM laeuft (Qwen/Claude). Das kann bis zu 30 Sekunden dauern.</p>
</div>
)}
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-sm text-red-700">
Fehler: {error}
</div>
)}
{data && (
<>
<div className="text-xs text-gray-500 flex items-center gap-3 border-b border-gray-100 pb-2">
<span>
Eingabe: {data.input_summary.hazard_count} Gefaehrdungen,{' '}
{data.input_summary.mitigation_count} Massnahmen, {data.input_summary.limits_form_fields} Grenzen-Felder
</span>
<span className="text-gray-300">·</span>
<span>
Quelle: {data.source === 'llm_gap_review'
? `LLM (${data.model ?? 'unbekannt'})`
: 'Statische Fallback-Liste'}
</span>
</div>
{data.suggestions.length === 0 && (
<div className="text-center text-gray-500 py-12 text-sm">
Keine Lueckenvorschlaege. Die deterministische Pattern-Engine hat vermutlich bereits alle Standard-Gefaehrdungen abgedeckt.
</div>
)}
{data.suggestions.map((s, i) => {
const isAdopted = adopted.has(i)
const isRejected = rejected.has(i)
const isWorking = adopting === i
return (
<div
key={i}
className={`border rounded-lg p-3 ${
isAdopted ? 'border-emerald-200 bg-emerald-50' :
isRejected ? 'border-slate-200 bg-slate-50 opacity-50' :
'border-gray-200 bg-white'
}`}
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap mb-1">
<span className={`px-1.5 py-0.5 text-[10px] rounded font-medium ${
s.kind === 'hazard' ? 'bg-red-100 text-red-700' : 'bg-blue-100 text-blue-700'
}`}>
{s.kind === 'hazard' ? 'Gefaehrdung' : 'Massnahme'}
</span>
{s.category && (
<span className="px-1.5 py-0.5 text-[10px] rounded bg-gray-100 text-gray-700">{s.category}</span>
)}
{s.confidence && (
<span className={`px-1.5 py-0.5 text-[10px] rounded border ${CONF_COLOR[s.confidence]}`}>
{s.confidence}
</span>
)}
{(s.norm_refs ?? []).map((n) => (
<span key={n} className="px-1.5 py-0.5 text-[10px] rounded bg-indigo-50 text-indigo-700 font-mono">{n}</span>
))}
{s.pattern_ref && (
<span className="px-1.5 py-0.5 text-[10px] rounded bg-purple-50 text-purple-700 font-mono">{s.pattern_ref}</span>
)}
</div>
<h3 className="text-sm font-semibold text-gray-900">{s.title}</h3>
<p className="text-xs text-gray-600 mt-1">{s.description}</p>
{s.hazard_ref && (
<p className="text-[11px] text-gray-500 mt-1">Bezogen auf: <em>{s.hazard_ref}</em></p>
)}
{s.rationale && (
<p className="text-[11px] text-gray-400 mt-1 italic">{s.rationale}</p>
)}
</div>
<div className="flex flex-col gap-1 flex-shrink-0">
{!isAdopted && !isRejected && (
<>
<button
onClick={() => adopt(i)}
disabled={isWorking}
className="px-3 py-1 text-xs bg-emerald-600 text-white rounded hover:bg-emerald-700 disabled:opacity-50"
>
{isWorking ? '…' : 'Uebernehmen'}
</button>
<button
onClick={() => reject(i)}
className="px-3 py-1 text-xs text-gray-600 border border-gray-300 rounded hover:bg-gray-50"
>
Verwerfen
</button>
</>
)}
{isAdopted && <span className="text-xs text-emerald-700 font-medium"> Uebernommen</span>}
{isRejected && <span className="text-xs text-gray-500">Verworfen</span>}
</div>
</div>
</div>
)
})}
</>
)}
</div>
<div className="px-6 py-3 border-t border-gray-200 bg-gray-50 flex items-center justify-between flex-shrink-0">
<p className="text-[11px] text-gray-500">
Hinweis: LLM-Vorschlaege sind NICHT die deterministische Engine-Output. Jede Uebernahme wird als <code>source=llm_gap_review</code> markiert.
</p>
<button onClick={onClose} className="px-3 py-1.5 text-sm border border-gray-300 rounded hover:bg-white">
Schliessen
</button>
</div>
</div>
</div>
)
}
export default LLMGapReviewModal
@@ -12,6 +12,7 @@ import type { ResidualFilter } from './_components/ResidualRiskPanel'
import { LibraryModal } from './_components/LibraryModal'
import { AutoSuggestPanel } from './_components/AutoSuggestPanel'
import { CustomHazardModal } from './_components/CustomHazardModal'
import { LLMGapReviewModal } from './_components/LLMGapReviewModal'
import { useHazards } from './_hooks/useHazards'
type ViewMode = 'list' | 'risk' | 'blocks'
@@ -22,6 +23,7 @@ export default function HazardsPage() {
const h = useHazards(projectId)
const [view, setView] = useState<ViewMode>('risk')
const [showCustomModal, setShowCustomModal] = useState(false)
const [showGapReview, setShowGapReview] = useState(false)
const [residualFilter, setResidualFilter] = useState<ResidualFilter>('all')
const [decisions, setDecisions] = useState<Record<string, boolean | null>>({})
@@ -104,6 +106,15 @@ export default function HazardsPage() {
</svg>
Eigene Gefaehrdung
</button>
<button
onClick={() => setShowGapReview(true)}
title="LLM (Qwen/Claude) prueft auf fehlende Gefaehrdungen und Massnahmen — Vorschlaege sind unverbindlich."
className="flex items-center gap-2 px-3 py-2 border border-indigo-300 text-indigo-700 rounded-lg hover:bg-indigo-50 transition-colors text-sm">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
KI-Gap-Review
</button>
<button onClick={() => h.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 text-sm">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@@ -170,6 +181,13 @@ export default function HazardsPage() {
onClose={() => setShowCustomModal(false)} />
)}
{showGapReview && (
<LLMGapReviewModal
projectId={projectId}
onClose={() => setShowGapReview(false)}
/>
)}
{h.hazards.length > 0 ? (
view === 'risk' ? (
<>
@@ -0,0 +1,288 @@
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
}
-111
View File
@@ -355,117 +355,6 @@ func registerWhistleblowerRoutes(v1 *gin.RouterGroup, h *handlers.WhistleblowerH
}
}
func registerIACERoutes(v1 *gin.RouterGroup, h *handlers.IACEHandler) {
iaceRoutes := v1.Group("/iace")
{
iaceRoutes.GET("/hazard-library", h.ListHazardLibrary)
iaceRoutes.GET("/controls-library", h.ListControlsLibrary)
iaceRoutes.GET("/norms-library", h.ListNormsLibrary)
iaceRoutes.GET("/lifecycle-phases", h.ListLifecyclePhases)
iaceRoutes.GET("/roles", h.ListRoles)
iaceRoutes.GET("/evidence-types", h.ListEvidenceTypes)
iaceRoutes.GET("/protective-measures-library", h.ListProtectiveMeasures)
iaceRoutes.GET("/failure-modes", h.ListFailureModes)
iaceRoutes.GET("/operational-states", h.ListOperationalStates)
iaceRoutes.GET("/component-library", h.ListComponentLibrary)
iaceRoutes.GET("/energy-sources", h.ListEnergySources)
iaceRoutes.GET("/tags", h.ListTags)
iaceRoutes.GET("/hazard-patterns", h.ListHazardPatterns)
iaceRoutes.POST("/projects", h.CreateProject)
iaceRoutes.GET("/projects", h.ListProjects)
iaceRoutes.GET("/projects/:id", h.GetProject)
iaceRoutes.PUT("/projects/:id", h.UpdateProject)
iaceRoutes.DELETE("/projects/:id", h.ArchiveProject)
iaceRoutes.POST("/projects/:id/init-from-profile", h.InitFromProfile)
iaceRoutes.POST("/projects/:id/variants", h.CreateVariant)
iaceRoutes.GET("/projects/:id/variants", h.ListVariants)
iaceRoutes.GET("/projects/:id/variant-gap", h.GetVariantGap)
iaceRoutes.POST("/projects/:id/completeness-check", h.CheckCompleteness)
iaceRoutes.POST("/projects/:id/components", h.CreateComponent)
iaceRoutes.GET("/projects/:id/components", h.ListComponents)
iaceRoutes.PUT("/projects/:id/components/:cid", h.UpdateComponent)
iaceRoutes.DELETE("/projects/:id/components/:cid", h.DeleteComponent)
iaceRoutes.POST("/projects/:id/classify", h.Classify)
iaceRoutes.GET("/projects/:id/classifications", h.GetClassifications)
iaceRoutes.POST("/projects/:id/classify/:regulation", h.ClassifySingle)
iaceRoutes.POST("/projects/:id/hazards", h.CreateHazard)
iaceRoutes.GET("/projects/:id/hazards", h.ListHazards)
iaceRoutes.PUT("/projects/:id/hazards/:hid", h.UpdateHazard)
iaceRoutes.POST("/projects/:id/hazards/suggest", h.SuggestHazards)
iaceRoutes.POST("/projects/:id/match-patterns", h.MatchPatterns)
iaceRoutes.POST("/projects/:id/parse-narrative", h.ParseNarrative)
iaceRoutes.POST("/projects/:id/delta-analysis", h.DeltaAnalysis)
iaceRoutes.GET("/projects/:id/fmea/export", h.ExportFMEA)
iaceRoutes.POST("/projects/:id/components/:cid/suggest-fms", h.SuggestFailureModes)
iaceRoutes.POST("/projects/:id/apply-patterns", h.ApplyPatternResults)
iaceRoutes.POST("/projects/:id/hazards/:hid/suggest-measures", h.SuggestMeasuresForHazard)
iaceRoutes.POST("/projects/:id/mitigations/:mid/suggest-evidence", h.SuggestEvidenceForMitigation)
iaceRoutes.POST("/projects/:id/hazards/:hid/assess", h.AssessRisk)
iaceRoutes.GET("/projects/:id/risk-summary", h.GetRiskSummary)
iaceRoutes.GET("/projects/:id/suggested-norms", h.SuggestProjectNorms)
iaceRoutes.POST("/projects/:id/hazards/:hid/reassess", h.ReassessRisk)
iaceRoutes.GET("/projects/:id/mitigations", h.ListProjectMitigations)
iaceRoutes.POST("/projects/:id/hazards/:hid/mitigations", h.CreateMitigation)
iaceRoutes.DELETE("/projects/:id/mitigations/:mid", h.DeleteMitigation)
iaceRoutes.PUT("/mitigations/:mid", h.UpdateMitigation)
iaceRoutes.POST("/mitigations/:mid/verify", h.VerifyMitigation)
iaceRoutes.POST("/projects/:id/validate-mitigation-hierarchy", h.ValidateMitigationHierarchy)
iaceRoutes.POST("/projects/:id/evidence", h.UploadEvidence)
iaceRoutes.GET("/projects/:id/evidence", h.ListEvidence)
iaceRoutes.POST("/projects/:id/verification-plan", h.CreateVerificationPlan)
iaceRoutes.PUT("/verification-plan/:vid", h.UpdateVerificationPlan)
iaceRoutes.POST("/verification-plan/:vid/complete", h.CompleteVerification)
iaceRoutes.GET("/projects/:id/verifications", h.ListVerificationPlans)
iaceRoutes.POST("/projects/:id/verifications", h.CreateVerificationAlias)
iaceRoutes.DELETE("/projects/:id/verifications/:vid", h.DeleteVerificationPlan)
iaceRoutes.POST("/projects/:id/verifications/:vid/complete", h.CompleteVerificationAlias)
iaceRoutes.POST("/projects/:id/tech-file/generate", h.GenerateTechFile)
iaceRoutes.GET("/projects/:id/tech-file", h.ListTechFileSections)
iaceRoutes.PUT("/projects/:id/tech-file/:section", h.UpdateTechFileSection)
iaceRoutes.POST("/projects/:id/tech-file/:section/approve", h.ApproveTechFileSection)
iaceRoutes.POST("/projects/:id/tech-file/:section/generate", h.GenerateSingleSection)
iaceRoutes.GET("/projects/:id/tech-file/export", h.ExportTechFile)
iaceRoutes.POST("/projects/:id/monitoring", h.CreateMonitoringEvent)
iaceRoutes.GET("/projects/:id/monitoring", h.ListMonitoringEvents)
iaceRoutes.PUT("/projects/:id/monitoring/:eid", h.UpdateMonitoringEvent)
iaceRoutes.GET("/projects/:id/audit-trail", h.GetAuditTrail)
iaceRoutes.POST("/library-search", h.SearchLibrary)
iaceRoutes.GET("/ce-corpus-documents", h.ListCECorpusDocuments)
iaceRoutes.POST("/projects/:id/initialize", h.InitializeProject)
iaceRoutes.GET("/projects/:id/hazard-blocks", h.GetHazardBlocks)
iaceRoutes.POST("/projects/:id/benchmark/import-gt", h.ImportGroundTruth)
iaceRoutes.GET("/projects/:id/benchmark", h.RunBenchmark)
iaceRoutes.GET("/projects/:id/benchmark/summary", h.GetBenchmarkSummary)
iaceRoutes.GET("/projects/:id/hazards/:hid/regulatory-hints", h.EnrichHazardWithRegulations)
iaceRoutes.GET("/projects/:id/mitigations/:mid/regulatory-hints", h.EnrichMitigationWithRegulations)
iaceRoutes.GET("/projects/:id/regulatory-hints", h.EnrichProjectHazardsBatch)
iaceRoutes.POST("/projects/:id/tech-file/:section/enrich", h.EnrichTechFileSection)
// Production Lines
iaceRoutes.POST("/production-lines", h.CreateProductionLine)
iaceRoutes.GET("/production-lines", h.ListProductionLines)
iaceRoutes.GET("/production-lines/:lid/dashboard", h.GetProductionLineDashboard)
iaceRoutes.POST("/production-lines/:lid/stations", h.AddStationToLine)
iaceRoutes.DELETE("/production-lines/:lid/stations/:sid", h.RemoveStationFromLine)
// CE x Compliance Crossover
iaceRoutes.GET("/projects/:id/compliance-triggers", h.GetComplianceTriggers)
iaceRoutes.GET("/compliance-faq", h.GetComplianceFAQ)
// Clarifications — aggregated open questions per project
iaceRoutes.GET("/projects/:id/clarifications", h.ListClarifications)
iaceRoutes.GET("/projects/:id/clarifications.csv", h.ExportClarificationsCSV)
iaceRoutes.GET("/projects/:id/clarifications.html", h.ExportClarificationsHTML)
iaceRoutes.GET("/projects/:id/clarifications/:cid/detail", h.ListClarificationDetail)
iaceRoutes.POST("/projects/:id/clarifications/:cid/answer", h.AnswerClarification)
iaceRoutes.POST("/projects/:id/clarifications/:cid/comment", h.PostClarificationComment)
// Customer-Standard Reuse (migration 031): pull reusable mitigations
// across prior projects of the same customer.
iaceRoutes.GET("/projects/:id/customer-standards", h.ListCustomerStandardSuggestions)
iaceRoutes.POST("/projects/:id/customer-standards/import", h.ImportCustomerStandardSuggestion)
}
}
func registerMaximizerRoutes(v1 *gin.RouterGroup, h *handlers.MaximizerHandlers) {
m := v1.Group("/maximizer")
@@ -0,0 +1,136 @@
package app
// IACE route registration extracted from routes.go (2026-05-21) because
// routes.go hit the 500-LOC hard cap when the LLM gap-review endpoint
// (Task #7) was added. Splitting keeps every routes file under the cap
// without changing behaviour — `registerRoutes` in routes.go still
// invokes `registerIACERoutes` exactly once at the same point in the
// startup sequence.
import (
"github.com/breakpilot/ai-compliance-sdk/internal/api/handlers"
"github.com/gin-gonic/gin"
)
func registerIACERoutes(v1 *gin.RouterGroup, h *handlers.IACEHandler) {
iaceRoutes := v1.Group("/iace")
{
// Library catalogues (read-only reference data).
iaceRoutes.GET("/hazard-library", h.ListHazardLibrary)
iaceRoutes.GET("/controls-library", h.ListControlsLibrary)
iaceRoutes.GET("/norms-library", h.ListNormsLibrary)
iaceRoutes.GET("/lifecycle-phases", h.ListLifecyclePhases)
iaceRoutes.GET("/roles", h.ListRoles)
iaceRoutes.GET("/evidence-types", h.ListEvidenceTypes)
iaceRoutes.GET("/protective-measures-library", h.ListProtectiveMeasures)
iaceRoutes.GET("/failure-modes", h.ListFailureModes)
iaceRoutes.GET("/operational-states", h.ListOperationalStates)
iaceRoutes.GET("/component-library", h.ListComponentLibrary)
iaceRoutes.GET("/energy-sources", h.ListEnergySources)
iaceRoutes.GET("/tags", h.ListTags)
iaceRoutes.GET("/hazard-patterns", h.ListHazardPatterns)
// Project CRUD.
iaceRoutes.POST("/projects", h.CreateProject)
iaceRoutes.GET("/projects", h.ListProjects)
iaceRoutes.GET("/projects/:id", h.GetProject)
iaceRoutes.PUT("/projects/:id", h.UpdateProject)
iaceRoutes.DELETE("/projects/:id", h.ArchiveProject)
iaceRoutes.POST("/projects/:id/init-from-profile", h.InitFromProfile)
iaceRoutes.POST("/projects/:id/variants", h.CreateVariant)
iaceRoutes.GET("/projects/:id/variants", h.ListVariants)
iaceRoutes.GET("/projects/:id/variant-gap", h.GetVariantGap)
iaceRoutes.POST("/projects/:id/completeness-check", h.CheckCompleteness)
// Components.
iaceRoutes.POST("/projects/:id/components", h.CreateComponent)
iaceRoutes.GET("/projects/:id/components", h.ListComponents)
iaceRoutes.PUT("/projects/:id/components/:cid", h.UpdateComponent)
iaceRoutes.DELETE("/projects/:id/components/:cid", h.DeleteComponent)
// Classification + hazards.
iaceRoutes.POST("/projects/:id/classify", h.Classify)
iaceRoutes.GET("/projects/:id/classifications", h.GetClassifications)
iaceRoutes.POST("/projects/:id/classify/:regulation", h.ClassifySingle)
iaceRoutes.POST("/projects/:id/hazards", h.CreateHazard)
iaceRoutes.GET("/projects/:id/hazards", h.ListHazards)
iaceRoutes.PUT("/projects/:id/hazards/:hid", h.UpdateHazard)
iaceRoutes.POST("/projects/:id/hazards/suggest", h.SuggestHazards)
iaceRoutes.POST("/projects/:id/match-patterns", h.MatchPatterns)
iaceRoutes.POST("/projects/:id/parse-narrative", h.ParseNarrative)
iaceRoutes.POST("/projects/:id/delta-analysis", h.DeltaAnalysis)
iaceRoutes.POST("/projects/:id/llm-gap-review", h.LLMGapReview)
iaceRoutes.GET("/projects/:id/fmea/export", h.ExportFMEA)
iaceRoutes.POST("/projects/:id/components/:cid/suggest-fms", h.SuggestFailureModes)
iaceRoutes.POST("/projects/:id/apply-patterns", h.ApplyPatternResults)
iaceRoutes.POST("/projects/:id/hazards/:hid/suggest-measures", h.SuggestMeasuresForHazard)
iaceRoutes.POST("/projects/:id/mitigations/:mid/suggest-evidence", h.SuggestEvidenceForMitigation)
iaceRoutes.POST("/projects/:id/hazards/:hid/assess", h.AssessRisk)
iaceRoutes.GET("/projects/:id/risk-summary", h.GetRiskSummary)
iaceRoutes.GET("/projects/:id/suggested-norms", h.SuggestProjectNorms)
iaceRoutes.POST("/projects/:id/hazards/:hid/reassess", h.ReassessRisk)
// Mitigations + evidence + verification.
iaceRoutes.GET("/projects/:id/mitigations", h.ListProjectMitigations)
iaceRoutes.POST("/projects/:id/hazards/:hid/mitigations", h.CreateMitigation)
iaceRoutes.DELETE("/projects/:id/mitigations/:mid", h.DeleteMitigation)
iaceRoutes.PUT("/mitigations/:mid", h.UpdateMitigation)
iaceRoutes.POST("/mitigations/:mid/verify", h.VerifyMitigation)
iaceRoutes.POST("/projects/:id/validate-mitigation-hierarchy", h.ValidateMitigationHierarchy)
iaceRoutes.POST("/projects/:id/evidence", h.UploadEvidence)
iaceRoutes.GET("/projects/:id/evidence", h.ListEvidence)
iaceRoutes.POST("/projects/:id/verification-plan", h.CreateVerificationPlan)
iaceRoutes.PUT("/verification-plan/:vid", h.UpdateVerificationPlan)
iaceRoutes.POST("/verification-plan/:vid/complete", h.CompleteVerification)
iaceRoutes.GET("/projects/:id/verifications", h.ListVerificationPlans)
iaceRoutes.POST("/projects/:id/verifications", h.CreateVerificationAlias)
iaceRoutes.DELETE("/projects/:id/verifications/:vid", h.DeleteVerificationPlan)
iaceRoutes.POST("/projects/:id/verifications/:vid/complete", h.CompleteVerificationAlias)
// Tech file + monitoring + audit.
iaceRoutes.POST("/projects/:id/tech-file/generate", h.GenerateTechFile)
iaceRoutes.GET("/projects/:id/tech-file", h.ListTechFileSections)
iaceRoutes.PUT("/projects/:id/tech-file/:section", h.UpdateTechFileSection)
iaceRoutes.POST("/projects/:id/tech-file/:section/approve", h.ApproveTechFileSection)
iaceRoutes.POST("/projects/:id/tech-file/:section/generate", h.GenerateSingleSection)
iaceRoutes.GET("/projects/:id/tech-file/export", h.ExportTechFile)
iaceRoutes.POST("/projects/:id/monitoring", h.CreateMonitoringEvent)
iaceRoutes.GET("/projects/:id/monitoring", h.ListMonitoringEvents)
iaceRoutes.PUT("/projects/:id/monitoring/:eid", h.UpdateMonitoringEvent)
iaceRoutes.GET("/projects/:id/audit-trail", h.GetAuditTrail)
// Library + corpus + benchmark.
iaceRoutes.POST("/library-search", h.SearchLibrary)
iaceRoutes.GET("/ce-corpus-documents", h.ListCECorpusDocuments)
iaceRoutes.POST("/projects/:id/initialize", h.InitializeProject)
iaceRoutes.GET("/projects/:id/hazard-blocks", h.GetHazardBlocks)
iaceRoutes.POST("/projects/:id/benchmark/import-gt", h.ImportGroundTruth)
iaceRoutes.GET("/projects/:id/benchmark", h.RunBenchmark)
iaceRoutes.GET("/projects/:id/benchmark/summary", h.GetBenchmarkSummary)
// Regulatory enrichment.
iaceRoutes.GET("/projects/:id/hazards/:hid/regulatory-hints", h.EnrichHazardWithRegulations)
iaceRoutes.GET("/projects/:id/mitigations/:mid/regulatory-hints", h.EnrichMitigationWithRegulations)
iaceRoutes.GET("/projects/:id/regulatory-hints", h.EnrichProjectHazardsBatch)
iaceRoutes.POST("/projects/:id/tech-file/:section/enrich", h.EnrichTechFileSection)
// Production lines.
iaceRoutes.POST("/production-lines", h.CreateProductionLine)
iaceRoutes.GET("/production-lines", h.ListProductionLines)
iaceRoutes.GET("/production-lines/:lid/dashboard", h.GetProductionLineDashboard)
iaceRoutes.POST("/production-lines/:lid/stations", h.AddStationToLine)
iaceRoutes.DELETE("/production-lines/:lid/stations/:sid", h.RemoveStationFromLine)
// CE x Compliance crossover + clarifications + customer standards.
iaceRoutes.GET("/projects/:id/compliance-triggers", h.GetComplianceTriggers)
iaceRoutes.GET("/compliance-faq", h.GetComplianceFAQ)
iaceRoutes.GET("/projects/:id/clarifications", h.ListClarifications)
iaceRoutes.GET("/projects/:id/clarifications.csv", h.ExportClarificationsCSV)
iaceRoutes.GET("/projects/:id/clarifications.html", h.ExportClarificationsHTML)
iaceRoutes.GET("/projects/:id/clarifications/:cid/detail", h.ListClarificationDetail)
iaceRoutes.POST("/projects/:id/clarifications/:cid/answer", h.AnswerClarification)
iaceRoutes.POST("/projects/:id/clarifications/:cid/comment", h.PostClarificationComment)
iaceRoutes.GET("/projects/:id/customer-standards", h.ListCustomerStandardSuggestions)
iaceRoutes.POST("/projects/:id/customer-standards/import", h.ImportCustomerStandardSuggestion)
}
}
@@ -81,6 +81,10 @@ func (e *DocumentExporter) ExportPDF(
e.pdfClassifications(pdf, classifications)
}
// --- Quellen & Lizenzen (Stufe 4 Attribution-Renderer, Task #29) ---
pdf.AddPage()
e.pdfSourcesAppendix(pdf, hazards, mitigations)
// --- Footer on every page ---
pdf.SetFooterFunc(func() {
pdf.SetY(-15)
@@ -0,0 +1,134 @@
package iace
// Sources & Licenses appendix for the IACE Tech-File PDF export.
// Stufe 4 of the Attribution Renderer (Task #29).
//
// The IACE engine generates hazards from BreakPilot Pattern-IDs that
// themselves cite ISO 12100, EN 13849, EN ISO 13855 etc. Those norm
// identifiers are R3 (DIN/EN copyright — identifier-only). The
// pattern-engine output itself is R3 (BreakPilot own work). OSHA values
// surfaced via the minimum-distance library are R1 (US Federal PD).
//
// This appendix aggregates what the Tech-File ACTUALLY cited and shows
// it grouped by license rule with the mandatory disclaimer that the
// per-export footer cannot be replaced by a pauschal Impressum-Hinweis.
import (
"sort"
"strings"
"github.com/jung-kurt/gofpdf"
)
// pdfSourcesAppendix renders the "Quellen & Lizenzen" appendix page.
// Called by ExportPDF after the regulatory classifications block.
func (e *DocumentExporter) pdfSourcesAppendix(pdf *gofpdf.Fpdf, hazards []Hazard, mitigations []Mitigation) {
pdf.SetFont("Helvetica", "B", 14)
pdf.SetTextColor(124, 58, 237)
pdf.CellFormat(0, 10, "Quellen und Lizenzen", "", 1, "L", false, 0, "")
pdf.Ln(2)
pdf.SetFont("Helvetica", "", 9)
pdf.SetTextColor(80, 80, 80)
intro := "Diese Risikobeurteilung verwendet die deterministische BreakPilot IACE " +
"Pattern-Engine sowie zitierte Sicherheitsnormen. Die folgende Aufstellung " +
"listet die konkret in diesem Dokument zitierten Quellen mit ihrer Lizenzregel."
pdf.MultiCell(0, 5, intro, "", "L", false)
pdf.Ln(3)
pdf.SetFont("Helvetica", "B", 10)
pdf.SetTextColor(0, 0, 0)
pdf.CellFormat(0, 7, "R3 — BreakPilot Pattern-Engine (Eigenwerk, Identifier-Verweis)", "", 1, "L", false, 0, "")
pdf.SetFont("Helvetica", "", 9)
pdf.SetTextColor(60, 60, 60)
pdf.MultiCell(0, 5,
"Alle in diesem Dokument referenzierten HP-XXXX-Identifier stammen aus der "+
"BreakPilot IACE Pattern-Library (Eigenwerk). Keine externe Lizenz-Attribution "+
"erforderlich.", "", "L", false)
pdf.Ln(3)
norms := extractCitedNorms(hazards, mitigations)
if len(norms) > 0 {
pdf.SetFont("Helvetica", "B", 10)
pdf.SetTextColor(0, 0, 0)
pdf.CellFormat(0, 7, "R3 — Sicherheitsnormen (DIN/EN/ISO/IEC, Identifier-Verweis)", "", 1, "L", false, 0, "")
pdf.SetFont("Helvetica", "", 9)
pdf.SetTextColor(60, 60, 60)
pdf.MultiCell(0, 5,
"DIN-/EN-/ISO-/IEC-Normen unterliegen dem Urheberrecht der jeweiligen "+
"Normungsorganisation. In diesem Dokument werden Normen ausschliesslich "+
"als Identifier (Norm-Nummer und Abschnitt) zitiert; kein Volltext aus "+
"diesen Normen wurde reproduziert. Konkret zitiert:", "", "L", false)
pdf.Ln(1)
for _, n := range norms {
pdf.CellFormat(0, 5, " • "+n, "", 1, "L", false, 0, "")
}
pdf.Ln(2)
}
pdf.SetFont("Helvetica", "B", 10)
pdf.SetTextColor(0, 0, 0)
pdf.CellFormat(0, 7, "R1 — Hoheitsrecht / Public Domain (woertlich uebernehmbar)", "", 1, "L", false, 0, "")
pdf.SetFont("Helvetica", "", 9)
pdf.SetTextColor(60, 60, 60)
pdf.MultiCell(0, 5,
"Soweit Werte aus US Federal Code (OSHA 29 CFR Subpart O) oder EU-Recht "+
"(Maschinenverordnung 2023/1230, AI Act 2024/1689) referenziert werden, "+
"sind diese als R1 woertlich uebernehmbar. Keine Attribution-Pflicht.", "", "L", false)
pdf.Ln(4)
pdf.SetFont("Helvetica", "I", 8)
pdf.SetTextColor(120, 120, 120)
pdf.MultiCell(0, 4,
"Hinweis: Pauschalvermerke in AGB oder Impressum reichen rechtlich nicht — "+
"die werknahe Attribution erfolgt durch diese Quellenseite. Vollstaendiges "+
"Quellenverzeichnis aller im BreakPilot-System verwendeten Quellen siehe "+
"/sdk/licenses im Web-Frontend.", "", "L", false)
}
// extractCitedNorms scans hazard descriptions + scenario fields for
// recognised norm identifiers. The detection is intentionally narrow:
// only well-known prefixes (EN/ISO/IEC/DIN) and only when followed by
// digits, so free-form prose is not turned into spurious citations.
func extractCitedNorms(hz []Hazard, mt []Mitigation) []string {
seen := make(map[string]bool)
consider := func(s string) {
fields := strings.FieldsFunc(s, func(r rune) bool {
return r == ' ' || r == ',' || r == ';' || r == '\n' || r == ';' || r == '('
})
for i := 0; i < len(fields)-1; i++ {
head := strings.ToUpper(strings.TrimSpace(fields[i]))
next := strings.TrimSpace(fields[i+1])
if !(head == "EN" || head == "ISO" || head == "IEC" || head == "DIN") {
continue
}
if next == "" {
continue
}
// Accept "ISO 12100", "EN 13849-1", "DIN EN 60204-1" etc.
if next[0] >= '0' && next[0] <= '9' {
seen[head+" "+next] = true
} else if head == "DIN" && (strings.HasPrefix(strings.ToUpper(next), "EN") || strings.HasPrefix(strings.ToUpper(next), "ISO")) && i+2 < len(fields) {
third := strings.TrimSpace(fields[i+2])
if third != "" && third[0] >= '0' && third[0] <= '9' {
seen[head+" "+next+" "+third] = true
}
}
}
}
for _, h := range hz {
consider(h.Description)
consider(h.Scenario)
consider(h.PossibleHarm)
}
for _, m := range mt {
consider(m.Description)
consider(m.Name)
}
out := make([]string, 0, len(seen))
for k := range seen {
out = append(out, k)
}
sort.Strings(out)
return out
}