c4be077c5d
[migration-approved]
Three pieces complete the Klaerungen lifecycle:
1. Migration 028: iace_clarifications + iace_clarification_comments +
iace_clarification_history. Deterministic clarification_key
(UNIQUE per project) so engine re-inits don't lose answers.
History table logs every status/answer transition. The previous
JSONB-in-metadata storage is kept as read-only fallback for
pre-migration projects until a one-shot upcopy script runs.
2. Multi-User-Workflow:
- assigned_to field on every clarification (free-text user kuerzel
for now; an FK to users can be added in a follow-up).
- Comment thread per clarification (POST .../comment, GET
.../detail returns the thread).
- Status-history log written by UpsertClarification when the
status or answer actually changes.
- Frontend Modal: Zugewiesen-an + Bearbeiter fields, comment
thread with inline post, collapsible history section.
3. PDF-Export via print-friendly HTML:
- GET /clarifications.html returns a standalone A4-styled
document with status badges, norm references, affected hazards
and a signature row at the bottom. The Bediener opens the link
and uses Strg-P / Cmd-P to save as PDF. No server-side PDF
dependency added.
- Frontend "PDF / Druck" button next to CSV export.
Backend:
- internal/iace/store_clarifications.go: UpsertClarification,
ListClarificationsForProject, GetClarificationByKey,
AddClarificationComment, ListClarificationComments,
ListClarificationHistory.
- internal/api/handlers/iace_handler_clarifications.go:
- AnswerClarification now writes the SQL row, falls back to legacy
JSONB read on list.
- PostClarificationComment, ListClarificationDetail,
ExportClarificationsHTML added.
Migration must be applied manually on Mac Mini and prod via
psql -f /migrations/028_iace_clarifications.sql — pattern as in
scripts/apply_*_migration.sh.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
244 lines
7.2 KiB
Go
244 lines
7.2 KiB
Go
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"`
|
|
AssignedTo string `json:"assigned_to,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"`
|
|
AssignedTo string `json:"assigned_to,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
|
|
b.AssignedTo = ans.AssignedTo
|
|
}
|
|
// 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
|
|
}
|