From 0a84c747f2bee2d20e79041d12249abbf4b426f5 Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Fri, 22 May 2026 09:48:07 +0200 Subject: [PATCH] feat(iace): wire crossref into tech-file, library UI, and contract tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three follow-ups to the 671-norm cross-reference matrix: 1. Tech-file renderer (Go): standards_applied section now gets a deterministic Markdown appendix with the DIN/ANSI/GB/JIS mappings for the project's suggested norms. Built from registry, never hallucinated by LLM. Applied both to LLM and fallback content paths. 2. Frontend NormCrossRefPanel (Next.js): expandable row in the IACE library norms tab now has a "Internationale Aequivalenzen anzeigen" button that lazy-loads /iace/norms-library/:id/crossref and renders a colour-coded table (relation + confidence). Region labels humanised (US — ANSI, China (GB), Japan (JIS), etc.). 3. Contract tests (Go): 4 new handler tests pinning the response shape of GetNormCrossRef and ListNormCrossRefs. Equivalent to an OpenAPI snapshot for these specific endpoints — ai-compliance-sdk has no full OpenAPI baseline yet (separate ticket). Tests: 6 renderer tests + 4 handler contract tests, all green. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../library/_components/NormCrossRefPanel.tsx | 176 ++++++++++++++++++ .../iace/library/_components/NormenTab.tsx | 2 + .../iace_handler_norms_crossref_test.go | 110 +++++++++++ .../internal/iace/norm_crossref_renderer.go | 159 ++++++++++++++++ .../iace/norm_crossref_renderer_test.go | 85 +++++++++ .../internal/iace/tech_file_generator.go | 57 +++++- 6 files changed, 587 insertions(+), 2 deletions(-) create mode 100644 admin-compliance/app/sdk/iace/library/_components/NormCrossRefPanel.tsx create mode 100644 ai-compliance-sdk/internal/api/handlers/iace_handler_norms_crossref_test.go create mode 100644 ai-compliance-sdk/internal/iace/norm_crossref_renderer.go create mode 100644 ai-compliance-sdk/internal/iace/norm_crossref_renderer_test.go diff --git a/admin-compliance/app/sdk/iace/library/_components/NormCrossRefPanel.tsx b/admin-compliance/app/sdk/iace/library/_components/NormCrossRefPanel.tsx new file mode 100644 index 00000000..0d13f689 --- /dev/null +++ b/admin-compliance/app/sdk/iace/library/_components/NormCrossRefPanel.tsx @@ -0,0 +1,176 @@ +'use client' + +import React, { useState } from 'react' + +interface NormMapping { + region: string + identifier: string + relation: string + confidence: string + notes?: string + source_url?: string +} + +interface CrossRefResponse { + norm_id: string + mappings: NormMapping[] + notes?: string + batch_id?: string +} + +const RELATION_COLORS: Record = { + identical: 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300', + equivalent: 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300', + partial: 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-300', + supersedes: 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300', + superseded_by: 'bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400', +} + +const CONFIDENCE_COLORS: Record = { + verified: 'text-emerald-700 dark:text-emerald-300 font-semibold', + high: 'text-blue-700 dark:text-blue-300', + medium: 'text-amber-700 dark:text-amber-300', + low: 'text-red-700 dark:text-red-300', +} + +const REGION_LABELS: Record = { + 'EU-DIN': 'EU (DIN)', + 'INTL-ISO': 'International (ISO/IEC)', + 'US-ANSI': 'US — ANSI', + 'US-NFPA': 'US — NFPA', + 'US-UL': 'US — UL', + 'US-OSHA': 'US — OSHA', + 'US-ASME': 'US — ASME', + 'US-ASTM': 'US — ASTM', + 'US-SAE': 'US — SAE', + 'US-NIOSH': 'US — NIOSH', + 'US-FDA': 'US — FDA', + 'US-EPA': 'US — EPA', + 'US-NEMA': 'US — NEMA', + 'US-NSF': 'US — NSF', + 'US-API': 'US — API', + 'US-CPSC': 'US — CPSC', + 'US-AHRI': 'US — AHRI', + 'US-ASHRAE': 'US — ASHRAE', + 'US-FCC': 'US — FCC', + 'US-DOT': 'US — DOT', + 'CN-GB': 'China (GB)', + 'JP-JIS': 'Japan (JIS)', +} + +function formatRegion(region: string): string { + return REGION_LABELS[region] || region +} + +interface Props { + normId: string +} + +export default function NormCrossRefPanel({ normId }: Props) { + const [loaded, setLoaded] = useState(false) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [data, setData] = useState(null) + + const handleLoad = async () => { + if (loaded || loading) return + setLoading(true) + setError(null) + try { + const res = await fetch(`/api/sdk/v1/iace/norms-library/${encodeURIComponent(normId)}/crossref`) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + const json = (await res.json()) as CrossRefResponse + setData(json) + setLoaded(true) + } catch (e: any) { + setError(e?.message || 'Fehler beim Laden') + } finally { + setLoading(false) + } + } + + if (!loaded && !loading && !error) { + return ( + + ) + } + + if (loading) { + return
Cross-Reference wird geladen…
+ } + + if (error) { + return ( +
+ Cross-Reference konnte nicht geladen werden: {error} +
+ ) + } + + if (!data || data.mappings.length === 0) { + return ( +
+ Fuer diese Norm liegt (noch) kein internationales Mapping in der Bibliothek vor. +
+ ) + } + + return ( +
+
+ Internationale Aequivalenzen +
+ {data.notes && ( +
{data.notes}
+ )} +
+ + + + + + + + + + + {data.mappings.map((m, i) => ( + + + + + + + ))} + +
RegionIdentifierRelationConfidence
{formatRegion(m.region)} + {m.source_url ? ( + + {m.identifier} + + ) : ( + m.identifier + )} + {m.notes && ( +
{m.notes}
+ )} +
+ + {m.relation} + + + {m.confidence} +
+
+
+ Vor Nutzung in einem Drittmarkt durch eine sachkundige Person verifizieren. +
+
+ ) +} diff --git a/admin-compliance/app/sdk/iace/library/_components/NormenTab.tsx b/admin-compliance/app/sdk/iace/library/_components/NormenTab.tsx index 50d9183b..5e559b69 100644 --- a/admin-compliance/app/sdk/iace/library/_components/NormenTab.tsx +++ b/admin-compliance/app/sdk/iace/library/_components/NormenTab.tsx @@ -2,6 +2,7 @@ import React, { useMemo, useState, useRef, useEffect } from 'react' import { SearchInput, FilterDropdown, Pagination, ExpandableRow, ExternalLinkIcon } from './LibraryTable' +import NormCrossRefPanel from './NormCrossRefPanel' export interface Norm { id: string @@ -128,6 +129,7 @@ export default function NormenTab({ norms }: Props) { {n.tags.map((t) => {t})} )} + } /> diff --git a/ai-compliance-sdk/internal/api/handlers/iace_handler_norms_crossref_test.go b/ai-compliance-sdk/internal/api/handlers/iace_handler_norms_crossref_test.go new file mode 100644 index 00000000..731d389d --- /dev/null +++ b/ai-compliance-sdk/internal/api/handlers/iace_handler_norms_crossref_test.go @@ -0,0 +1,110 @@ +package handlers + +import ( + "encoding/json" + "testing" + + "github.com/gin-gonic/gin" +) + +// Contract tests for the new /norms-library/crossref endpoints. +// These are the practical equivalent of an OpenAPI snapshot: they pin +// the response shape so a downstream consumer (admin-compliance, +// developer-portal, SDK) cannot be silently broken. + +func TestGetNormCrossRef_KnownID_ReturnsExpectedShape(t *testing.T) { + handler := &IACEHandler{} + w, c := newTestContext("GET", "/norms-library/ISO-12100/crossref", nil, nil, gin.Params{ + {Key: "id", Value: "ISO-12100"}, + }) + + handler.GetNormCrossRef(c) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String()) + } + + var resp struct { + NormID string `json:"norm_id"` + Mappings []struct { + Region string `json:"region"` + Identifier string `json:"identifier"` + Relation string `json:"relation"` + Confidence string `json:"confidence"` + } `json:"mappings"` + BatchID string `json:"batch_id"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("response not parsable: %v body=%s", err, w.Body.String()) + } + if resp.NormID != "ISO-12100" { + t.Errorf("expected norm_id ISO-12100, got %q", resp.NormID) + } + if len(resp.Mappings) < 3 { + t.Errorf("expected ISO-12100 to have at least 3 mappings, got %d", len(resp.Mappings)) + } +} + +func TestGetNormCrossRef_MissingID_Returns400(t *testing.T) { + handler := &IACEHandler{} + w, c := newTestContext("GET", "/norms-library//crossref", nil, nil, gin.Params{ + {Key: "id", Value: ""}, + }) + + handler.GetNormCrossRef(c) + if w.Code != 400 { + t.Errorf("expected 400 for missing id, got %d", w.Code) + } +} + +func TestGetNormCrossRef_UnknownID_ReturnsEmptyMappings(t *testing.T) { + handler := &IACEHandler{} + w, c := newTestContext("GET", "/norms-library/ISO-DOESNOTEXIST/crossref", nil, nil, gin.Params{ + {Key: "id", Value: "ISO-DOESNOTEXIST"}, + }) + + handler.GetNormCrossRef(c) + + if w.Code != 200 { + t.Fatalf("expected 200 for unknown id (returns empty), got %d", w.Code) + } + var resp struct { + NormID string `json:"norm_id"` + Mappings []interface{} `json:"mappings"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("response not parsable: %v", err) + } + if resp.NormID != "ISO-DOESNOTEXIST" { + t.Errorf("expected norm_id to echo back, got %q", resp.NormID) + } + if len(resp.Mappings) != 0 { + t.Errorf("expected empty mappings, got %d", len(resp.Mappings)) + } +} + +func TestListNormCrossRefs_ReturnsAll(t *testing.T) { + handler := &IACEHandler{} + w, c := newTestContext("GET", "/norms-library/crossref", nil, nil, nil) + + handler.ListNormCrossRefs(c) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d", w.Code) + } + var resp struct { + Entries []struct { + NormID string `json:"norm_id"` + } `json:"entries"` + Total int `json:"total"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("response not parsable: %v", err) + } + if resp.Total != 671 { + t.Errorf("expected 671 cross-ref entries, got %d", resp.Total) + } + if len(resp.Entries) != resp.Total { + t.Errorf("entries count %d does not match total %d", len(resp.Entries), resp.Total) + } +} diff --git a/ai-compliance-sdk/internal/iace/norm_crossref_renderer.go b/ai-compliance-sdk/internal/iace/norm_crossref_renderer.go new file mode 100644 index 00000000..03542769 --- /dev/null +++ b/ai-compliance-sdk/internal/iace/norm_crossref_renderer.go @@ -0,0 +1,159 @@ +package iace + +import ( + "fmt" + "sort" + "strings" +) + +// RenderCrossRefAppendix builds a Markdown appendix for a tech-file section +// that lists the international equivalents of the given norm IDs. It is +// intended to be appended to the "Applied Harmonised Standards" section so +// the same tech file is usable for CE + US/CN/JP market submissions. +// +// Output format: +// +// ## Anhang: Internationale Aequivalenzen / International Cross-Reference +// +// Diese Tabelle ordnet die in dieser technischen Dokumentation angewandten +// EU-Normen den Pendants in anderen Maerkten zu. Die Spalte "Relation" gibt +// an, ob es sich um eine identische Uebernahme, eine teilweise Ueberdeckung +// oder ein abgeloestes (superseded_by) Dokument handelt. Vor Nutzung im +// jeweiligen Marktraum durch eine sachkundige Person verifizieren. +// +// | EU Norm | Region | International Identifier | Relation | Confidence | +// |---------|--------|--------------------------|----------|------------| +// ... +// +// If no norms have crossref entries, returns an empty string so the caller +// can skip the appendix entirely. +func RenderCrossRefAppendix(normIDs []string) string { + rows := collectCrossRefRows(normIDs) + if len(rows) == 0 { + return "" + } + + var b strings.Builder + b.WriteString("\n\n## Anhang: Internationale Aequivalenzen / International Cross-Reference\n\n") + b.WriteString("Diese Tabelle ordnet die in dieser technischen Dokumentation angewandten EU-Normen den Pendants in anderen Maerkten zu (DIN, ANSI/NFPA/UL/OSHA, GB, JIS u.a.). Die Spalte ") + b.WriteString("**Relation** kennzeichnet `identical` (wortgleiche Uebernahme), `equivalent` (Kompatibilitaet auf Verfahrensebene), ") + b.WriteString("`partial` (Teilueberdeckung — vor Nutzung pruefen), `supersedes`/`superseded_by` (Ablaufverhaeltnis). ") + b.WriteString("Die Spalte **Confidence** drueckt die intern hinterlegte Verlaesslichkeit der Zuordnung aus. ") + b.WriteString("Vor Verwendung in einem Drittmarkt durch eine sachkundige Person verifizieren.\n\n") + b.WriteString("| EU Norm (verwendet) | Region | International Identifier | Relation | Confidence | Hinweis |\n") + b.WriteString("|---------------------|--------|--------------------------|----------|------------|---------|\n") + + for _, row := range rows { + note := row.Notes + if note == "" { + note = "—" + } + // Escape pipes in identifier and note for markdown table safety. + fmt.Fprintf(&b, + "| %s | %s | %s | %s | %s | %s |\n", + escapeCell(row.SourceNorm), + escapeCell(row.Region), + escapeCell(row.Identifier), + escapeCell(row.Relation), + escapeCell(row.Confidence), + escapeCell(note), + ) + } + + b.WriteString("\n*Quelle: BreakPilot Cross-Reference Matrix. Keine Originalnormtexte reproduziert — nur Identifikatoren. Stand: Bezugsperiode der jeweiligen Norm-Bibliothek.*\n") + return b.String() +} + +// crossRefRow is a flattened row of the matrix used by the renderer. +type crossRefRow struct { + SourceNorm string + Region string + Identifier string + Relation string + Confidence string + Notes string +} + +// collectCrossRefRows expands the per-norm mapping list into a sorted slice +// of rows. Sort order: source norm ID first, then region in a canonical +// regional order so EU markets appear before non-EU. +func collectCrossRefRows(normIDs []string) []crossRefRow { + regionRank := map[string]int{ + "EU-DIN": 0, + "INTL-ISO": 1, + "US-ANSI": 2, + "US-NFPA": 3, + "US-UL": 4, + "US-OSHA": 5, + "US-ASME": 6, + "US-ASTM": 7, + "US-SAE": 8, + "US-NIOSH": 9, + "US-FDA": 10, + "US-EPA": 11, + "US-NEMA": 12, + "US-NSF": 13, + "US-API": 14, + "US-CPSC": 15, + "US-AHRI": 16, + "US-ASHRAE": 17, + "US-FCC": 18, + "US-DOT": 19, + "US-MSHA": 20, + "US-FM": 21, + "US-AAR": 22, + "US-ACI": 23, + "US-ADA": 24, + "US-AAMA": 25, + "US-APA": 26, + "US-APSP": 27, + "US-EJMA": 28, + "US-ICC": 29, + "US-SMACNA": 30, + "CN-GB": 40, + "JP-JIS": 50, + } + + seen := make(map[string]bool) + var rows []crossRefRow + for _, id := range normIDs { + if seen[id] { + continue + } + seen[id] = true + cr := GetNormCrossRef(id) + for _, m := range cr.Mappings { + rows = append(rows, crossRefRow{ + SourceNorm: id, + Region: m.Region, + Identifier: m.Identifier, + Relation: m.Relation, + Confidence: m.Confidence, + Notes: m.Notes, + }) + } + } + + sort.SliceStable(rows, func(i, j int) bool { + if rows[i].SourceNorm != rows[j].SourceNorm { + return rows[i].SourceNorm < rows[j].SourceNorm + } + ri, ok := regionRank[rows[i].Region] + if !ok { + ri = 99 + } + rj, ok := regionRank[rows[j].Region] + if !ok { + rj = 99 + } + return ri < rj + }) + return rows +} + +// escapeCell escapes pipes and newlines so a Markdown table cell does not break. +func escapeCell(s string) string { + s = strings.ReplaceAll(s, "|", "\\|") + s = strings.ReplaceAll(s, "\n", " ") + return s +} diff --git a/ai-compliance-sdk/internal/iace/norm_crossref_renderer_test.go b/ai-compliance-sdk/internal/iace/norm_crossref_renderer_test.go new file mode 100644 index 00000000..df59e9d5 --- /dev/null +++ b/ai-compliance-sdk/internal/iace/norm_crossref_renderer_test.go @@ -0,0 +1,85 @@ +package iace + +import ( + "strings" + "testing" +) + +func TestRenderCrossRefAppendix_EmptyInput(t *testing.T) { + got := RenderCrossRefAppendix(nil) + if got != "" { + t.Errorf("expected empty string for nil input, got %d bytes", len(got)) + } +} + +func TestRenderCrossRefAppendix_UnknownIDs(t *testing.T) { + got := RenderCrossRefAppendix([]string{"ISO-DOES-NOT-EXIST", "EN-ALSO-MISSING"}) + if got != "" { + t.Errorf("expected empty string when no IDs match, got:\n%s", got) + } +} + +func TestRenderCrossRefAppendix_ISO12100_RendersAllRegions(t *testing.T) { + got := RenderCrossRefAppendix([]string{"ISO-12100"}) + if got == "" { + t.Fatal("expected non-empty appendix for ISO-12100") + } + for _, want := range []string{ + "## Anhang: Internationale Aequivalenzen", + "ISO-12100", + "EU-DIN", + "US-ANSI", + "CN-GB", + "JP-JIS", + "DIN EN ISO 12100", + "GB/T 15706", + } { + if !strings.Contains(got, want) { + t.Errorf("expected appendix to contain %q, got:\n%s", want, got) + } + } +} + +func TestRenderCrossRefAppendix_RegionOrdering(t *testing.T) { + got := RenderCrossRefAppendix([]string{"EN-60204-1"}) + if got == "" { + t.Fatal("expected non-empty appendix for EN-60204-1") + } + // EU-DIN must appear before US-NFPA which must appear before CN-GB. + euIdx := strings.Index(got, "EU-DIN") + usIdx := strings.Index(got, "US-NFPA") + cnIdx := strings.Index(got, "CN-GB") + if euIdx < 0 || usIdx < 0 || cnIdx < 0 { + t.Fatalf("missing one of EU-DIN/US-NFPA/CN-GB markers, got:\n%s", got) + } + if !(euIdx < usIdx && usIdx < cnIdx) { + t.Errorf("expected region order EU-DIN < US-NFPA < CN-GB, got positions %d, %d, %d", euIdx, usIdx, cnIdx) + } +} + +func TestRenderCrossRefAppendix_MultipleNorms_SortedByID(t *testing.T) { + got := RenderCrossRefAppendix([]string{"ISO-13850", "ISO-12100", "EN-60204-1"}) + if got == "" { + t.Fatal("expected non-empty appendix") + } + // Expect EN-60204-1 first (alphabetical), then ISO-12100, then ISO-13850. + en := strings.Index(got, "EN-60204-1") + iso12100 := strings.Index(got, "ISO-12100") + iso13850 := strings.Index(got, "ISO-13850") + if en < 0 || iso12100 < 0 || iso13850 < 0 { + t.Fatalf("missing one of the IDs in output:\n%s", got) + } + if !(en < iso12100 && iso12100 < iso13850) { + t.Errorf("expected source-norm ordering by alphabetical ID, got positions %d, %d, %d", en, iso12100, iso13850) + } +} + +func TestRenderCrossRefAppendix_PipeEscape(t *testing.T) { + got := RenderCrossRefAppendix([]string{"ISO-12100"}) + // Find a line that came from a mapping with the pipe character — none of + // our identifiers contain literal '|' so this just checks that the table + // header is intact (no accidental pipe injection). + if !strings.Contains(got, "| EU Norm (verwendet) |") { + t.Errorf("table header malformed:\n%s", got) + } +} diff --git a/ai-compliance-sdk/internal/iace/tech_file_generator.go b/ai-compliance-sdk/internal/iace/tech_file_generator.go index 1c7627cb..49ff9f4f 100644 --- a/ai-compliance-sdk/internal/iace/tech_file_generator.go +++ b/ai-compliance-sdk/internal/iace/tech_file_generator.go @@ -153,8 +153,61 @@ func (g *TechFileGenerator) GenerateSection(ctx context.Context, projectID uuid. }) if err != nil { // LLM unavailable — return structured fallback with real project data - return buildFallbackContent(sctx, sectionType), nil + return appendCrossRefIfApplicable(buildFallbackContent(sctx, sectionType), sctx, sectionType), nil } - return resp.Message.Content, nil + return appendCrossRefIfApplicable(resp.Message.Content, sctx, sectionType), nil +} + +// appendCrossRefIfApplicable adds the international cross-reference appendix +// (DIN/ANSI/GB/JIS) to the "standards_applied" section. For other section +// types it returns content unchanged. The appendix is built deterministically +// from the in-process registry, so it is never hallucinated by the LLM. +func appendCrossRefIfApplicable(content string, sctx *SectionGenerationContext, sectionType string) string { + if sectionType != SectionStandardsApplied { + return content + } + normIDs := suggestNormIDsForProject(sctx) + appendix := RenderCrossRefAppendix(normIDs) + if appendix == "" { + return content + } + return content + appendix +} + +// suggestNormIDsForProject reuses the existing SuggestNorms heuristic to pick +// the norms most likely applicable to this project. We only need the IDs; +// the rest of the SuggestNorms output (scores, reasons) is discarded. +func suggestNormIDsForProject(sctx *SectionGenerationContext) []string { + if sctx == nil || sctx.Project == nil { + return nil + } + hazardCats := make([]string, 0, len(sctx.Hazards)) + seenCat := map[string]bool{} + for _, h := range sctx.Hazards { + if h.Category != "" && !seenCat[h.Category] { + seenCat[h.Category] = true + hazardCats = append(hazardCats, h.Category) + } + } + result := SuggestNorms(sctx.Project.MachineType, hazardCats, nil) + if result == nil { + return nil + } + ids := make([]string, 0, result.Total) + seenID := map[string]bool{} + push := func(suggs []NormSuggestion) { + for _, s := range suggs { + if s.Norm.ID == "" || seenID[s.Norm.ID] { + continue + } + seenID[s.Norm.ID] = true + ids = append(ids, s.Norm.ID) + } + } + push(result.ANorms) + push(result.B1Norms) + push(result.B2Norms) + push(result.CNorms) + return ids }