From 94233b7c666c750176499e8bcdc5f8d049aedf10 Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Fri, 22 May 2026 00:21:49 +0200 Subject: [PATCH] feat(iace): LLM gap-review (Task #7+#8) + tech-file sources appendix (#29) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 + 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. --- .../hazards/_components/LLMGapReviewModal.tsx | 218 +++++++++++++ .../app/sdk/iace/[projectId]/hazards/page.tsx | 18 ++ .../api/handlers/iace_handler_gap_review.go | 288 ++++++++++++++++++ ai-compliance-sdk/internal/app/routes.go | 111 ------- ai-compliance-sdk/internal/app/routes_iace.go | 136 +++++++++ .../internal/iace/document_export.go | 4 + .../internal/iace/document_export_sources.go | 134 ++++++++ 7 files changed, 798 insertions(+), 111 deletions(-) create mode 100644 admin-compliance/app/sdk/iace/[projectId]/hazards/_components/LLMGapReviewModal.tsx create mode 100644 ai-compliance-sdk/internal/api/handlers/iace_handler_gap_review.go create mode 100644 ai-compliance-sdk/internal/app/routes_iace.go create mode 100644 ai-compliance-sdk/internal/iace/document_export_sources.go diff --git a/admin-compliance/app/sdk/iace/[projectId]/hazards/_components/LLMGapReviewModal.tsx b/admin-compliance/app/sdk/iace/[projectId]/hazards/_components/LLMGapReviewModal.tsx new file mode 100644 index 00000000..669db5bf --- /dev/null +++ b/admin-compliance/app/sdk/iace/[projectId]/hazards/_components/LLMGapReviewModal.tsx @@ -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 = { + 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 + onAdoptMitigation?: (s: Suggestion) => Promise +} + +export function LLMGapReviewModal({ projectId, onClose, onAdoptHazard, onAdoptMitigation }: Props) { + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [adopted, setAdopted] = useState>(new Set()) + const [rejected, setRejected] = useState>(new Set()) + const [adopting, setAdopting] = useState(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 ( +
+
+
+
+

KI-Gap-Review

+

+ LLM-gestuetzte Suche nach fehlenden Gefaehrdungen und Schutzmassnahmen — Vorschlaege sind unverbindlich bis explizit uebernommen. +

+
+ +
+ +
+ {loading && ( +
+
+

LLM laeuft (Qwen/Claude). Das kann bis zu 30 Sekunden dauern.

+
+ )} + {error && ( +
+ Fehler: {error} +
+ )} + {data && ( + <> +
+ + Eingabe: {data.input_summary.hazard_count} Gefaehrdungen,{' '} + {data.input_summary.mitigation_count} Massnahmen, {data.input_summary.limits_form_fields} Grenzen-Felder + + · + + Quelle: {data.source === 'llm_gap_review' + ? `LLM (${data.model ?? 'unbekannt'})` + : 'Statische Fallback-Liste'} + +
+ + {data.suggestions.length === 0 && ( +
+ Keine Lueckenvorschlaege. Die deterministische Pattern-Engine hat vermutlich bereits alle Standard-Gefaehrdungen abgedeckt. +
+ )} + + {data.suggestions.map((s, i) => { + const isAdopted = adopted.has(i) + const isRejected = rejected.has(i) + const isWorking = adopting === i + return ( +
+
+
+
+ + {s.kind === 'hazard' ? 'Gefaehrdung' : 'Massnahme'} + + {s.category && ( + {s.category} + )} + {s.confidence && ( + + {s.confidence} + + )} + {(s.norm_refs ?? []).map((n) => ( + {n} + ))} + {s.pattern_ref && ( + {s.pattern_ref} + )} +
+

{s.title}

+

{s.description}

+ {s.hazard_ref && ( +

Bezogen auf: {s.hazard_ref}

+ )} + {s.rationale && ( +

{s.rationale}

+ )} +
+
+ {!isAdopted && !isRejected && ( + <> + + + + )} + {isAdopted && ✓ Uebernommen} + {isRejected && Verworfen} +
+
+
+ ) + })} + + )} +
+ +
+

+ Hinweis: LLM-Vorschlaege sind NICHT die deterministische Engine-Output. Jede Uebernahme wird als source=llm_gap_review markiert. +

+ +
+
+
+ ) +} + +export default LLMGapReviewModal diff --git a/admin-compliance/app/sdk/iace/[projectId]/hazards/page.tsx b/admin-compliance/app/sdk/iace/[projectId]/hazards/page.tsx index 8bac01bb..5c98d1de 100644 --- a/admin-compliance/app/sdk/iace/[projectId]/hazards/page.tsx +++ b/admin-compliance/app/sdk/iace/[projectId]/hazards/page.tsx @@ -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('risk') const [showCustomModal, setShowCustomModal] = useState(false) + const [showGapReview, setShowGapReview] = useState(false) const [residualFilter, setResidualFilter] = useState('all') const [decisions, setDecisions] = useState>({}) @@ -104,6 +106,15 @@ export default function HazardsPage() { Eigene Gefaehrdung +