feat(iace): wire crossref into tech-file, library UI, and contract tests
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user