From f19a75d83dcf7d647fa2e69753787bea9e7aad11 Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Sun, 17 May 2026 01:25:36 +0200 Subject: [PATCH] =?UTF-8?q?feat(iace):=20Klaerungen=20Phase=202=20?= =?UTF-8?q?=E2=80=94=20Sidebar-Counter=20+=20CSV-Export=20+=20Hazard-Banne?= =?UTF-8?q?r?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../_components/HazardComparisonTable.tsx | 107 ++++++++++++++---- .../iace/[projectId]/clarifications/page.tsx | 27 +++-- admin-compliance/app/sdk/iace/layout.tsx | 27 ++++- .../handlers/iace_handler_clarifications.go | 64 +++++++++++ .../api/handlers/iace_handler_init.go | 41 +------ ai-compliance-sdk/internal/app/routes.go | 1 + 6 files changed, 205 insertions(+), 62 deletions(-) diff --git a/admin-compliance/app/sdk/iace/[projectId]/benchmark/_components/HazardComparisonTable.tsx b/admin-compliance/app/sdk/iace/[projectId]/benchmark/_components/HazardComparisonTable.tsx index 9c430cd1..0e52e5f1 100644 --- a/admin-compliance/app/sdk/iace/[projectId]/benchmark/_components/HazardComparisonTable.tsx +++ b/admin-compliance/app/sdk/iace/[projectId]/benchmark/_components/HazardComparisonTable.tsx @@ -1,6 +1,7 @@ 'use client' -import React, { useState } from 'react' +import React, { useState, useEffect } from 'react' +import { useParams } from 'next/navigation' import type { HazardMatchPair, GroundTruthEntry, HazardSummary } from '../_hooks/useBenchmark' interface Props { @@ -11,8 +12,41 @@ interface Props { type TabType = 'matched' | 'missing' | 'extra' +// Per-hazard clarification status fetched once and shared with all detail rows. +type HazardClarStatus = { open: number; answered: number; total: number } + +function useClarificationsByHazard(projectId: string | undefined): Record { + const [byHz, setByHz] = useState>({}) + useEffect(() => { + if (!projectId) return + let cancelled = false + fetch(`/api/sdk/v1/iace/projects/${projectId}/clarifications`) + .then(r => r.ok ? r.json() : null) + .then(d => { + if (cancelled || !d?.clarifications) return + const out: Record = {} + for (const c of d.clarifications as Array<{ affected_hazard_ids: string[]; status: string }>) { + const isOpen = c.status !== 'answered' && c.status !== 'not_relevant' + for (const hid of c.affected_hazard_ids) { + if (!out[hid]) out[hid] = { open: 0, answered: 0, total: 0 } + out[hid].total += 1 + if (isOpen) out[hid].open += 1 + else out[hid].answered += 1 + } + } + setByHz(out) + }) + .catch(() => {}) + return () => { cancelled = true } + }, [projectId]) + return byHz +} + export function HazardComparisonTable({ matched, missing, extra }: Props) { const [tab, setTab] = useState('matched') + const params = useParams() + const projectId = params?.projectId as string | undefined + const clarStatusByHazard = useClarificationsByHazard(projectId) // Split matches: >= 50% are real matches, < 50% are weak (shown separately) const realMatched = matched.filter(p => p.match_score >= 0.5) @@ -51,7 +85,7 @@ export function HazardComparisonTable({ matched, missing, extra }: Props) {
- {tab === 'matched' && } + {tab === 'matched' && } {tab === 'missing' && } {tab === 'extra' && }
@@ -59,7 +93,7 @@ export function HazardComparisonTable({ matched, missing, extra }: Props) { ) } -function MatchedTable({ pairs }: { pairs: HazardMatchPair[] }) { +function MatchedTable({ pairs, clarStatusByHazard, projectId }: { pairs: HazardMatchPair[]; clarStatusByHazard: Record; projectId?: string }) { const [expanded, setExpanded] = useState>({}) if (pairs.length === 0) return return ( @@ -109,7 +143,12 @@ function MatchedTable({ pairs }: { pairs: HazardMatchPair[] }) { {isOpen && ( - + )} @@ -137,7 +176,12 @@ function formatLifecycles(raw: string): string { } /** Side-by-side detail comparison of GT entry vs. Engine hazard */ -function DetailComparison({ gt, engine }: { gt: GroundTruthEntry; engine: HazardSummary }) { +function DetailComparison({ gt, engine, clarStatus, projectId }: { + gt: GroundTruthEntry + engine: HazardSummary + clarStatus?: HazardClarStatus + projectId?: string +}) { return (
{/* Left: Ground Truth */} @@ -178,11 +222,9 @@ function DetailComparison({ gt, engine }: { gt: GroundTruthEntry; engine: Hazard ) : ( )} - {(() => { - const clarifications = extractClarifications(engine.description) - if (clarifications.length === 0) return null - return '• ' + c).join('\n')} multiline /> - })()} + {clarStatus && clarStatus.total > 0 && ( + + )} {(() => { const norms = extractEngineNorms(engine.description) if (norms.length === 0) return null @@ -209,15 +251,42 @@ function extractScenario(desc?: string): string { return (normIdx >= 0 ? cut.slice(0, normIdx) : cut).trim() } -function extractClarifications(desc?: string): string[] { - if (!desc) return [] - const start = desc.indexOf('Mit Anlagenbauer zu klaeren:') - if (start < 0) return [] - const after = desc.slice(start + 'Mit Anlagenbauer zu klaeren:'.length) - // Stop at the next double-newline section (e.g. norm block) - const stop = after.indexOf('\n\n') - const block = stop >= 0 ? after.slice(0, stop) : after - return block.split('\n').map(s => s.replace(/^[\s-]+/, '').trim()).filter(Boolean) +// (extractClarifications removed in Phase 2 — clarifications are loaded +// from the dedicated /clarifications API and rendered as a status banner +// instead of being parsed out of the hazard description.) + +function ClarificationBanner({ status, projectId }: { status: HazardClarStatus; projectId?: string }) { + const allDone = status.open === 0 + const href = projectId ? `/sdk/iace/${projectId}/clarifications` : '#' + return ( + + ) } function extractEngineNorms(desc?: string): string[] { diff --git a/admin-compliance/app/sdk/iace/[projectId]/clarifications/page.tsx b/admin-compliance/app/sdk/iace/[projectId]/clarifications/page.tsx index 685396f4..9a5134e7 100644 --- a/admin-compliance/app/sdk/iace/[projectId]/clarifications/page.tsx +++ b/admin-compliance/app/sdk/iace/[projectId]/clarifications/page.tsx @@ -100,13 +100,26 @@ export default function ClarificationsPage() { Standardisierte Prüffragen aus Norm- und Herstellerwissen. Eine Antwort gilt für alle referenzierten Gefährdungen.

- {data && ( -
- - - -
- )} +
+ {data && ( +
+ + + +
+ )} + + + + + CSV-Export + +
diff --git a/admin-compliance/app/sdk/iace/layout.tsx b/admin-compliance/app/sdk/iace/layout.tsx index b961a5f2..f1550bf0 100644 --- a/admin-compliance/app/sdk/iace/layout.tsx +++ b/admin-compliance/app/sdk/iace/layout.tsx @@ -116,6 +116,23 @@ export default function IACELayout({ children }: { children: React.ReactNode }) const [variantInfo, setVariantInfo] = React.useState<{ parentProjectId?: string; parentName?: string; variantCount?: number }>({}) + const [openClarifications, setOpenClarifications] = React.useState(null) + + // Poll the clarifications endpoint so the sidebar always shows the + // current "offene Klaerungen" counter. Refresh whenever the user + // navigates back to this layout (i.e. when pathname changes). + React.useEffect(() => { + if (!projectId) return + let cancelled = false + fetch(`/api/sdk/v1/iace/projects/${projectId}/clarifications`) + .then(r => r.ok ? r.json() : null) + .then(d => { + if (cancelled || !d || typeof d.open_count !== 'number') return + setOpenClarifications(d.open_count) + }) + .catch(() => {}) + return () => { cancelled = true } + }, [projectId, pathname]) React.useEffect(() => { if (!projectId) return @@ -219,7 +236,15 @@ export default function IACELayout({ children }: { children: React.ReactNode }) }`} > - {item.label} + {item.label} + {item.id === 'clarifications' && openClarifications !== null && openClarifications > 0 && ( + + {openClarifications} + + )} ))} 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 cbe8e1e7..2dccba63 100644 --- a/ai-compliance-sdk/internal/api/handlers/iace_handler_clarifications.go +++ b/ai-compliance-sdk/internal/api/handlers/iace_handler_clarifications.go @@ -1,9 +1,12 @@ package handlers import ( + "encoding/csv" "encoding/json" + "fmt" "net/http" "sort" + "strings" "time" "github.com/breakpilot/ai-compliance-sdk/internal/iace" @@ -216,3 +219,64 @@ func (h *IACEHandler) AnswerClarification(c *gin.Context) { "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() +} diff --git a/ai-compliance-sdk/internal/api/handlers/iace_handler_init.go b/ai-compliance-sdk/internal/api/handlers/iace_handler_init.go index 6847ddaf..5355d2c0 100644 --- a/ai-compliance-sdk/internal/api/handlers/iace_handler_init.go +++ b/ai-compliance-sdk/internal/api/handlers/iace_handler_init.go @@ -212,42 +212,13 @@ func (h *IACEHandler) InitializeProject(c *gin.Context) { // Join all applicable lifecycles as comma-separated string lifecycleStr := strings.Join(mp.ApplicableLifecycles, ",") - // Append pattern-defined clarification questions to the - // description so they're visible in the UI without DB- - // schema changes. The engine does NOT invent commentary; - // it only hangs the standard ISO/EN clarification points - // onto the hazard so the operator knows what to verify - // with the Anlagenbauer. + // Phase 2: clarification questions are no longer embedded + // in the hazard description — they live in the dedicated + // /clarifications API and the UI loads them on demand. + // The hazard description stays clean and focused on the + // scenario itself. Only the aggregated norm-references + // block is appended below for an at-a-glance audit trail. desc := mp.ScenarioDE - clarBuckets := mp.ClarificationQuestionsDE - // Manufacturer-specific clarifications: if the narrative - // mentions a known manufacturer (FANUC/KUKA/Siemens/...), - // append its feature-specific questions to the matching - // hazard categories. Markennennung ist nominative use - // (§ 23 MarkenG), Fakten ueber Safety-Features sind nicht - // urheberrechtlich geschuetzt. - for _, mf := range iace.LookupManufacturerFeaturesInText(narrativeText) { - applies := len(mf.AppliesToHazardCats) == 0 - for _, hc := range mf.AppliesToHazardCats { - if hc == cat { - applies = true - break - } - } - if !applies { - continue - } - prefix := mf.Manufacturer + " (" + mf.FeatureName + "): " - for _, q := range mf.Clarifications { - clarBuckets = append(clarBuckets, prefix+q) - } - } - if len(clarBuckets) > 0 { - desc += "\n\nMit Anlagenbauer zu klaeren:" - for _, q := range clarBuckets { - desc += "\n- " + q - } - } hz, cerr := h.store.CreateHazard(ctx, iace.CreateHazardRequest{ ProjectID: projectID, diff --git a/ai-compliance-sdk/internal/app/routes.go b/ai-compliance-sdk/internal/app/routes.go index 0e2dd247..75d57244 100644 --- a/ai-compliance-sdk/internal/app/routes.go +++ b/ai-compliance-sdk/internal/app/routes.go @@ -454,6 +454,7 @@ 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.POST("/projects/:id/clarifications/:cid/answer", h.AnswerClarification) } }