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:
Benjamin Admin
2026-05-22 09:48:07 +02:00
parent cf6005a47c
commit 0a84c747f2
6 changed files with 587 additions and 2 deletions
@@ -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<string, string> = {
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<string, string> = {
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<string, string> = {
'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<string | null>(null)
const [data, setData] = useState<CrossRefResponse | null>(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 (
<button
type="button"
onClick={handleLoad}
className="text-xs text-purple-600 hover:text-purple-800 dark:text-purple-400 dark:hover:text-purple-200 font-medium underline-offset-2 hover:underline"
>
Internationale Aequivalenzen anzeigen (DIN/ANSI/GB/JIS)
</button>
)
}
if (loading) {
return <div className="text-xs text-gray-500 dark:text-gray-400">Cross-Reference wird geladen</div>
}
if (error) {
return (
<div className="text-xs text-red-600 dark:text-red-400">
Cross-Reference konnte nicht geladen werden: {error}
</div>
)
}
if (!data || data.mappings.length === 0) {
return (
<div className="text-xs text-gray-500 dark:text-gray-400 italic">
Fuer diese Norm liegt (noch) kein internationales Mapping in der Bibliothek vor.
</div>
)
}
return (
<div className="space-y-2 mt-2 border-t border-gray-200 dark:border-gray-700 pt-2">
<div className="text-xs font-medium text-gray-700 dark:text-gray-300">
Internationale Aequivalenzen
</div>
{data.notes && (
<div className="text-xs text-gray-500 dark:text-gray-400 italic">{data.notes}</div>
)}
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="text-gray-500 dark:text-gray-400 border-b border-gray-200 dark:border-gray-700">
<th className="text-left py-1 pr-3 font-medium">Region</th>
<th className="text-left py-1 pr-3 font-medium">Identifier</th>
<th className="text-left py-1 pr-3 font-medium">Relation</th>
<th className="text-left py-1 pr-3 font-medium">Confidence</th>
</tr>
</thead>
<tbody>
{data.mappings.map((m, i) => (
<tr key={i} className="border-b border-gray-100 dark:border-gray-800 last:border-0 align-top">
<td className="py-1 pr-3 text-gray-600 dark:text-gray-400 whitespace-nowrap">{formatRegion(m.region)}</td>
<td className="py-1 pr-3 font-mono text-gray-800 dark:text-gray-200">
{m.source_url ? (
<a href={m.source_url} target="_blank" rel="noopener noreferrer" className="text-purple-600 hover:text-purple-800 dark:text-purple-400">
{m.identifier}
</a>
) : (
m.identifier
)}
{m.notes && (
<div className="text-[10px] text-gray-500 dark:text-gray-400 mt-0.5 font-sans">{m.notes}</div>
)}
</td>
<td className="py-1 pr-3">
<span className={`inline-block px-1.5 py-0.5 rounded ${RELATION_COLORS[m.relation] || 'bg-gray-100 dark:bg-gray-800 text-gray-600'}`}>
{m.relation}
</span>
</td>
<td className={`py-1 pr-3 ${CONFIDENCE_COLORS[m.confidence] || 'text-gray-600'}`}>
{m.confidence}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="text-[10px] text-gray-400 dark:text-gray-500">
Vor Nutzung in einem Drittmarkt durch eine sachkundige Person verifizieren.
</div>
</div>
)
}
@@ -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) => <span key={t} className="px-1.5 py-0.5 rounded text-xs bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300">{t}</span>)}
</div>
)}
<NormCrossRefPanel normId={n.id} />
</div>
}
/>