From 6b41eec1767136e9cb2cb2cfd7ccfe6166fffc99 Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Thu, 11 Jun 2026 13:39:48 +0200 Subject: [PATCH] =?UTF-8?q?feat(iace):=20surface=20OSHA=20distance=20ancho?= =?UTF-8?q?r=20in=20Ma=C3=9Fnahmen=20tab=20(name-resolved)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Makes the OSHA minimum-distance anchor visible per measure in a project without a DB schema change or re-seed: persisted mitigations store the measure NAME verbatim (not the catalog ID), and measure names are unique across the 578-entry library (pinned by test), so a name→ID resolver bridges the gap. Backend: MeasureIDByName + MinimumDistancesForMeasureName/LinksForMeasureName; /iace/minimum-distances now accepts ?measure_name=; link table enriched with measure_name for one-request UI matching. Frontend: useMinimumDistances loads the link table once and keys it by name; OshaDistanceNote renders the anchor (value/CFR/license/EU-hint/relation) on the matching measure group in the Maßnahmen tab. Co-Authored-By: Claude Opus 4.7 --- .../_components/OshaDistanceNote.tsx | 53 +++++++++++++ .../mitigations/_hooks/useMinimumDistances.ts | 77 +++++++++++++++++++ .../sdk/iace/[projectId]/mitigations/page.tsx | 6 ++ .../api/handlers/iace_handler_distances.go | 21 +++-- .../internal/iace/minimum_distances.go | 45 ++++++++++- .../internal/iace/minimum_distances_test.go | 32 ++++++++ 6 files changed, 226 insertions(+), 8 deletions(-) create mode 100644 admin-compliance/app/sdk/iace/[projectId]/mitigations/_components/OshaDistanceNote.tsx create mode 100644 admin-compliance/app/sdk/iace/[projectId]/mitigations/_hooks/useMinimumDistances.ts diff --git a/admin-compliance/app/sdk/iace/[projectId]/mitigations/_components/OshaDistanceNote.tsx b/admin-compliance/app/sdk/iace/[projectId]/mitigations/_components/OshaDistanceNote.tsx new file mode 100644 index 00000000..9b430503 --- /dev/null +++ b/admin-compliance/app/sdk/iace/[projectId]/mitigations/_components/OshaDistanceNote.tsx @@ -0,0 +1,53 @@ +'use client' + +import { OshaAnchor, MinimumDistance } from '../_hooks/useMinimumDistances' + +const RELATION_LABEL: Record = { + value_source: 'Wertquelle', + public_domain_crossref: 'Public-Domain-Pendant', +} + +function distanceLine(d: MinimumDistance): string { + if (d.formula_description) return d.formula_description + if (d.recommended_min_mm && d.recommended_max_mm) + return `${d.recommended_min_mm}–${d.recommended_max_mm} mm (empfohlen, sicherheitsseitig gerundet)` + if (d.recommended_mm) return `${d.recommended_mm} mm (empfohlen, sicherheitsseitig gerundet)` + return d.context +} + +/** Renders the OSHA safety-distance anchor for one measure (audit view). */ +export function OshaDistanceNote({ entry }: { entry: OshaAnchor }) { + const { link, distances } = entry + if (!distances.length) return null + + return ( +
+
+
+ + OSHA-Sicherheitsabstand + + + {RELATION_LABEL[link.relation] || link.relation} + +
+ {distances.map((d) => ( +
+ {distanceLine(d)} + + {' · '} + {d.source_cfr} · {d.license} + + {(d.eu_norm_hints || []).map((h, i) => ( + + EU: {h.norm} + {h.din_comparison_note ? ` — ${h.din_comparison_note}` : ''} + + ))} +
+ ))} + {link.note &&

{link.note}

} +
+
+ ) +} diff --git a/admin-compliance/app/sdk/iace/[projectId]/mitigations/_hooks/useMinimumDistances.ts b/admin-compliance/app/sdk/iace/[projectId]/mitigations/_hooks/useMinimumDistances.ts new file mode 100644 index 00000000..20f4a160 --- /dev/null +++ b/admin-compliance/app/sdk/iace/[projectId]/mitigations/_hooks/useMinimumDistances.ts @@ -0,0 +1,77 @@ +'use client' + +import { useEffect, useState } from 'react' + +export interface EuNormHint { + norm: string + section?: string + din_comparison_note?: string +} + +export interface MinimumDistance { + id: string + source_cfr?: string + source_table?: string + license: string + context: string + body_part?: string + recommended_mm?: number + recommended_min_mm?: number + recommended_max_mm?: number + formula_description?: string + formula_mm_per_second?: number + rounding_note?: string + eu_norm_hints?: EuNormHint[] +} + +export interface MeasureDistanceLink { + measure_id: string + measure_name?: string + distance_ids: string[] + relation: string // "value_source" | "public_domain_crossref" + note?: string +} + +export interface OshaAnchor { + link: MeasureDistanceLink + distances: MinimumDistance[] +} + +/** + * Loads the OSHA minimum-distance link table ONCE and returns a lookup keyed by + * (lower-cased) measure name. A persisted mitigation stores the measure name + * verbatim, so the Maßnahmen tab can surface the OSHA anchor by matching on name + * — no per-row request, no catalog ID needed. + */ +export function useMinimumDistances() { + const [byName, setByName] = useState>({}) + + useEffect(() => { + let cancelled = false + async function load() { + try { + const res = await fetch('/api/sdk/v1/iace/minimum-distances') + if (!res.ok) return + const json = (await res.json()) as { distances: MinimumDistance[]; links: MeasureDistanceLink[] } + const byId = Object.fromEntries((json.distances || []).map((d) => [d.id, d])) + const map: Record = {} + for (const link of json.links || []) { + if (!link.measure_name) continue + map[link.measure_name.toLowerCase()] = { + link, + distances: link.distance_ids.map((id) => byId[id]).filter(Boolean), + } + } + if (!cancelled) setByName(map) + } catch (err) { + console.error('Failed to load minimum distances:', err) + } + } + load() + return () => { + cancelled = true + } + }, []) + + return { byName } +} diff --git a/admin-compliance/app/sdk/iace/[projectId]/mitigations/page.tsx b/admin-compliance/app/sdk/iace/[projectId]/mitigations/page.tsx index af128dc0..d56f657a 100644 --- a/admin-compliance/app/sdk/iace/[projectId]/mitigations/page.tsx +++ b/admin-compliance/app/sdk/iace/[projectId]/mitigations/page.tsx @@ -9,6 +9,8 @@ import { SuggestMeasuresModal } from './_components/SuggestMeasuresModal' import { MitigationForm } from './_components/MitigationForm' import { StatusBadge } from './_components/StatusBadge' import { MitigationHints } from './_components/MitigationHints' +import { OshaDistanceNote } from './_components/OshaDistanceNote' +import { useMinimumDistances } from './_hooks/useMinimumDistances' import { ProtectiveMeasure } from './_components/types' import { useMitigations } from './_hooks/useMitigations' @@ -24,6 +26,7 @@ export default function MitigationsPage() { } = useMitigations(projectId) const [measureNorms, setMeasureNorms] = useState>({}) + const { byName: oshaByName } = useMinimumDistances() useEffect(() => { fetch('/api/sdk/v1/iace/protective-measures-library') @@ -276,6 +279,9 @@ export default function MitigationsPage() { {refs?.length > 0 && (

Normen: {refs.join(', ')}

)} + {oshaByName[title.toLowerCase()] && ( + + )} {instances.map((m) => { const isDetailOpen = expandedMeasure === m.id return ( diff --git a/ai-compliance-sdk/internal/api/handlers/iace_handler_distances.go b/ai-compliance-sdk/internal/api/handlers/iace_handler_distances.go index 9f0022cc..b9ed6ff6 100644 --- a/ai-compliance-sdk/internal/api/handlers/iace_handler_distances.go +++ b/ai-compliance-sdk/internal/api/handlers/iace_handler_distances.go @@ -10,15 +10,22 @@ import ( // ListMinimumDistances handles GET /minimum-distances. // Read-only OSHA safety-distance reference (29 CFR 1910, US public domain) // plus the curated measure→distance link table, so an auditor can see WHERE a -// measure's mm figure comes from. Optional ?measure_id= returns only the -// distances (and links) tied to that protective measure. +// measure's mm figure comes from. Scope to one measure via ?measure_id= or +// ?measure_name= (the latter lets a persisted mitigation — which stores the +// name, not the catalog ID — resolve its anchor without a schema change). func (h *IACEHandler) ListMinimumDistances(c *gin.Context) { - if mid := c.Query("measure_id"); mid != "" { + mid := c.Query("measure_id") + mname := c.Query("measure_name") + if mid == "" && mname != "" { + mid, _ = iace.MeasureIDByName(mname) // "" if unknown → empty scoped result + } + if mid != "" || mname != "" { c.JSON(http.StatusOK, gin.H{ - "measure_id": mid, - "distances": iace.MinimumDistancesForMeasure(mid), - "links": iace.LinksForMeasure(mid), - "note": iace.MinimumDistanceNote, + "measure_id": mid, + "measure_name": mname, + "distances": iace.MinimumDistancesForMeasure(mid), + "links": iace.LinksForMeasure(mid), + "note": iace.MinimumDistanceNote, }) return } diff --git a/ai-compliance-sdk/internal/iace/minimum_distances.go b/ai-compliance-sdk/internal/iace/minimum_distances.go index 0b295129..dad3fbc1 100644 --- a/ai-compliance-sdk/internal/iace/minimum_distances.go +++ b/ai-compliance-sdk/internal/iace/minimum_distances.go @@ -200,6 +200,7 @@ func GetMinimumDistanceByID(id string) (MinimumDistance, bool) { // OSHA entry is offered as the public-domain pendant for independent check. type MeasureDistanceLink struct { MeasureID string `json:"measure_id"` + MeasureName string `json:"measure_name,omitempty"` // resolved from the library for name-based UI matching DistanceIDs []string `json:"distance_ids"` Relation string `json:"relation"` Note string `json:"note,omitempty"` @@ -216,7 +217,7 @@ const ( // ISO value (e.g. M340 robot teach speed, M368 air-receiver wall) are NOT // linked — that would imply a public-domain anchor that does not exist. func AllMeasureDistanceLinks() []MeasureDistanceLink { - return []MeasureDistanceLink{ + links := []MeasureDistanceLink{ { MeasureID: "M600", DistanceIDs: []string{"MD_OSHA_217_PSDI"}, @@ -236,6 +237,18 @@ func AllMeasureDistanceLinks() []MeasureDistanceLink { Note: "OSHA §1910.212(a)(5) Luefterschutz (max. 12 mm Spaltoeffnung) als Public-Domain-Pendant zu ISO 13857.", }, } + // Enrich each link with the measure name so a name-based UI (persisted + // mitigations carry the name, not the ID) can match without a second lookup. + lib := GetProtectiveMeasureLibrary() + for i := range links { + for _, m := range lib { + if m.ID == links[i].MeasureID { + links[i].MeasureName = m.Name + break + } + } + } + return links } // LinksForMeasure returns the distance links declared for one measure. @@ -263,3 +276,33 @@ func MinimumDistancesForMeasure(measureID string) []MinimumDistance { } return out } + +// MeasureIDByName resolves a protective-measure name to its catalog ID. Measure +// names are unique across the library (verified by test), so a PERSISTED +// mitigation — which stores the measure name verbatim but NOT the catalog ID — +// can still be joined to its OSHA distance link without a DB schema change or a +// re-seed. This is the bridge that lets a project's measures surface the anchor. +func MeasureIDByName(name string) (string, bool) { + for _, m := range GetProtectiveMeasureLibrary() { + if m.Name == name { + return m.ID, true + } + } + return "", false +} + +// MinimumDistancesForMeasureName resolves OSHA distances by measure NAME. +func MinimumDistancesForMeasureName(name string) []MinimumDistance { + if id, ok := MeasureIDByName(name); ok { + return MinimumDistancesForMeasure(id) + } + return nil +} + +// LinksForMeasureName resolves distance links by measure NAME. +func LinksForMeasureName(name string) []MeasureDistanceLink { + if id, ok := MeasureIDByName(name); ok { + return LinksForMeasure(id) + } + return nil +} diff --git a/ai-compliance-sdk/internal/iace/minimum_distances_test.go b/ai-compliance-sdk/internal/iace/minimum_distances_test.go index 4ca3db15..92521019 100644 --- a/ai-compliance-sdk/internal/iace/minimum_distances_test.go +++ b/ai-compliance-sdk/internal/iace/minimum_distances_test.go @@ -113,6 +113,38 @@ func TestMeasureDistanceLinks_ValueSourceProseConsistency(t *testing.T) { } } +// The name→ID resolver underpins surfacing OSHA anchors on persisted +// mitigations (which store the name, not the catalog ID). It is only safe if +// measure names are unique — pin that, and that the resolver round-trips for +// every linked measure. +func TestMeasureNames_UniqueAndResolvable(t *testing.T) { + idByName := map[string]string{} + nameByID := map[string]string{} + for _, m := range GetProtectiveMeasureLibrary() { + if prev, dup := idByName[m.Name]; dup { + t.Errorf("duplicate measure name %q (IDs %s, %s) — name→ID resolver unsafe", + m.Name, prev, m.ID) + } + idByName[m.Name] = m.ID + nameByID[m.ID] = m.Name + } + + for _, l := range AllMeasureDistanceLinks() { + name := nameByID[l.MeasureID] + if name == "" { + t.Errorf("linked measure %q not found in library", l.MeasureID) + continue + } + gotID, ok := MeasureIDByName(name) + if !ok || gotID != l.MeasureID { + t.Errorf("MeasureIDByName(%q) = %q,%v; want %q", name, gotID, ok, l.MeasureID) + } + if len(MinimumDistancesForMeasureName(name)) != len(MinimumDistancesForMeasure(l.MeasureID)) { + t.Errorf("name vs id resolution mismatch for %q", l.MeasureID) + } + } +} + // proseMentionsDistance reports whether the (dot-stripped, lowercased) measure // text contains a numeric form of the distance's value, formula or recommended mm. func proseMentionsDistance(text string, md MinimumDistance) bool {