feat(iace): Klaerungen MVP — Phase 1
New page "Klaerungen" between Massnahmen and Verifikation.
Backend:
- internal/iace/clarifications.go: Clarification struct + ClarificationAnswer +
BuildProjectClarifications() — aggregates pattern-level + manufacturer-
level questions from collectAllPatterns + GetManufacturerSafetyFeatures.
Deterministic IDs ("pattern:HP1640:0", "manuf:fanuc:dual-check-safety-dcs:1")
so persisted answers survive every re-init.
- internal/api/handlers/iace_handler_clarifications.go:
- GET /projects/:id/clarifications returns aggregated list with affected
hazard names + persisted answer state, sorted (open first).
- POST /projects/:id/clarifications/:cid/answer writes status/answer/
reasoning/answered_by/answered_at to project.metadata.clarification_-
answers — no DB schema change.
Frontend:
- admin-compliance/app/sdk/iace/layout.tsx: new "Klaerungen" nav item.
- app/sdk/iace/[projectId]/clarifications/page.tsx: table grouped by
source (FANUC / Pattern HP1640 / …), Filter Offen/Beantwortet/Alle,
search field, Antwort-Modal with status/answer/Begruendung/Bearbeiter.
A clarification answered once applies to ALL referenced hazards — the
operator no longer has to answer the same FANUC DCS question on 48
mechanical hazards individually.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,316 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
|
||||
type Clarification = {
|
||||
id: string
|
||||
question: string
|
||||
source: string
|
||||
category: 'manufacturer' | 'pattern_norm' | string
|
||||
norm_references?: string[]
|
||||
affected_hazard_ids: string[]
|
||||
affected_hazard_names: string[]
|
||||
status: 'open' | 'in_progress' | 'answered' | 'not_relevant'
|
||||
answer?: 'ja' | 'nein' | 'teilweise' | ''
|
||||
reasoning?: string
|
||||
answered_by?: string
|
||||
answered_at?: string
|
||||
}
|
||||
|
||||
type ListResponse = {
|
||||
clarifications: Clarification[]
|
||||
open_count: number
|
||||
answered_count: number
|
||||
total: number
|
||||
}
|
||||
|
||||
const CATEGORY_LABEL: Record<string, string> = {
|
||||
manufacturer: 'Hersteller',
|
||||
pattern_norm: 'Norm / Pattern',
|
||||
}
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
open: 'Offen',
|
||||
in_progress: 'In Klärung',
|
||||
answered: 'Beantwortet',
|
||||
not_relevant: 'Nicht relevant',
|
||||
}
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
open: 'bg-orange-100 text-orange-800',
|
||||
in_progress: 'bg-yellow-100 text-yellow-800',
|
||||
answered: 'bg-green-100 text-green-800',
|
||||
not_relevant: 'bg-gray-100 text-gray-700',
|
||||
}
|
||||
|
||||
export default function ClarificationsPage() {
|
||||
const params = useParams()
|
||||
const projectId = params.projectId as string
|
||||
|
||||
const [data, setData] = useState<ListResponse | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [editing, setEditing] = useState<Clarification | null>(null)
|
||||
const [filter, setFilter] = useState<'all' | 'open' | 'answered'>('open')
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const r = await fetch(`/api/sdk/v1/iace/projects/${projectId}/clarifications`)
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`)
|
||||
const json: ListResponse = await r.json()
|
||||
setData(json)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [projectId])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
const filtered = (data?.clarifications ?? []).filter(c => {
|
||||
if (filter === 'open' && (c.status === 'answered' || c.status === 'not_relevant')) return false
|
||||
if (filter === 'answered' && c.status !== 'answered' && c.status !== 'not_relevant') return false
|
||||
if (searchQuery) {
|
||||
const q = searchQuery.toLowerCase()
|
||||
if (!c.question.toLowerCase().includes(q) && !c.source.toLowerCase().includes(q)) return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
const groupedBySource: Record<string, Clarification[]> = {}
|
||||
for (const c of filtered) {
|
||||
const key = c.source
|
||||
if (!groupedBySource[key]) groupedBySource[key] = []
|
||||
groupedBySource[key].push(c)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto">
|
||||
<div className="flex items-baseline justify-between mb-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Klärungen mit dem Anlagenbauer</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Standardisierte Prüffragen aus Norm- und Herstellerwissen. Eine Antwort gilt für alle referenzierten Gefährdungen.
|
||||
</p>
|
||||
</div>
|
||||
{data && (
|
||||
<div className="flex gap-2 text-sm">
|
||||
<Badge color="bg-orange-100 text-orange-800" label={`${data.open_count} offen`} />
|
||||
<Badge color="bg-green-100 text-green-800" label={`${data.answered_count} beantwortet`} />
|
||||
<Badge color="bg-gray-100 text-gray-700" label={`${data.total} gesamt`} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 mb-4 items-center">
|
||||
<div className="flex gap-1 text-sm">
|
||||
{(['open', 'answered', 'all'] as const).map(f => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setFilter(f)}
|
||||
className={`px-3 py-1.5 rounded ${filter === f ? 'bg-blue-600 text-white' : 'bg-gray-100 hover:bg-gray-200'}`}
|
||||
>
|
||||
{f === 'open' ? 'Offen' : f === 'answered' ? 'Beantwortet' : 'Alle'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Suchen in Frage oder Quelle..."
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
className="flex-1 max-w-sm border rounded px-3 py-1.5 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{loading && <div className="text-gray-500">Lade Klärungen…</div>}
|
||||
{error && <div className="text-red-600">Fehler: {error}</div>}
|
||||
|
||||
{!loading && data && Object.keys(groupedBySource).length === 0 && (
|
||||
<div className="text-gray-500 italic">Keine Klärungen für die aktuelle Auswahl.</div>
|
||||
)}
|
||||
|
||||
{!loading && data && Object.entries(groupedBySource).map(([source, items]) => (
|
||||
<div key={source} className="mb-6">
|
||||
<h2 className="text-sm font-semibold text-gray-700 mb-2 flex items-center gap-2">
|
||||
<span className="text-xs bg-blue-100 text-blue-800 px-2 py-0.5 rounded">
|
||||
{CATEGORY_LABEL[items[0].category] || items[0].category}
|
||||
</span>
|
||||
{source}
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{items.map(c => (
|
||||
<div key={c.id} className="border rounded-lg p-3 bg-white shadow-sm">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-gray-900">{c.question}</div>
|
||||
<div className="mt-1 text-xs text-gray-500">
|
||||
Betrifft <strong>{c.affected_hazard_ids.length}</strong> Gefährdung
|
||||
{c.affected_hazard_ids.length !== 1 ? 'en' : ''}
|
||||
{c.affected_hazard_names.length > 0 && (
|
||||
<span className="ml-1">— {c.affected_hazard_names.slice(0, 2).join('; ')}{c.affected_hazard_names.length > 2 ? `, +${c.affected_hazard_names.length - 2} weitere` : ''}</span>
|
||||
)}
|
||||
</div>
|
||||
{c.norm_references && c.norm_references.length > 0 && (
|
||||
<div className="mt-1 text-xs text-gray-500">
|
||||
Norm: {c.norm_references.join(' | ')}
|
||||
</div>
|
||||
)}
|
||||
{c.status === 'answered' && c.reasoning && (
|
||||
<div className="mt-2 text-xs text-gray-700 bg-green-50 border border-green-200 rounded p-2">
|
||||
<strong>Antwort ({c.answer}):</strong> {c.reasoning}
|
||||
{c.answered_by && (
|
||||
<span className="text-gray-500 ml-2">— {c.answered_by}, {c.answered_at?.slice(0, 10)}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2 text-xs">
|
||||
<span className={`px-2 py-0.5 rounded ${STATUS_COLOR[c.status]}`}>{STATUS_LABEL[c.status]}</span>
|
||||
<button
|
||||
onClick={() => setEditing(c)}
|
||||
className="px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700"
|
||||
>
|
||||
{c.status === 'answered' ? 'Bearbeiten' : 'Beantworten'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{editing && (
|
||||
<AnswerModal
|
||||
clarification={editing}
|
||||
projectId={projectId}
|
||||
onClose={() => setEditing(null)}
|
||||
onSaved={() => {
|
||||
setEditing(null)
|
||||
load()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Badge({ color, label }: { color: string; label: string }) {
|
||||
return <span className={`px-2 py-0.5 rounded text-xs ${color}`}>{label}</span>
|
||||
}
|
||||
|
||||
function AnswerModal({
|
||||
clarification,
|
||||
projectId,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
clarification: Clarification
|
||||
projectId: string
|
||||
onClose: () => void
|
||||
onSaved: () => void
|
||||
}) {
|
||||
const [status, setStatus] = useState(clarification.status)
|
||||
const [answer, setAnswer] = useState<'ja' | 'nein' | 'teilweise' | ''>(
|
||||
(clarification.answer as 'ja' | 'nein' | 'teilweise' | '') || ''
|
||||
)
|
||||
const [reasoning, setReasoning] = useState(clarification.reasoning || '')
|
||||
const [answeredBy, setAnsweredBy] = useState(clarification.answered_by || '')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const r = await fetch(
|
||||
`/api/sdk/v1/iace/projects/${projectId}/clarifications/${encodeURIComponent(clarification.id)}/answer`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status, answer, reasoning, answered_by: answeredBy }),
|
||||
}
|
||||
)
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`)
|
||||
onSaved()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-black/40 flex items-center justify-center p-4" onClick={onClose}>
|
||||
<div className="bg-white rounded-lg max-w-xl w-full p-5 shadow-xl" onClick={e => e.stopPropagation()}>
|
||||
<div className="text-sm text-gray-500 mb-1">{clarification.source}</div>
|
||||
<div className="text-base font-medium mb-4">{clarification.question}</div>
|
||||
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">Status</label>
|
||||
<div className="flex gap-1 mb-3 text-sm">
|
||||
{(['open', 'in_progress', 'answered', 'not_relevant'] as const).map(s => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setStatus(s)}
|
||||
className={`px-3 py-1 rounded border ${status === s ? 'bg-blue-600 text-white border-blue-600' : 'bg-white hover:bg-gray-50'}`}
|
||||
>
|
||||
{STATUS_LABEL[s]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{(status === 'answered' || status === 'in_progress') && (
|
||||
<>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">Antwort</label>
|
||||
<div className="flex gap-1 mb-3 text-sm">
|
||||
{(['ja', 'teilweise', 'nein'] as const).map(a => (
|
||||
<button
|
||||
key={a}
|
||||
onClick={() => setAnswer(a)}
|
||||
className={`px-3 py-1 rounded border ${answer === a ? 'bg-blue-600 text-white border-blue-600' : 'bg-white hover:bg-gray-50'}`}
|
||||
>
|
||||
{a}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">Begründung / Notiz</label>
|
||||
<textarea
|
||||
value={reasoning}
|
||||
onChange={e => setReasoning(e.target.value)}
|
||||
rows={4}
|
||||
className="w-full border rounded p-2 text-sm mb-3"
|
||||
placeholder="z.B. Pruefprotokoll vom 12.03.2024 vom Anlagenbauer FANUC vorgelegt; DCS-Konfig liegt bei."
|
||||
/>
|
||||
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">Bearbeiter</label>
|
||||
<input
|
||||
type="text"
|
||||
value={answeredBy}
|
||||
onChange={e => setAnsweredBy(e.target.value)}
|
||||
className="w-full border rounded p-2 text-sm mb-4"
|
||||
placeholder="Name oder Kürzel"
|
||||
/>
|
||||
|
||||
{error && <div className="text-red-600 text-sm mb-2">Fehler: {error}</div>}
|
||||
|
||||
<div className="flex justify-end gap-2 text-sm">
|
||||
<button onClick={onClose} className="px-3 py-1.5 rounded border bg-white hover:bg-gray-50">Abbrechen</button>
|
||||
<button onClick={save} disabled={saving} className="px-3 py-1.5 rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50">
|
||||
{saving ? 'Speichere…' : 'Speichern'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -14,6 +14,7 @@ const IACE_NAV_ITEMS = [
|
||||
{ id: 'components', label: 'Komponenten', href: '/components', icon: 'cube' },
|
||||
{ id: 'hazards', label: 'Hazard Log', href: '/hazards', icon: 'warning' },
|
||||
{ id: 'mitigations', label: 'Massnahmen', href: '/mitigations', icon: 'shield' },
|
||||
{ id: 'clarifications', label: 'Klärungen', href: '/clarifications', icon: 'chat' },
|
||||
{ id: 'verification', label: 'Verifikation', href: '/verification', icon: 'check' },
|
||||
{ id: 'evidence', label: 'Nachweise', href: '/evidence', icon: 'document' },
|
||||
{ id: 'tech-file', label: 'CE-Akte', href: '/tech-file', icon: 'folder' },
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/iace"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// projectMetadataRoot is the shape we store inside iace_projects.metadata.
|
||||
// We only own the "clarification_answers" key; everything else is preserved
|
||||
// as opaque JSON so we don't trample on existing fields (limits_form, etc).
|
||||
type projectMetadataRoot map[string]json.RawMessage
|
||||
|
||||
const clarificationAnswersKey = "clarification_answers"
|
||||
|
||||
// readClarificationAnswers parses project.metadata and returns the
|
||||
// clarification_answers map. Missing/empty metadata yields an empty map.
|
||||
func readClarificationAnswers(meta json.RawMessage) (map[string]iace.ClarificationAnswer, projectMetadataRoot) {
|
||||
root := projectMetadataRoot{}
|
||||
if len(meta) > 0 {
|
||||
_ = json.Unmarshal(meta, &root)
|
||||
}
|
||||
answers := map[string]iace.ClarificationAnswer{}
|
||||
if raw, ok := root[clarificationAnswersKey]; ok && len(raw) > 0 {
|
||||
_ = json.Unmarshal(raw, &answers)
|
||||
}
|
||||
return answers, root
|
||||
}
|
||||
|
||||
// reconstructHazardPatterns re-runs the pattern engine for the project's
|
||||
// narrative so we can map each hazard back to the patterns that fired for
|
||||
// it. The Hazard table itself doesn't persist the source-pattern list, so
|
||||
// this is the only way to know "which clarifications apply to which hazard".
|
||||
func (h *IACEHandler) reconstructHazardPatterns(narrative string, machineType string, hazards []iace.Hazard) map[uuid.UUID][]string {
|
||||
parsed := iace.ParseNarrative(narrative, machineType)
|
||||
compIDs := make([]string, 0, len(parsed.Components))
|
||||
for _, c := range parsed.Components {
|
||||
compIDs = append(compIDs, c.LibraryID)
|
||||
}
|
||||
energyIDs := make([]string, 0, len(parsed.EnergySources))
|
||||
for _, e := range parsed.EnergySources {
|
||||
energyIDs = append(energyIDs, e.SourceID)
|
||||
}
|
||||
engine := iace.NewPatternEngine()
|
||||
out := engine.Match(iace.MatchInput{
|
||||
ComponentLibraryIDs: compIDs,
|
||||
EnergySourceIDs: energyIDs,
|
||||
LifecyclePhases: parsed.LifecyclePhases,
|
||||
CustomTags: parsed.CustomTags,
|
||||
OperationalStates: parsed.OperationalStates,
|
||||
StateTransitions: parsed.StateTransitions,
|
||||
HumanRoles: parsed.Roles,
|
||||
MachineTypes: []string{machineType},
|
||||
})
|
||||
|
||||
// Map hazard.HazardousZone → set of HP-IDs by substring-matching the
|
||||
// pattern's ZoneDE. The hazard table doesn't keep a back-pointer to
|
||||
// the source pattern, so this approximation re-runs pattern matching
|
||||
// against the narrative and matches by normalised zone.
|
||||
hazardToPatterns := map[uuid.UUID][]string{}
|
||||
for _, hz := range hazards {
|
||||
hzZone := normalizeKey(hz.HazardousZone)
|
||||
if hzZone == "" {
|
||||
continue
|
||||
}
|
||||
for _, m := range out.MatchedPatterns {
|
||||
pz := normalizeKey(m.ZoneDE)
|
||||
if pz == "" {
|
||||
continue
|
||||
}
|
||||
if pz == hzZone || containsSubstring(hzZone, pz) || containsSubstring(pz, hzZone) {
|
||||
hazardToPatterns[hz.ID] = appendUnique(hazardToPatterns[hz.ID], m.PatternID)
|
||||
}
|
||||
}
|
||||
}
|
||||
return hazardToPatterns
|
||||
}
|
||||
|
||||
func normalizeKey(s string) string {
|
||||
s = iace.NormalizeDEPublic(s)
|
||||
out := []rune{}
|
||||
for _, r := range s {
|
||||
switch r {
|
||||
case ',', '/', '(', ')', '-', '.', ':', ';':
|
||||
out = append(out, ' ')
|
||||
default:
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func appendUnique(slice []string, s string) []string {
|
||||
for _, x := range slice {
|
||||
if x == s {
|
||||
return slice
|
||||
}
|
||||
}
|
||||
return append(slice, s)
|
||||
}
|
||||
|
||||
// ListClarifications handles GET /projects/:id/clarifications.
|
||||
// Returns the aggregated clarification list with affected-hazard cross-refs
|
||||
// and the persisted answer state.
|
||||
func (h *IACEHandler) ListClarifications(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 || project == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "project not found"})
|
||||
return
|
||||
}
|
||||
hazards, _ := h.store.ListHazards(ctx, projectID)
|
||||
|
||||
answers, _ := readClarificationAnswers(project.Metadata)
|
||||
narrative := extractNarrativeFromMetadata(project.Metadata)
|
||||
hazardToPatterns := h.reconstructHazardPatterns(narrative, project.MachineType, hazards)
|
||||
manufHits := iace.LookupManufacturerFeaturesInText(narrative)
|
||||
|
||||
clarifications := iace.BuildProjectClarifications(hazards, hazardToPatterns, manufHits, answers)
|
||||
sort.Slice(clarifications, func(i, j int) bool {
|
||||
// Open first, then answered. Within a status, group by category, then by source.
|
||||
if clarifications[i].Status != clarifications[j].Status {
|
||||
return clarifications[i].Status == "open"
|
||||
}
|
||||
if clarifications[i].Category != clarifications[j].Category {
|
||||
return clarifications[i].Category < clarifications[j].Category
|
||||
}
|
||||
return clarifications[i].Source < clarifications[j].Source
|
||||
})
|
||||
|
||||
openCount, answeredCount := 0, 0
|
||||
for _, cl := range clarifications {
|
||||
switch cl.Status {
|
||||
case "answered", "not_relevant":
|
||||
answeredCount++
|
||||
default:
|
||||
openCount++
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"clarifications": clarifications,
|
||||
"open_count": openCount,
|
||||
"answered_count": answeredCount,
|
||||
"total": len(clarifications),
|
||||
})
|
||||
}
|
||||
|
||||
// AnswerClarificationRequest is the request body for POST .../answer.
|
||||
type AnswerClarificationRequest struct {
|
||||
Status string `json:"status"` // open | in_progress | answered | not_relevant
|
||||
Answer string `json:"answer"` // ja | nein | teilweise
|
||||
Reasoning string `json:"reasoning"`
|
||||
AnsweredBy string `json:"answered_by"`
|
||||
}
|
||||
|
||||
// AnswerClarification handles POST /projects/:id/clarifications/:cid/answer.
|
||||
// Stores the answer in project.metadata.clarification_answers — no schema
|
||||
// change required.
|
||||
func (h *IACEHandler) AnswerClarification(c *gin.Context) {
|
||||
projectID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"})
|
||||
return
|
||||
}
|
||||
cid := c.Param("cid")
|
||||
if cid == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "missing clarification id"})
|
||||
return
|
||||
}
|
||||
var req AnswerClarificationRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if req.Status == "" {
|
||||
if req.Answer != "" {
|
||||
req.Status = "answered"
|
||||
} else {
|
||||
req.Status = "open"
|
||||
}
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
project, err := h.store.GetProject(ctx, projectID)
|
||||
if err != nil || project == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "project not found"})
|
||||
return
|
||||
}
|
||||
answers, root := readClarificationAnswers(project.Metadata)
|
||||
answers[cid] = iace.ClarificationAnswer{
|
||||
Status: req.Status,
|
||||
Answer: req.Answer,
|
||||
Reasoning: req.Reasoning,
|
||||
AnsweredBy: req.AnsweredBy,
|
||||
AnsweredAt: time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
answersJSON, _ := json.Marshal(answers)
|
||||
root[clarificationAnswersKey] = answersJSON
|
||||
merged, _ := json.Marshal(root)
|
||||
if err := h.store.UpdateProjectMetadata(ctx, projectID, merged); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"clarification_id": cid,
|
||||
"answer": answers[cid],
|
||||
})
|
||||
}
|
||||
@@ -451,6 +451,10 @@ func registerIACERoutes(v1 *gin.RouterGroup, h *handlers.IACEHandler) {
|
||||
// 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.POST("/projects/:id/clarifications/:cid/answer", h.AnswerClarification)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
package iace
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Clarification represents an aggregated open question that the operator
|
||||
// must verify with the Anlagenbauer. The engine NEVER generates commentary
|
||||
// — it only surfaces norm-/manufacturer-derived check items that can be
|
||||
// objectively answered.
|
||||
//
|
||||
// IDs are deterministic so existing answers survive every project re-init:
|
||||
// - pattern:<HP-ID>:<index> — question is hard-coded on a HazardPattern
|
||||
// - manuf:<Manufacturer>:<index> — question comes from the manufacturer library
|
||||
//
|
||||
// "AffectedHazardIDs" / "AffectedMitigationIDs" are filled at request time
|
||||
// from the project's current hazards. They tell the UI which entries in the
|
||||
// hazard list will be marked "geklaert" once this clarification is answered.
|
||||
type Clarification struct {
|
||||
ID string `json:"id"`
|
||||
Question string `json:"question"`
|
||||
Source string `json:"source"` // "FANUC (Dual Check Safety)", "Pattern HP1640", ...
|
||||
Category string `json:"category"` // "manufacturer" | "pattern_norm"
|
||||
NormReferences []string `json:"norm_references,omitempty"`
|
||||
AffectedHazardIDs []uuid.UUID `json:"affected_hazard_ids"`
|
||||
AffectedHazardNames []string `json:"affected_hazard_names"` // shown directly in the table
|
||||
AffectedMitigationIDs []uuid.UUID `json:"affected_mitigation_ids,omitempty"`
|
||||
// State (merged from project.metadata.clarification_answers)
|
||||
Status string `json:"status"` // "open" | "in_progress" | "answered" | "not_relevant"
|
||||
Answer string `json:"answer,omitempty"` // "ja" | "nein" | "teilweise"
|
||||
Reasoning string `json:"reasoning,omitempty"`
|
||||
AnsweredBy string `json:"answered_by,omitempty"`
|
||||
AnsweredAt string `json:"answered_at,omitempty"`
|
||||
}
|
||||
|
||||
// ClarificationAnswer is the persisted shape (one entry in
|
||||
// project.metadata.clarification_answers[<clarification.id>]).
|
||||
type ClarificationAnswer struct {
|
||||
Status string `json:"status"`
|
||||
Answer string `json:"answer,omitempty"`
|
||||
Reasoning string `json:"reasoning,omitempty"`
|
||||
AnsweredBy string `json:"answered_by,omitempty"`
|
||||
AnsweredAt string `json:"answered_at,omitempty"`
|
||||
}
|
||||
|
||||
// BuildProjectClarifications walks the project's current hazards and returns
|
||||
// the deduplicated list of clarification questions that apply, with each
|
||||
// hazard correctly cross-referenced.
|
||||
//
|
||||
// Inputs are resolved upstream so this function stays free of DB access and
|
||||
// is unit-testable:
|
||||
// - hazards: the project's persisted hazards (Name, ID, Category)
|
||||
// - hazardSourcePatterns: per hazard, the HP-IDs that fired for it (today
|
||||
// we don't have a clean back-reference, so the handler does a name+zone
|
||||
// re-match against patterns)
|
||||
// - manufacturerHits: ManufacturerSafetyFeature entries whose aliases were
|
||||
// found in the project narrative
|
||||
// - answers: map[clarificationID]ClarificationAnswer from project.metadata
|
||||
func BuildProjectClarifications(
|
||||
hazards []Hazard,
|
||||
hazardSourcePatterns map[uuid.UUID][]string,
|
||||
manufacturerHits []ManufacturerSafetyFeature,
|
||||
answers map[string]ClarificationAnswer,
|
||||
) []Clarification {
|
||||
// Lookup helpers
|
||||
patternByID := make(map[string]HazardPattern)
|
||||
for _, p := range collectAllPatterns() {
|
||||
patternByID[p.ID] = p
|
||||
}
|
||||
|
||||
// Bucket by clarification ID so we accumulate affected hazards
|
||||
buckets := make(map[string]*Clarification)
|
||||
|
||||
// 1) Pattern-level clarifications
|
||||
for hzID, hpIDs := range hazardSourcePatterns {
|
||||
hz := findHazard(hazards, hzID)
|
||||
if hz == nil {
|
||||
continue
|
||||
}
|
||||
for _, hpID := range hpIDs {
|
||||
p, ok := patternByID[hpID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for i, q := range p.ClarificationQuestionsDE {
|
||||
cid := "pattern:" + hpID + ":" + intStr(i)
|
||||
b, exists := buckets[cid]
|
||||
if !exists {
|
||||
b = &Clarification{
|
||||
ID: cid,
|
||||
Question: q,
|
||||
Source: "Pattern " + hpID + " — " + p.NameDE,
|
||||
Category: "pattern_norm",
|
||||
Status: "open",
|
||||
}
|
||||
buckets[cid] = b
|
||||
}
|
||||
b.AffectedHazardIDs = append(b.AffectedHazardIDs, hz.ID)
|
||||
b.AffectedHazardNames = appendUniqueString(b.AffectedHazardNames, hz.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Manufacturer-level clarifications — apply to every hazard whose
|
||||
// category matches the manufacturer entry's AppliesToHazardCats
|
||||
for _, mf := range manufacturerHits {
|
||||
applicable := func(cat string) bool {
|
||||
if len(mf.AppliesToHazardCats) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, c := range mf.AppliesToHazardCats {
|
||||
if c == cat {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
for i, q := range mf.Clarifications {
|
||||
cid := "manuf:" + slug(mf.Manufacturer) + ":" + slug(mf.FeatureName) + ":" + intStr(i)
|
||||
b, exists := buckets[cid]
|
||||
if !exists {
|
||||
b = &Clarification{
|
||||
ID: cid,
|
||||
Question: q,
|
||||
Source: mf.Manufacturer + " — " + mf.FeatureName,
|
||||
Category: "manufacturer",
|
||||
NormReferences: mf.NormReferences,
|
||||
Status: "open",
|
||||
}
|
||||
buckets[cid] = b
|
||||
}
|
||||
for _, hz := range hazards {
|
||||
if !applicable(hz.Category) {
|
||||
continue
|
||||
}
|
||||
b.AffectedHazardIDs = append(b.AffectedHazardIDs, hz.ID)
|
||||
b.AffectedHazardNames = appendUniqueString(b.AffectedHazardNames, hz.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Merge persisted answers
|
||||
out := make([]Clarification, 0, len(buckets))
|
||||
for cid, b := range buckets {
|
||||
if ans, ok := answers[cid]; ok {
|
||||
if ans.Status != "" {
|
||||
b.Status = ans.Status
|
||||
} else if ans.Answer != "" {
|
||||
b.Status = "answered"
|
||||
}
|
||||
b.Answer = ans.Answer
|
||||
b.Reasoning = ans.Reasoning
|
||||
b.AnsweredBy = ans.AnsweredBy
|
||||
b.AnsweredAt = ans.AnsweredAt
|
||||
}
|
||||
// dedup hazard IDs (multiple patterns can target the same hazard)
|
||||
b.AffectedHazardIDs = dedupUUIDs(b.AffectedHazardIDs)
|
||||
out = append(out, *b)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func findHazard(hazards []Hazard, id uuid.UUID) *Hazard {
|
||||
for i := range hazards {
|
||||
if hazards[i].ID == id {
|
||||
return &hazards[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func appendUniqueString(slice []string, s string) []string {
|
||||
for _, x := range slice {
|
||||
if x == s {
|
||||
return slice
|
||||
}
|
||||
}
|
||||
return append(slice, s)
|
||||
}
|
||||
|
||||
func dedupUUIDs(ids []uuid.UUID) []uuid.UUID {
|
||||
seen := make(map[uuid.UUID]bool, len(ids))
|
||||
out := make([]uuid.UUID, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if !seen[id] {
|
||||
seen[id] = true
|
||||
out = append(out, id)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func intStr(i int) string {
|
||||
if i == 0 {
|
||||
return "0"
|
||||
}
|
||||
neg := false
|
||||
if i < 0 {
|
||||
neg = true
|
||||
i = -i
|
||||
}
|
||||
var buf [20]byte
|
||||
pos := len(buf)
|
||||
for i > 0 {
|
||||
pos--
|
||||
buf[pos] = byte('0' + i%10)
|
||||
i /= 10
|
||||
}
|
||||
if neg {
|
||||
pos--
|
||||
buf[pos] = '-'
|
||||
}
|
||||
return string(buf[pos:])
|
||||
}
|
||||
|
||||
// slug lowercases and replaces non-[a-z0-9] with "-" so the manufacturer name
|
||||
// and feature name can be embedded in a stable clarification ID.
|
||||
func slug(s string) string {
|
||||
s = normalizeForMatch(s) // already lower + umlaut-folded
|
||||
var b strings.Builder
|
||||
prevDash := false
|
||||
for _, r := range s {
|
||||
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
|
||||
b.WriteRune(r)
|
||||
prevDash = false
|
||||
} else {
|
||||
if !prevDash && b.Len() > 0 {
|
||||
b.WriteRune('-')
|
||||
prevDash = true
|
||||
}
|
||||
}
|
||||
}
|
||||
out := b.String()
|
||||
if strings.HasSuffix(out, "-") {
|
||||
out = out[:len(out)-1]
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user