feat(iace): Klaerungen Phase 2 — Sidebar-Counter + CSV-Export + Hazard-Banner

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>
This commit is contained in:
Benjamin Admin
2026-05-17 01:25:36 +02:00
parent 525038359a
commit f19a75d83d
6 changed files with 205 additions and 62 deletions
@@ -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<string, HazardClarStatus> {
const [byHz, setByHz] = useState<Record<string, HazardClarStatus>>({})
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<string, HazardClarStatus> = {}
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<TabType>('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) {
</div>
<div className="overflow-x-auto">
{tab === 'matched' && <MatchedTable pairs={realMatched} />}
{tab === 'matched' && <MatchedTable pairs={realMatched} clarStatusByHazard={clarStatusByHazard} projectId={projectId} />}
{tab === 'missing' && <MissingTable entries={allMissing} />}
{tab === 'extra' && <ExtraTable entries={allExtra} />}
</div>
@@ -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<string, HazardClarStatus>; projectId?: string }) {
const [expanded, setExpanded] = useState<Record<number, boolean>>({})
if (pairs.length === 0) return <EmptyState text="Keine Zuordnungen gefunden" />
return (
@@ -109,7 +143,12 @@ function MatchedTable({ pairs }: { pairs: HazardMatchPair[] }) {
{isOpen && (
<tr className="bg-gray-50/70 dark:bg-gray-850">
<td colSpan={6} className="px-4 py-3">
<DetailComparison gt={p.gt_entry} engine={p.engine_hazard} />
<DetailComparison
gt={p.gt_entry}
engine={p.engine_hazard}
clarStatus={clarStatusByHazard[p.engine_hazard.id]}
projectId={projectId}
/>
</td>
</tr>
)}
@@ -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 (
<div className="grid grid-cols-2 gap-4 text-xs">
{/* Left: Ground Truth */}
@@ -178,11 +222,9 @@ function DetailComparison({ gt, engine }: { gt: GroundTruthEntry; engine: Hazard
) : (
<DetailRow label="Massnahmen" gt="(keine zugeordnet)" />
)}
{(() => {
const clarifications = extractClarifications(engine.description)
if (clarifications.length === 0) return null
return <DetailRow label="Mit Anlagenbauer zu klaeren" gt={clarifications.map(c => '• ' + c).join('\n')} multiline />
})()}
{clarStatus && clarStatus.total > 0 && (
<ClarificationBanner status={clarStatus} projectId={projectId} />
)}
{(() => {
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 (
<div>
<div className="text-[10px] font-medium text-gray-500 uppercase">Klärungen</div>
<a
href={href}
className={`mt-0.5 inline-flex items-center gap-2 px-3 py-1.5 rounded border text-xs ${
allDone
? 'bg-green-50 border-green-200 text-green-800 hover:bg-green-100'
: 'bg-orange-50 border-orange-200 text-orange-800 hover:bg-orange-100'
}`}
>
{allDone ? (
<>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
Alle {status.total} Klärungen beantwortet
</>
) : (
<>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
{status.open} offene Klärung{status.open === 1 ? '' : 'en'} {status.answered > 0 && `(${status.answered} beantwortet)`} Klärungen-Seite öffnen
</>
)}
</a>
</div>
)
}
function extractEngineNorms(desc?: string): string[] {
@@ -100,13 +100,26 @@ export default function ClarificationsPage() {
Standardisierte Prüffragen aus Norm- und Herstellerwissen. Eine Antwort gilt für alle referenzierten Gefährdungen.
</p>
</div>
{data && (
<div className="flex gap-2 text-sm">
<Badge color="bg-orange-100 text-orange-800" label={`${data.open_count} offen`} />
<Badge color="bg-green-100 text-green-800" label={`${data.answered_count} beantwortet`} />
<Badge color="bg-gray-100 text-gray-700" label={`${data.total} gesamt`} />
</div>
)}
<div className="flex items-center gap-3">
{data && (
<div className="flex gap-2 text-sm">
<Badge color="bg-orange-100 text-orange-800" label={`${data.open_count} offen`} />
<Badge color="bg-green-100 text-green-800" label={`${data.answered_count} beantwortet`} />
<Badge color="bg-gray-100 text-gray-700" label={`${data.total} gesamt`} />
</div>
)}
<a
href={`/api/sdk/v1/iace/projects/${projectId}/clarifications.csv`}
download
className="text-xs px-3 py-1.5 rounded border border-gray-300 bg-white hover:bg-gray-50 inline-flex items-center gap-1.5"
title="CSV-Export für die Übergabe an den Anlagenbauer"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2M7 10l5 5 5-5M12 15V3" />
</svg>
CSV-Export
</a>
</div>
</div>
<div className="flex gap-3 mb-4 items-center">
+26 -1
View File
@@ -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<number | null>(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 })
}`}
>
<NavIcon icon={item.icon} className="w-4 h-4 flex-shrink-0" />
<span className="truncate">{item.label}</span>
<span className="truncate flex-1">{item.label}</span>
{item.id === 'clarifications' && openClarifications !== null && openClarifications > 0 && (
<span
className="ml-auto inline-flex items-center justify-center min-w-[20px] px-1.5 py-0.5 text-[10px] font-semibold rounded-full bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-300"
title={`${openClarifications} offene Klärung${openClarifications === 1 ? '' : 'en'}`}
>
{openClarifications}
</span>
)}
</Link>
))}
</nav>
@@ -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()
}
@@ -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,
+1
View File
@@ -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)
}
}