package handlers import ( "encoding/csv" "encoding/json" "fmt" "net/http" "sort" "strings" "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], }) } // ExportClarificationsCSV handles GET /projects/:id/clarifications.csv. // Returns the aggregated clarifications as a CSV for handover to the // Anlagenbauer — one row per question with all referenced hazards and // the current answer state. func (h *IACEHandler) ExportClarificationsCSV(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 { if clarifications[i].Status != clarifications[j].Status { return clarifications[i].Status == "open" } return clarifications[i].Source < clarifications[j].Source }) filename := fmt.Sprintf("klaerungen_%s_%s.csv", project.MachineName, time.Now().Format("2006-01-02")) filename = strings.ReplaceAll(filename, " ", "_") c.Header("Content-Type", "text/csv; charset=utf-8") c.Header("Content-Disposition", `attachment; filename="`+filename+`"`) // Excel-Erkennung: UTF-8 BOM voranstellen c.Writer.Write([]byte{0xEF, 0xBB, 0xBF}) w := csv.NewWriter(c.Writer) w.Comma = ';' _ = w.Write([]string{ "ID", "Quelle", "Kategorie", "Frage", "Status", "Antwort", "Begruendung", "Bearbeiter", "Beantwortet_am", "Anzahl_Gefaehrdungen", "Gefaehrdungen", "Norm_Referenzen", }) for _, cl := range clarifications { _ = w.Write([]string{ cl.ID, cl.Source, cl.Category, cl.Question, cl.Status, cl.Answer, cl.Reasoning, cl.AnsweredBy, cl.AnsweredAt, fmt.Sprintf("%d", len(cl.AffectedHazardIDs)), strings.Join(cl.AffectedHazardNames, " | "), strings.Join(cl.NormReferences, " | "), }) } w.Flush() }