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' ? (
<>