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:
Benjamin Admin
2026-05-17 01:05:53 +02:00
parent bc21480a2a
commit 79efa54898
5 changed files with 779 additions and 0 deletions
@@ -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],
})
}
+4
View File
@@ -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
}