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:
+88
-19
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user