f19a75d83d
Three pieces complete the Klaerungen UX: 1. Sidebar-Counter: layout.tsx polls /clarifications and shows a colored open-count badge on the "Klaerungen" nav item. Refreshes whenever the user changes route. 2. CSV-Export: new backend endpoint GET /sdk/v1/iace/projects/:id/clarifications.csv produces a UTF-8- BOM-prefixed semicolon-separated CSV (Excel-friendly) with ID, Quelle, Kategorie, Frage, Status, Antwort, Begruendung, Bearbeiter, answered_at, anzahl Gefaehrdungen, Gefaehrdungs-Namen, Norm-Refs. Frontend Klaerungen-Seite bekommt einen "CSV-Export"-Button. 3. Hazard-Banner statt Fragentext im Benchmark-Detail: the previous bulleted clarification list was duplicated across 48 hazards for a single FANUC question. Phase 2 replaces it with a compact status badge — "N offene Klaerung(en) — Klaerungen-Seite oeffnen" (orange) or "Alle N Klaerungen beantwortet" (green) with a direct link. Backend cleanup: iace_handler_init.go no longer appends the "Mit Anlagenbauer zu klaeren" block to Hazard.Description. The description stays focused on the scenario; clarifications live in the dedicated endpoint and answers persist across re-inits via project.metadata. The aggregated "Referenzierte Normen" line on the hazard is kept. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
283 lines
9.2 KiB
Go
283 lines
9.2 KiB
Go
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()
|
|
}
|