Files
breakpilot-compliance/ai-compliance-sdk/internal/api/handlers/iace_handler_norms.go
T
Benjamin Admin ff100c1cb8 feat(iace): norm cross-reference matrix, batch 1 (ISO/DIN/ANSI/GB/JIS — 100 entries)
Adds a jurisdiction-cross-reference layer to the norms library. Each entry
maps an ISO/IEC/EN norm to its identifier in DIN (DE), ANSI/NFPA/UL/OSHA (US),
GB (CN), and JIS (JP), with explicit Relation (identical/equivalent/partial/
superseded_by/supersedes) and Confidence (verified/high/medium/low) fields.

Batch 1 covers IDs 1-100 in load order:
  - 1a (50): A-norms + B1-norms + early B2-norms (ergonomics, vibration, noise)
  - 1b (50): remaining B2 (ATEX, EMC, cybersec) + first C-norms (presses,
    robots, conveyors, plastics, woodworking)

These are the foundational, internationally harmonized standards with the
strongest verified mappings (ISO 12100 ~> GB 15706 ~> JIS B 9700, EN 60204-1
~> NFPA 79 ~> GB 5226.1 ~> JIS B 9960-1, etc.).

API:
  - GET /iace/norms-library?include_crossref=true  → inline crossref
  - GET /iace/norms-library/:id/crossref           → single norm lookup
  - GET /iace/norms-library/crossref               → bulk dump

Strategic context: enables dual-use CE/US/CN/JP tech files without
re-authoring, and addresses the "Norm Translation Matrix" gap that the
US-export strategy memory entry calls out. 6 batches remaining (~571 norms)
to reach full library coverage.

Tests: 6 new tests; all pass via `go test -vet=off ./internal/iace/`.
(vet=off needed only to bypass an unrelated pre-existing typo in
 document_export_sources.go.)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 09:02:05 +02:00

249 lines
7.0 KiB
Go

package handlers
import (
"encoding/json"
"net/http"
"strings"
"github.com/breakpilot/ai-compliance-sdk/internal/iace"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// ============================================================================
// Norms Library & Norm Suggestions
// ============================================================================
// ListNormsLibrary handles GET /norms-library
// Returns the full norms library, optionally filtered by ?type=A|B1|B2|C
// and ?hazard_category=xxx.
func (h *IACEHandler) ListNormsLibrary(c *gin.Context) {
normType := c.Query("type")
hazardCat := c.Query("hazard_category")
allNorms := iace.GetNormsLibrary()
allNorms = append(allNorms, iace.GetExtendedB2Norms()...)
allNorms = append(allNorms, iace.GetCNormsLibrary()...)
allNorms = append(allNorms, iace.GetExtendedCNormsLibrary()...)
allNorms = append(allNorms, iace.GetWoodMetalCNorms()...)
allNorms = append(allNorms, iace.GetFoodPkgCNorms()...)
allNorms = append(allNorms, iace.GetLiftMiscCNorms()...)
allNorms = append(allNorms, iace.GetMachiningCNorms()...)
allNorms = append(allNorms, iace.GetConveyorAutoCNorms()...)
allNorms = append(allNorms, iace.GetProcessCNorms()...)
allNorms = append(allNorms, iace.GetConstructionCNorms()...)
allNorms = append(allNorms, iace.GetNiche1CNorms()...)
allNorms = append(allNorms, iace.GetNiche2CNorms()...)
allNorms = append(allNorms, iace.GetNiche3CNorms()...)
allNorms = append(allNorms, iace.GetExtendedB2Norms2()...)
allNorms = append(allNorms, iace.GetWave3aCNorms()...)
allNorms = append(allNorms, iace.GetWave3a2CNorms()...)
allNorms = append(allNorms, iace.GetWave3bCNorms()...)
allNorms = append(allNorms, iace.GetWave3cCNorms()...)
allNorms = append(allNorms, iace.GetWave3c2CNorms()...)
allNorms = append(allNorms, iace.GetWave3dCNorms()...)
allNorms = append(allNorms, iace.GetWave3dExtCNorms()...)
allNorms = append(allNorms, iace.GetWave3dHvacCNorms()...)
allNorms = append(allNorms, iace.GetFinalCNorms()...)
includeCrossRef := c.Query("include_crossref") == "true"
var filtered []iace.NormReference
for _, norm := range allNorms {
if normType != "" && norm.NormType != normType {
continue
}
if hazardCat != "" && !containsString(norm.HazardCats, hazardCat) {
continue
}
if includeCrossRef {
cr := iace.GetNormCrossRef(norm.ID)
if len(cr.Mappings) > 0 {
norm.CrossRef = &cr
}
}
filtered = append(filtered, norm)
}
if filtered == nil {
filtered = []iace.NormReference{}
}
covered, total := iace.CrossRefCoverage(len(allNorms))
c.JSON(http.StatusOK, gin.H{
"norms": filtered,
"total": len(filtered),
"crossref_coverage": gin.H{
"covered": covered,
"total_norms": total,
},
})
}
// GetNormCrossRef handles GET /norms-library/:id/crossref
// Returns the international cross-reference (DIN/ANSI/GB/JIS/...) for a single norm.
func (h *IACEHandler) GetNormCrossRef(c *gin.Context) {
normID := c.Param("id")
if normID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "norm id required"})
return
}
cr := iace.GetNormCrossRef(normID)
c.JSON(http.StatusOK, cr)
}
// ListNormCrossRefs handles GET /norms-library/crossref
// Returns the entire cross-reference matrix (all populated entries).
func (h *IACEHandler) ListNormCrossRefs(c *gin.Context) {
entries := iace.ListNormCrossRefs()
c.JSON(http.StatusOK, gin.H{
"entries": entries,
"total": len(entries),
})
}
// SuggestProjectNorms handles GET /projects/:id/suggested-norms
// Returns norm suggestions based on the project's machine type, identified
// hazards, and component tags.
func (h *IACEHandler) SuggestProjectNorms(c *gin.Context) {
projectID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"})
return
}
// Fetch project to get machine type
project, err := h.store.GetProject(c.Request.Context(), projectID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if project == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "project not found"})
return
}
// Collect unique hazard categories from project hazards
hazardCategories := collectHazardCategories(h, c, projectID)
// Collect tags from component metadata
tags := collectComponentTags(h, c, projectID)
result := iace.SuggestNorms(project.MachineType, hazardCategories, tags)
c.JSON(http.StatusOK, gin.H{
"suggestions": result,
"machine_type": project.MachineType,
"hazard_categories": hazardCategories,
"tags": tags,
})
}
// collectHazardCategories extracts unique hazard categories from a project's hazards.
func collectHazardCategories(h *IACEHandler, c *gin.Context, projectID uuid.UUID) []string {
hazards, err := h.store.ListHazards(c.Request.Context(), projectID)
if err != nil {
return []string{}
}
seen := make(map[string]bool)
var categories []string
for _, hz := range hazards {
if hz.Category != "" && !seen[hz.Category] {
seen[hz.Category] = true
categories = append(categories, hz.Category)
}
}
return categories
}
// collectComponentTags extracts tags from the metadata JSON of project components.
// Components store tags in their metadata field as {"tags": ["tag1", "tag2"]}.
// Additionally, component types are mapped to tags via the component library.
func collectComponentTags(h *IACEHandler, c *gin.Context, projectID uuid.UUID) []string {
components, err := h.store.ListComponents(c.Request.Context(), projectID)
if err != nil {
return []string{}
}
seen := make(map[string]bool)
var tags []string
for _, comp := range components {
// Extract tags from metadata JSON
extracted := extractTagsFromMetadata(comp.Metadata)
for _, t := range extracted {
if !seen[t] {
seen[t] = true
tags = append(tags, t)
}
}
// Extract tags from description field ("Tags: x, y, z" pattern)
descTags := parseDescriptionTags(comp.Description)
for _, t := range descTags {
if !seen[t] {
seen[t] = true
tags = append(tags, t)
}
}
}
return tags
}
// extractTagsFromMetadata parses the component metadata JSON for a "tags" array.
func extractTagsFromMetadata(metadata json.RawMessage) []string {
if len(metadata) == 0 {
return nil
}
var m map[string]interface{}
if err := json.Unmarshal(metadata, &m); err != nil {
return nil
}
tagsRaw, ok := m["tags"]
if !ok {
return nil
}
arr, ok := tagsRaw.([]interface{})
if !ok {
return nil
}
var tags []string
for _, v := range arr {
if s, ok := v.(string); ok && s != "" {
tags = append(tags, s)
}
}
return tags
}
// parseDescriptionTags looks for a "Tags: x, y, z" pattern in the description.
func parseDescriptionTags(description string) []string {
idx := strings.Index(strings.ToLower(description), "tags:")
if idx < 0 {
return nil
}
// Take everything after "Tags:"
rest := strings.TrimSpace(description[idx+5:])
if rest == "" {
return nil
}
// Split by comma and trim each tag
parts := strings.Split(rest, ",")
var tags []string
for _, p := range parts {
t := strings.TrimSpace(p)
if t != "" {
tags = append(tags, t)
}
}
return tags
}