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:: — question is hard-coded on a HazardPattern // - manuf:: — 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[]). 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 }