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:
@@ -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">×</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' ? (
|
||||
<>
|
||||
|
||||
Reference in New Issue
Block a user