diff --git a/admin-compliance/app/sdk/iace/[projectId]/clarifications/page.tsx b/admin-compliance/app/sdk/iace/[projectId]/clarifications/page.tsx index 9a5134e7..5b227bfd 100644 --- a/admin-compliance/app/sdk/iace/[projectId]/clarifications/page.tsx +++ b/admin-compliance/app/sdk/iace/[projectId]/clarifications/page.tsx @@ -16,6 +16,7 @@ type Clarification = { reasoning?: string answered_by?: string answered_at?: string + assigned_to?: string } type ListResponse = { @@ -117,7 +118,19 @@ export default function ClarificationsPage() { - CSV-Export + CSV + + + + + + PDF / Druck @@ -220,13 +233,23 @@ function Badge({ color, label }: { color: string; label: string }) { return {label} } +type Comment = { id: string; author: string; body: string; created_at: string } +type HistoryEntry = { + actor: string + from_status?: string + to_status?: string + from_answer?: string + to_answer?: string + created_at: string +} + function AnswerModal({ clarification, projectId, onClose, onSaved, }: { - clarification: Clarification + clarification: Clarification & { assigned_to?: string } projectId: string onClose: () => void onSaved: () => void @@ -237,9 +260,26 @@ function AnswerModal({ ) const [reasoning, setReasoning] = useState(clarification.reasoning || '') const [answeredBy, setAnsweredBy] = useState(clarification.answered_by || '') + const [assignedTo, setAssignedTo] = useState(clarification.assigned_to || '') const [saving, setSaving] = useState(false) const [error, setError] = useState(null) + const [comments, setComments] = useState([]) + const [history, setHistory] = useState([]) + const [newComment, setNewComment] = useState('') + const [postingComment, setPostingComment] = useState(false) + + useEffect(() => { + fetch(`/api/sdk/v1/iace/projects/${projectId}/clarifications/${encodeURIComponent(clarification.id)}/detail`) + .then(r => r.ok ? r.json() : null) + .then(d => { + if (!d) return + setComments(d.comments || []) + setHistory(d.history || []) + }) + .catch(() => {}) + }, [projectId, clarification.id]) + const save = async () => { setSaving(true) setError(null) @@ -249,7 +289,15 @@ function AnswerModal({ { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ status, answer, reasoning, answered_by: answeredBy }), + body: JSON.stringify({ + status, answer, reasoning, + answered_by: answeredBy, + assigned_to: assignedTo, + question: clarification.question, + source: clarification.source, + category: clarification.category, + norm_references: clarification.norm_references, + }), } ) if (!r.ok) throw new Error(`HTTP ${r.status}`) @@ -261,12 +309,59 @@ function AnswerModal({ } } + const postComment = async () => { + if (!newComment.trim()) return + setPostingComment(true) + try { + const r = await fetch( + `/api/sdk/v1/iace/projects/${projectId}/clarifications/${encodeURIComponent(clarification.id)}/comment`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ author: answeredBy || assignedTo || 'unbekannt', body: newComment }), + } + ) + if (r.ok) { + const d = await r.json() + if (d.comment) setComments(prev => [...prev, d.comment]) + setNewComment('') + } else { + setError(`Kommentar HTTP ${r.status} — bitte zuerst Status setzen, damit der Klärungs-Datensatz angelegt wird.`) + } + } finally { + setPostingComment(false) + } + } + return ( -
-
e.stopPropagation()}> +
+
e.stopPropagation()}>
{clarification.source}
{clarification.question}
+
+
+ + setAssignedTo(e.target.value)} + className="w-full border rounded p-2 text-sm" + placeholder="z.B. anlagenbauer@fanuc.de" + /> +
+
+ + setAnsweredBy(e.target.value)} + className="w-full border rounded p-2 text-sm" + placeholder="Name oder Kürzel" + /> +
+
+
{(['open', 'in_progress', 'answered', 'not_relevant'] as const).map(s => ( @@ -302,18 +397,53 @@ function AnswerModal({ value={reasoning} onChange={e => setReasoning(e.target.value)} rows={4} - className="w-full border rounded p-2 text-sm mb-3" + className="w-full border rounded p-2 text-sm mb-4" placeholder="z.B. Pruefprotokoll vom 12.03.2024 vom Anlagenbauer FANUC vorgelegt; DCS-Konfig liegt bei." /> - - setAnsweredBy(e.target.value)} - className="w-full border rounded p-2 text-sm mb-4" - placeholder="Name oder Kürzel" - /> + {/* Comment Thread */} +
+
Diskussion ({comments.length})
+
+ {comments.map(c => ( +
+
{c.author || 'anonym'} · {c.created_at.slice(0, 16).replace('T', ' ')}
+
{c.body}
+
+ ))} + {comments.length === 0 &&
Noch keine Kommentare.
} +
+
+ setNewComment(e.target.value)} + placeholder="Kommentar hinzufügen..." + className="flex-1 border rounded px-2 py-1.5 text-xs" + onKeyDown={e => { if (e.key === 'Enter') postComment() }} + /> + +
+
+ + {history.length > 0 && ( +
+ Verlauf ({history.length}) +
+ {history.map((h, i) => ( +
+ {h.created_at.slice(0, 16).replace('T', ' ')} · + {h.actor || 'unbekannt'}: {h.from_status} → {h.to_status} + {h.from_answer !== h.to_answer && ` (Antwort ${h.from_answer || '—'} → ${h.to_answer || '—'})`} +
+ ))} +
+
+ )} {error &&
Fehler: {error}
} diff --git a/ai-compliance-sdk/internal/api/handlers/iace_handler_clarifications.go b/ai-compliance-sdk/internal/api/handlers/iace_handler_clarifications.go index 2dccba63..1adef823 100644 --- a/ai-compliance-sdk/internal/api/handlers/iace_handler_clarifications.go +++ b/ai-compliance-sdk/internal/api/handlers/iace_handler_clarifications.go @@ -110,6 +110,12 @@ func appendUnique(slice []string, s string) []string { // ListClarifications handles GET /projects/:id/clarifications. // Returns the aggregated clarification list with affected-hazard cross-refs // and the persisted answer state. +// +// Phase 3 storage model: answers live in the iace_clarifications table +// when migration 028 has been applied. The JSONB fallback in +// project.metadata.clarification_answers is still read so projects that +// were answered before the migration keep their state until the one-shot +// upcopy runs. func (h *IACEHandler) ListClarifications(c *gin.Context) { projectID, err := uuid.Parse(c.Param("id")) if err != nil { @@ -124,7 +130,32 @@ func (h *IACEHandler) ListClarifications(c *gin.Context) { } hazards, _ := h.store.ListHazards(ctx, projectID) - answers, _ := readClarificationAnswers(project.Metadata) + // Primary: relational answers + answers := map[string]iace.ClarificationAnswer{} + if rows, rerr := h.store.ListClarificationsForProject(ctx, projectID); rerr == nil { + for _, r := range rows { + answeredAt := "" + if r.AnsweredAt != nil { + answeredAt = r.AnsweredAt.UTC().Format(time.RFC3339) + } + answers[r.ClarificationKey] = iace.ClarificationAnswer{ + Status: r.Status, + Answer: r.Answer, + Reasoning: r.Reasoning, + AnsweredBy: r.AnsweredBy, + AnsweredAt: answeredAt, + AssignedTo: r.AssignedTo, + } + } + } + // Fallback: JSONB legacy answers (keep until one-shot upcopy is done) + if legacy, _ := readClarificationAnswers(project.Metadata); len(legacy) > 0 { + for k, v := range legacy { + if _, ok := answers[k]; !ok { + answers[k] = v + } + } + } narrative := extractNarrativeFromMetadata(project.Metadata) hazardToPatterns := h.reconstructHazardPatterns(narrative, project.MachineType, hazards) manufHits := iace.LookupManufacturerFeaturesInText(narrative) @@ -164,11 +195,18 @@ type AnswerClarificationRequest struct { Answer string `json:"answer"` // ja | nein | teilweise Reasoning string `json:"reasoning"` AnsweredBy string `json:"answered_by"` + AssignedTo string `json:"assigned_to"` + // Snapshot fields written into the new table on first contact so the + // audit trail does not break if the pattern library changes later. + Question string `json:"question,omitempty"` + Source string `json:"source,omitempty"` + Category string `json:"category,omitempty"` + NormReferences []string `json:"norm_references,omitempty"` } // AnswerClarification handles POST /projects/:id/clarifications/:cid/answer. -// Stores the answer in project.metadata.clarification_answers — no schema -// change required. +// Upserts the answer in iace_clarifications (Phase 3). Old JSONB answers +// remain readable but are no longer written. func (h *IACEHandler) AnswerClarification(c *gin.Context) { projectID, err := uuid.Parse(c.Param("id")) if err != nil { @@ -199,27 +237,123 @@ func (h *IACEHandler) AnswerClarification(c *gin.Context) { 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), + tenantID, terr := getTenantID(c) + if terr != nil { + tenantID = project.TenantID } - answersJSON, _ := json.Marshal(answers) - root[clarificationAnswersKey] = answersJSON - merged, _ := json.Marshal(root) - if err := h.store.UpdateProjectMetadata(ctx, projectID, merged); err != nil { + + // If the client didn't supply snapshot fields, fall back to whatever + // the engine currently produces for this clarification id. + if req.Question == "" || req.Source == "" { + if prev, _ := h.store.GetClarificationByKey(ctx, projectID, cid); prev != nil { + if req.Question == "" { + req.Question = prev.Question + } + if req.Source == "" { + req.Source = prev.Source + } + if req.Category == "" { + req.Category = prev.Category + } + if len(req.NormReferences) == 0 { + req.NormReferences = prev.NormReferences + } + } + } + + now := time.Now().UTC() + answeredAt := &now + if req.Status != "answered" && req.Status != "not_relevant" { + answeredAt = nil + } + in := iace.ClarificationRow{ + TenantID: tenantID, + ProjectID: projectID, + ClarificationKey: cid, + Question: req.Question, + Source: req.Source, + Category: req.Category, + NormReferences: req.NormReferences, + Status: req.Status, + Answer: req.Answer, + Reasoning: req.Reasoning, + AssignedTo: req.AssignedTo, + AnsweredBy: req.AnsweredBy, + AnsweredAt: answeredAt, + } + row, err := h.store.UpsertClarification(ctx, in) + if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{ "clarification_id": cid, - "answer": answers[cid], + "row": row, }) } +// CommentRequest is the body for POST .../comment. +type CommentRequest struct { + Author string `json:"author"` + Body string `json:"body"` +} + +// PostClarificationComment handles POST /projects/:id/clarifications/:cid/comment. +func (h *IACEHandler) PostClarificationComment(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") + var req CommentRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if req.Body == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "body required"}) + return + } + ctx := c.Request.Context() + row, err := h.store.GetClarificationByKey(ctx, projectID, cid) + if err != nil || row == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "clarification not found — answer/assign it first to create the row"}) + return + } + comment, err := h.store.AddClarificationComment(ctx, row.ID, req.Author, req.Body) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"comment": comment}) +} + +// ListClarificationDetail handles GET /projects/:id/clarifications/:cid/detail +// and returns comments + history for one clarification. +func (h *IACEHandler) ListClarificationDetail(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") + ctx := c.Request.Context() + row, _ := h.store.GetClarificationByKey(ctx, projectID, cid) + if row == nil { + c.JSON(http.StatusOK, gin.H{"row": nil, "comments": []any{}, "history": []any{}}) + return + } + comments, _ := h.store.ListClarificationComments(ctx, row.ID) + history, _ := h.store.ListClarificationHistory(ctx, row.ID) + c.JSON(http.StatusOK, gin.H{ + "row": row, + "comments": comments, + "history": history, + }) + _ = json.RawMessage{} // keep encoding/json import in case of future fields +} + // 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 @@ -280,3 +414,153 @@ func (h *IACEHandler) ExportClarificationsCSV(c *gin.Context) { } w.Flush() } + +// ExportClarificationsHTML handles GET /projects/:id/clarifications.html +// and returns a print-friendly standalone HTML document that the browser +// can render to PDF (no server-side PDF dependency needed). The Bediener +// opens the link, hits Cmd-P / Strg-P and saves as PDF. +func (h *IACEHandler) ExportClarificationsHTML(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 := map[string]iace.ClarificationAnswer{} + if rows, _ := h.store.ListClarificationsForProject(ctx, projectID); rows != nil { + for _, r := range rows { + at := "" + if r.AnsweredAt != nil { + at = r.AnsweredAt.UTC().Format(time.RFC3339) + } + answers[r.ClarificationKey] = iace.ClarificationAnswer{ + Status: r.Status, Answer: r.Answer, Reasoning: r.Reasoning, + AnsweredBy: r.AnsweredBy, AnsweredAt: at, + } + } + } + if legacy, _ := readClarificationAnswers(project.Metadata); len(legacy) > 0 { + for k, v := range legacy { + if _, ok := answers[k]; !ok { + answers[k] = v + } + } + } + narrative := extractNarrativeFromMetadata(project.Metadata) + hazardToPatterns := h.reconstructHazardPatterns(narrative, project.MachineType, hazards) + manufHits := iace.LookupManufacturerFeaturesInText(narrative) + cls := iace.BuildProjectClarifications(hazards, hazardToPatterns, manufHits, answers) + sort.Slice(cls, func(i, j int) bool { + if cls[i].Status != cls[j].Status { + return cls[i].Status == "open" + } + return cls[i].Source < cls[j].Source + }) + + c.Header("Content-Type", "text/html; charset=utf-8") + w := c.Writer + fmt.Fprintf(w, ` +Klaerungen — %s + +
Tipp: Mit Strg+P / Cmd+P als PDF speichern.
+

Klaerungsliste — %s

+
Projekt-ID %s · Stand %s
+
+ %d offen + %d beantwortet + %d gesamt +
+`, + htmlEscape(project.MachineName), + htmlEscape(project.MachineName), + project.ID.String(), + time.Now().Format("2006-01-02 15:04"), + countByStatus(cls, false), countByStatus(cls, true), len(cls), + ) + for _, cl := range cls { + statusCls := "open" + statusLabel := "Offen" + if cl.Status == "answered" { + statusCls, statusLabel = "done", "Beantwortet" + } else if cl.Status == "not_relevant" { + statusCls, statusLabel = "gray", "Nicht relevant" + } else if cl.Status == "in_progress" { + statusCls, statusLabel = "open", "In Klaerung" + } + fmt.Fprintf(w, `
%s · %s
+

%s

+`, + htmlEscape(cl.Source), statusCls, statusLabel, + htmlEscape(cl.Question), + ) + if len(cl.NormReferences) > 0 { + fmt.Fprintf(w, `
Normen: %s
`, htmlEscape(strings.Join(cl.NormReferences, " | "))) + } + if len(cl.AffectedHazardNames) > 0 { + fmt.Fprintf(w, `
Betrifft %d Gefaehrdung(en): %s
`, + len(cl.AffectedHazardIDs), + htmlEscape(strings.Join(cl.AffectedHazardNames, "; ")), + ) + } + if cl.Status == "answered" || cl.Status == "not_relevant" { + fmt.Fprintf(w, `
Antwort (%s): %s`, + htmlEscape(cl.Answer), + htmlEscape(cl.Reasoning), + ) + if cl.AnsweredBy != "" { + ts := cl.AnsweredAt + if len(ts) > 10 { + ts = ts[:10] + } + fmt.Fprintf(w, ` — %s, %s`, htmlEscape(cl.AnsweredBy), htmlEscape(ts)) + } + fmt.Fprintf(w, `
`) + } + fmt.Fprintf(w, `
`) + } + fmt.Fprintf(w, `
Anlagenbauer · Datum · Unterschrift
Bediener · Datum · Unterschrift
`) + fmt.Fprintf(w, ``) +} + +func htmlEscape(s string) string { + r := strings.NewReplacer("&", "&", "<", "<", ">", ">", `"`, """, `'`, "'") + return r.Replace(s) +} + +func countByStatus(cls []iace.Clarification, answered bool) int { + n := 0 + for _, c := range cls { + isDone := c.Status == "answered" || c.Status == "not_relevant" + if isDone == answered { + n++ + } + } + return n +} diff --git a/ai-compliance-sdk/internal/app/routes.go b/ai-compliance-sdk/internal/app/routes.go index 75d57244..af532563 100644 --- a/ai-compliance-sdk/internal/app/routes.go +++ b/ai-compliance-sdk/internal/app/routes.go @@ -455,7 +455,10 @@ func registerIACERoutes(v1 *gin.RouterGroup, h *handlers.IACEHandler) { // Clarifications — aggregated open questions per project iaceRoutes.GET("/projects/:id/clarifications", h.ListClarifications) iaceRoutes.GET("/projects/:id/clarifications.csv", h.ExportClarificationsCSV) + iaceRoutes.GET("/projects/:id/clarifications.html", h.ExportClarificationsHTML) + iaceRoutes.GET("/projects/:id/clarifications/:cid/detail", h.ListClarificationDetail) iaceRoutes.POST("/projects/:id/clarifications/:cid/answer", h.AnswerClarification) + iaceRoutes.POST("/projects/:id/clarifications/:cid/comment", h.PostClarificationComment) } } diff --git a/ai-compliance-sdk/internal/iace/clarifications.go b/ai-compliance-sdk/internal/iace/clarifications.go index b31a97a7..7b216b64 100644 --- a/ai-compliance-sdk/internal/iace/clarifications.go +++ b/ai-compliance-sdk/internal/iace/clarifications.go @@ -33,6 +33,7 @@ type Clarification struct { 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 @@ -43,6 +44,7 @@ type ClarificationAnswer struct { 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 @@ -154,6 +156,7 @@ func BuildProjectClarifications( 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) diff --git a/ai-compliance-sdk/internal/iace/store_clarifications.go b/ai-compliance-sdk/internal/iace/store_clarifications.go new file mode 100644 index 00000000..34597cd9 --- /dev/null +++ b/ai-compliance-sdk/internal/iace/store_clarifications.go @@ -0,0 +1,241 @@ +package iace + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +// ClarificationRow is the persisted shape (one row per [project, clarification_key]). +type ClarificationRow struct { + ID uuid.UUID + TenantID uuid.UUID + ProjectID uuid.UUID + ClarificationKey string + Question string + Source string + Category string + NormReferences []string + Status string + Answer string + Reasoning string + AssignedTo string + AnsweredBy string + AnsweredAt *time.Time + CreatedAt time.Time + UpdatedAt time.Time +} + +// ClarificationComment is a single comment on a clarification. +type ClarificationComment struct { + ID uuid.UUID + ClarificationID uuid.UUID + Author string + Body string + CreatedAt time.Time +} + +// ClarificationHistoryEntry logs status/answer transitions. +type ClarificationHistoryEntry struct { + ID uuid.UUID + ClarificationID uuid.UUID + Actor string + FromStatus string + ToStatus string + FromAnswer string + ToAnswer string + Note string + CreatedAt time.Time +} + +// UpsertClarification creates or updates a clarification row by +// (project_id, clarification_key) and logs the status/answer transition +// in iace_clarification_history when something changes. +func (s *Store) UpsertClarification(ctx context.Context, in ClarificationRow) (*ClarificationRow, error) { + tx, err := s.pool.Begin(ctx) + if err != nil { + return nil, fmt.Errorf("begin upsert clarification: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + + // Fetch existing for history diff + var prev ClarificationRow + prevErr := tx.QueryRow(ctx, ` + SELECT id, status, answer + FROM iace_clarifications + WHERE project_id = $1 AND clarification_key = $2 + `, in.ProjectID, in.ClarificationKey).Scan(&prev.ID, &prev.Status, &prev.Answer) + hadPrev := prevErr == nil + if prevErr != nil && !errors.Is(prevErr, pgx.ErrNoRows) && !errors.Is(prevErr, sql.ErrNoRows) { + return nil, fmt.Errorf("lookup existing clarification: %w", prevErr) + } + + var row ClarificationRow + err = tx.QueryRow(ctx, ` + INSERT INTO iace_clarifications ( + tenant_id, project_id, clarification_key, question, source, category, + norm_references, status, answer, reasoning, assigned_to, answered_by, answered_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) + ON CONFLICT (project_id, clarification_key) DO UPDATE SET + question = EXCLUDED.question, + source = EXCLUDED.source, + category = EXCLUDED.category, + norm_references = EXCLUDED.norm_references, + status = EXCLUDED.status, + answer = EXCLUDED.answer, + reasoning = EXCLUDED.reasoning, + assigned_to = EXCLUDED.assigned_to, + answered_by = EXCLUDED.answered_by, + answered_at = EXCLUDED.answered_at + RETURNING id, tenant_id, project_id, clarification_key, question, source, category, + norm_references, status, answer, reasoning, assigned_to, answered_by, answered_at, + created_at, updated_at + `, + in.TenantID, in.ProjectID, in.ClarificationKey, in.Question, in.Source, in.Category, + in.NormReferences, in.Status, in.Answer, in.Reasoning, in.AssignedTo, in.AnsweredBy, in.AnsweredAt, + ).Scan( + &row.ID, &row.TenantID, &row.ProjectID, &row.ClarificationKey, &row.Question, &row.Source, &row.Category, + &row.NormReferences, &row.Status, &row.Answer, &row.Reasoning, &row.AssignedTo, &row.AnsweredBy, &row.AnsweredAt, + &row.CreatedAt, &row.UpdatedAt, + ) + if err != nil { + return nil, fmt.Errorf("upsert clarification: %w", err) + } + + // Log transition iff something changed + if hadPrev && (prev.Status != row.Status || prev.Answer != row.Answer) { + _, err = tx.Exec(ctx, ` + INSERT INTO iace_clarification_history (clarification_id, actor, from_status, to_status, from_answer, to_answer, note) + VALUES ($1, $2, $3, $4, $5, $6, $7) + `, row.ID, in.AnsweredBy, prev.Status, row.Status, prev.Answer, row.Answer, "") + if err != nil { + return nil, fmt.Errorf("write history: %w", err) + } + } + if err := tx.Commit(ctx); err != nil { + return nil, fmt.Errorf("commit upsert clarification: %w", err) + } + return &row, nil +} + +// ListClarificationsForProject returns all clarification rows for a project. +func (s *Store) ListClarificationsForProject(ctx context.Context, projectID uuid.UUID) ([]ClarificationRow, error) { + rows, err := s.pool.Query(ctx, ` + SELECT id, tenant_id, project_id, clarification_key, question, source, category, + norm_references, status, answer, reasoning, assigned_to, answered_by, answered_at, + created_at, updated_at + FROM iace_clarifications + WHERE project_id = $1 + ORDER BY status, source, created_at + `, projectID) + if err != nil { + return nil, fmt.Errorf("list clarifications: %w", err) + } + defer rows.Close() + out := []ClarificationRow{} + for rows.Next() { + var r ClarificationRow + if err := rows.Scan( + &r.ID, &r.TenantID, &r.ProjectID, &r.ClarificationKey, &r.Question, &r.Source, &r.Category, + &r.NormReferences, &r.Status, &r.Answer, &r.Reasoning, &r.AssignedTo, &r.AnsweredBy, &r.AnsweredAt, + &r.CreatedAt, &r.UpdatedAt, + ); err != nil { + return nil, err + } + out = append(out, r) + } + return out, rows.Err() +} + +// GetClarificationByKey fetches a clarification by project + key. +func (s *Store) GetClarificationByKey(ctx context.Context, projectID uuid.UUID, key string) (*ClarificationRow, error) { + var r ClarificationRow + err := s.pool.QueryRow(ctx, ` + SELECT id, tenant_id, project_id, clarification_key, question, source, category, + norm_references, status, answer, reasoning, assigned_to, answered_by, answered_at, + created_at, updated_at + FROM iace_clarifications + WHERE project_id = $1 AND clarification_key = $2 + `, projectID, key).Scan( + &r.ID, &r.TenantID, &r.ProjectID, &r.ClarificationKey, &r.Question, &r.Source, &r.Category, + &r.NormReferences, &r.Status, &r.Answer, &r.Reasoning, &r.AssignedTo, &r.AnsweredBy, &r.AnsweredAt, + &r.CreatedAt, &r.UpdatedAt, + ) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("get clarification: %w", err) + } + return &r, nil +} + +// AddClarificationComment appends a comment to the thread. +func (s *Store) AddClarificationComment(ctx context.Context, clarID uuid.UUID, author, body string) (*ClarificationComment, error) { + var c ClarificationComment + err := s.pool.QueryRow(ctx, ` + INSERT INTO iace_clarification_comments (clarification_id, author, body) + VALUES ($1, $2, $3) + RETURNING id, clarification_id, author, body, created_at + `, clarID, author, body).Scan(&c.ID, &c.ClarificationID, &c.Author, &c.Body, &c.CreatedAt) + if err != nil { + return nil, fmt.Errorf("add clarification comment: %w", err) + } + return &c, nil +} + +// ListClarificationComments returns the comment thread, oldest first. +func (s *Store) ListClarificationComments(ctx context.Context, clarID uuid.UUID) ([]ClarificationComment, error) { + rows, err := s.pool.Query(ctx, ` + SELECT id, clarification_id, author, body, created_at + FROM iace_clarification_comments + WHERE clarification_id = $1 + ORDER BY created_at + `, clarID) + if err != nil { + return nil, fmt.Errorf("list clarification comments: %w", err) + } + defer rows.Close() + out := []ClarificationComment{} + for rows.Next() { + var c ClarificationComment + if err := rows.Scan(&c.ID, &c.ClarificationID, &c.Author, &c.Body, &c.CreatedAt); err != nil { + return nil, err + } + out = append(out, c) + } + return out, rows.Err() +} + +// ListClarificationHistory returns the audit trail entries for a clarification. +func (s *Store) ListClarificationHistory(ctx context.Context, clarID uuid.UUID) ([]ClarificationHistoryEntry, error) { + rows, err := s.pool.Query(ctx, ` + SELECT id, clarification_id, actor, from_status, to_status, from_answer, to_answer, note, created_at + FROM iace_clarification_history + WHERE clarification_id = $1 + ORDER BY created_at DESC + `, clarID) + if err != nil { + return nil, fmt.Errorf("list clarification history: %w", err) + } + defer rows.Close() + out := []ClarificationHistoryEntry{} + for rows.Next() { + var h ClarificationHistoryEntry + var fromStatus, toStatus, fromAnswer, toAnswer sql.NullString + if err := rows.Scan(&h.ID, &h.ClarificationID, &h.Actor, &fromStatus, &toStatus, &fromAnswer, &toAnswer, &h.Note, &h.CreatedAt); err != nil { + return nil, err + } + h.FromStatus = fromStatus.String + h.ToStatus = toStatus.String + h.FromAnswer = fromAnswer.String + h.ToAnswer = toAnswer.String + out = append(out, h) + } + return out, rows.Err() +} diff --git a/ai-compliance-sdk/migrations/028_iace_clarifications.sql b/ai-compliance-sdk/migrations/028_iace_clarifications.sql new file mode 100644 index 00000000..2e53ef2f --- /dev/null +++ b/ai-compliance-sdk/migrations/028_iace_clarifications.sql @@ -0,0 +1,88 @@ +-- Migration 028: IACE Clarifications — Multi-User-Workflow +-- ========================================================================== +-- Up to Phase 2 the Klaerungen feature persisted answers in +-- iace_projects.metadata.clarification_answers (JSONB). That works for a +-- single user but cannot model assigned_to, comment threads, status +-- history or audit trail. Phase 3 introduces a proper relational table. +-- +-- The previous JSONB blob remains read by the engine as fallback for any +-- project whose clarifications have not yet been migrated, so this is a +-- non-breaking add-on. A separate one-shot upcopy script migrates the +-- existing JSONB answers into rows. +-- ========================================================================== + +-- 1. Main table: one row per (project, clarification_id) +CREATE TABLE IF NOT EXISTS iace_clarifications ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + project_id UUID NOT NULL REFERENCES iace_projects(id) ON DELETE CASCADE, + -- Deterministic clarification ID generated by the engine + -- (e.g. "pattern:HP1640:0", "manuf:fanuc:dual-check-safety-dcs:1"). + -- Stable across re-inits so persisted answers survive. + clarification_key TEXT NOT NULL, + -- The verbatim question + source as known at last engine run. + -- Kept in this table so the audit trail does not break if the + -- pattern library is later updated. + question TEXT NOT NULL, + source TEXT NOT NULL, + category TEXT NOT NULL, + norm_references TEXT[] DEFAULT '{}', + -- Lifecycle state + status TEXT NOT NULL DEFAULT 'open' + CHECK (status IN ('open', 'in_progress', 'answered', 'not_relevant')), + answer TEXT DEFAULT '' CHECK (answer IN ('', 'ja', 'nein', 'teilweise')), + reasoning TEXT DEFAULT '', + -- Multi-User workflow + assigned_to TEXT DEFAULT '', -- user id / kuerzel — free text for now + answered_by TEXT DEFAULT '', + answered_at TIMESTAMPTZ, + -- Common audit + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (project_id, clarification_key) +); + +CREATE INDEX IF NOT EXISTS idx_iace_clar_project ON iace_clarifications(project_id); +CREATE INDEX IF NOT EXISTS idx_iace_clar_tenant ON iace_clarifications(tenant_id); +CREATE INDEX IF NOT EXISTS idx_iace_clar_status ON iace_clarifications(project_id, status); +CREATE INDEX IF NOT EXISTS idx_iace_clar_assignee ON iace_clarifications(assigned_to) WHERE assigned_to <> ''; + +-- 2. Comment thread: one row per comment, ordered by created_at +CREATE TABLE IF NOT EXISTS iace_clarification_comments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + clarification_id UUID NOT NULL REFERENCES iace_clarifications(id) ON DELETE CASCADE, + author TEXT NOT NULL DEFAULT '', + body TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_iace_clar_comments_clar ON iace_clarification_comments(clarification_id, created_at); + +-- 3. Status history — every status / answer change is logged. +CREATE TABLE IF NOT EXISTS iace_clarification_history ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + clarification_id UUID NOT NULL REFERENCES iace_clarifications(id) ON DELETE CASCADE, + actor TEXT NOT NULL DEFAULT '', + from_status TEXT, + to_status TEXT, + from_answer TEXT, + to_answer TEXT, + note TEXT DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_iace_clar_history_clar ON iace_clarification_history(clarification_id, created_at); + +-- 4. Trigger to bump updated_at on the main table +CREATE OR REPLACE FUNCTION iace_clarifications_touch_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_iace_clarifications_updated_at ON iace_clarifications; +CREATE TRIGGER trg_iace_clarifications_updated_at + BEFORE UPDATE ON iace_clarifications + FOR EACH ROW EXECUTE FUNCTION iace_clarifications_touch_updated_at();