From 230dc052877d1a6c57f6c67dfcc6a17073911bbc Mon Sep 17 00:00:00 2001 From: Benjamin_Boenisch Date: Wed, 24 Jun 2026 06:37:22 +0000 Subject: [PATCH 01/16] feat(ai-sdk): legal-corpus coverage + Phase-2 citation-graph assessment (#33) --- admin-compliance/app/sdk/coverage/_helpers.ts | 22 +++ admin-compliance/app/sdk/coverage/page.tsx | 86 ++++++++- .../internal/api/handlers/rag_handlers.go | 36 +++- ai-compliance-sdk/internal/app/routes.go | 1 + .../internal/ucca/authority_rerank.go | 23 ++- .../internal/ucca/legal_corpus_structure.go | 167 ++++++++++++++++++ .../ucca/legal_corpus_structure_test.go | 50 ++++++ .../internal/ucca/legal_rag_assess.go | 134 ++++++++++++++ .../internal/ucca/legal_rag_assess_test.go | 112 ++++++++++++ .../internal/ucca/legal_rag_client.go | 18 ++ .../internal/ucca/legal_rag_graph.go | 162 +++++++++++++++++ .../internal/ucca/legal_rag_graph_test.go | 89 ++++++++++ .../internal/ucca/legal_rag_supersede_test.go | 30 ++++ .../internal/ucca/legal_rag_types.go | 25 +++ 14 files changed, 941 insertions(+), 14 deletions(-) create mode 100644 ai-compliance-sdk/internal/ucca/legal_corpus_structure.go create mode 100644 ai-compliance-sdk/internal/ucca/legal_corpus_structure_test.go create mode 100644 ai-compliance-sdk/internal/ucca/legal_rag_assess.go create mode 100644 ai-compliance-sdk/internal/ucca/legal_rag_assess_test.go create mode 100644 ai-compliance-sdk/internal/ucca/legal_rag_graph.go create mode 100644 ai-compliance-sdk/internal/ucca/legal_rag_graph_test.go create mode 100644 ai-compliance-sdk/internal/ucca/legal_rag_supersede_test.go diff --git a/admin-compliance/app/sdk/coverage/_helpers.ts b/admin-compliance/app/sdk/coverage/_helpers.ts index bc56be0f..fb138dc5 100644 --- a/admin-compliance/app/sdk/coverage/_helpers.ts +++ b/admin-compliance/app/sdk/coverage/_helpers.ts @@ -46,6 +46,28 @@ export interface CorpusOverview { totals: { documents: number; catalog_sources: number } } +// --- Ingested legal-corpus structure (from the vector store, via the Go SDK). +// Shows WHAT each eur-lex act consists of (articles/annexes/recitals), so the +// ingested corpus is not a black box for developers. --- +export interface LegalActStructure { + regulation_short: string + regulation_name: string + articles: number + annexes: number + recitals: number + chunks: number +} + +export interface LegalCorpus { + regulations: LegalActStructure[] + totals: { + regulations: number + articles: number + annexes: number + recitals: number + } +} + // --- Korpus-Dokumente: gruppieren nach Art (Gesetz/Leitfaden/Standard/Urteil) // + Herausgeber-Familie (DSK, EDPB, OWASP, NIST …). Deterministisch, pure. --- interface DocCat { diff --git a/admin-compliance/app/sdk/coverage/page.tsx b/admin-compliance/app/sdk/coverage/page.tsx index dc73df1e..7f254b8b 100644 --- a/admin-compliance/app/sdk/coverage/page.tsx +++ b/admin-compliance/app/sdk/coverage/page.tsx @@ -3,6 +3,7 @@ import Link from 'next/link' import { type UseCaseRow, type CorpusOverview, + type LegalCorpus, licenseTierBadgeClass, commercialBadgeClass, groupUseCases, @@ -11,28 +12,46 @@ import { const BACKEND_URL = process.env.COMPLIANCE_BACKEND_URL || 'http://backend-compliance:8002' +// The legal-corpus structure comes from the Go SDK (it owns the vector store). +const SDK_URL = process.env.SDK_URL || 'http://ai-compliance-sdk:8090' export const dynamic = 'force-dynamic' +// Fetched from the SDK and isolated in its own try/catch so a vector-store +// hiccup degrades to "no structure shown" instead of blanking the whole page. +async function fetchLegalCorpus(): Promise { + try { + const res = await fetch(`${SDK_URL}/sdk/v1/rag/legal-corpus`, { + cache: 'no-store', + }) + return res.ok ? await res.json() : null + } catch { + return null + } +} + async function getData(): Promise<{ useCases: UseCaseRow[] corpus: CorpusOverview | null + legalCorpus: LegalCorpus | null }> { try { - const [ucRes, corpusRes] = await Promise.all([ + const [ucRes, corpusRes, legalCorpus] = await Promise.all([ fetch(`${BACKEND_URL}/api/compliance/v1/controls/use-cases`, { cache: 'no-store', }), fetch(`${BACKEND_URL}/api/compliance/v1/controls/corpus`, { cache: 'no-store', }), + fetchLegalCorpus(), ]) return { useCases: ucRes.ok ? await ucRes.json() : [], corpus: corpusRes.ok ? await corpusRes.json() : null, + legalCorpus, } } catch { - return { useCases: [], corpus: null } + return { useCases: [], corpus: null, legalCorpus: null } } } @@ -46,7 +65,7 @@ function Stat({ label, value }: { label: string; value: string | number }) { } export default async function CoveragePage() { - const { useCases, corpus } = await getData() + const { useCases, corpus, legalCorpus } = await getData() const groups = groupUseCases(useCases) const totalRelevant = useCases.reduce((s, u) => s + u.atom_relevant, 0) const totalAtoms = useCases.reduce((s, u) => s + u.atom_total, 0) @@ -221,6 +240,67 @@ export default async function CoveragePage() { + {legalCorpus?.regulations?.length ? ( +
+

+ Ingestierter Rechtskorpus – Struktur ({legalCorpus.totals.regulations}{' '} + Rechtsakte) +

+

+ Woraus jeder ingestierte eur-lex-Rechtsakt tatsächlich besteht: + Artikel (§), Anhänge, Erwägungsgründe und retrievbare Chunks — direkt + aus dem Vektorspeicher, damit kein Black-Box-Korpus entsteht. +

+
+ + + + + + + + + + + + {legalCorpus.regulations.map((r) => ( + + + + + + + + ))} + +
RechtsaktArtikel (§)AnhängeErwägungsgründeChunks
+ {r.regulation_short} + {r.regulation_name !== r.regulation_short ? ( + + {r.regulation_name} + + ) : null} + + {r.articles.toLocaleString('de-DE')} + + {r.annexes > 0 ? ( + r.annexes.toLocaleString('de-DE') + ) : ( + + )} + + {r.recitals > 0 ? ( + r.recitals.toLocaleString('de-DE') + ) : ( + + )} + + {r.chunks.toLocaleString('de-DE')} +
+
+
+ ) : null} + {corpus?.license_catalog?.length ? (

diff --git a/ai-compliance-sdk/internal/api/handlers/rag_handlers.go b/ai-compliance-sdk/internal/api/handlers/rag_handlers.go index 97dd6d89..eac9e17a 100644 --- a/ai-compliance-sdk/internal/api/handlers/rag_handlers.go +++ b/ai-compliance-sdk/internal/api/handlers/rag_handlers.go @@ -75,9 +75,10 @@ func (h *RAGHandlers) Search(c *gin.Context) { } c.JSON(http.StatusOK, gin.H{ - "query": req.Query, - "results": results, - "count": len(results), + "query": req.Query, + "results": results, + "count": len(results), + "assessment": ucca.Assess(results), }) } @@ -206,3 +207,32 @@ func (h *RAGHandlers) HandleScrollChunks(c *gin.Context) { "total": len(chunks), }) } + +// LegalCorpusStructure returns the composition (distinct articles, annexes, +// recitals + chunk count) of every ingested eur-lex legal act, so the coverage +// page can show WHAT was ingested instead of just the act name. +// GET /sdk/v1/rag/legal-corpus +func (h *RAGHandlers) LegalCorpusStructure(c *gin.Context) { + acts, err := h.ragClient.CorpusStructure(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to aggregate legal corpus: " + err.Error()}) + return + } + + arts, anns, recs := 0, 0, 0 + for _, a := range acts { + arts += a.Articles + anns += a.Annexes + recs += a.Recitals + } + + c.JSON(http.StatusOK, gin.H{ + "regulations": acts, + "totals": gin.H{ + "regulations": len(acts), + "articles": arts, + "annexes": anns, + "recitals": recs, + }, + }) +} diff --git a/ai-compliance-sdk/internal/app/routes.go b/ai-compliance-sdk/internal/app/routes.go index e190c39a..396402bb 100644 --- a/ai-compliance-sdk/internal/app/routes.go +++ b/ai-compliance-sdk/internal/app/routes.go @@ -161,6 +161,7 @@ func registerRAGRoutes(v1 *gin.RouterGroup, h *handlers.RAGHandlers) { ragRoutes.GET("/corpus-status", h.CorpusStatus) ragRoutes.GET("/corpus-versions/:collection", h.CorpusVersionHistory) ragRoutes.GET("/scroll", h.HandleScrollChunks) + ragRoutes.GET("/legal-corpus", h.LegalCorpusStructure) } } diff --git a/ai-compliance-sdk/internal/ucca/authority_rerank.go b/ai-compliance-sdk/internal/ucca/authority_rerank.go index 6c6232eb..b4eaff03 100644 --- a/ai-compliance-sdk/internal/ucca/authority_rerank.go +++ b/ai-compliance-sdk/internal/ucca/authority_rerank.go @@ -4,14 +4,15 @@ import "sort" // Re-ranking coefficients (validated in the offline golden harness; Phase A — conservative). const ( - authorityCoef = 0.40 // * weight/100 - jurisdictionGain = 0.05 // binding/guidance from DE or EU - foreignPenalty = 0.60 // foreign law on a DE/EU question (demoted, not removed) - unknownPenalty = 0.08 - domainMatchGain = 0.15 - offDomainPenalty = 0.10 // off-domain binding (demoted, not removed) - scopePenalty = 0.25 // BDSG Teil 3 (law enforcement) on a general DP question - topicGain = 0.18 // amplifier only + authorityCoef = 0.40 // * weight/100 + jurisdictionGain = 0.05 // binding/guidance from DE or EU + foreignPenalty = 0.60 // foreign law on a DE/EU question (demoted, not removed) + unknownPenalty = 0.08 + domainMatchGain = 0.15 + offDomainPenalty = 0.10 // off-domain binding (demoted, not removed) + scopePenalty = 0.25 // BDSG Teil 3 (law enforcement) on a general DP question + topicGain = 0.18 // amplifier only + supersededPenalty = 0.50 // superseded Alt-Quelle (pre-eu-v1): demoted, nicht versteckt ) // authorityScore computes the normative relevance of a result for a query. It augments the @@ -20,6 +21,12 @@ func authorityScore(query string, r LegalSearchResult, qDomain string, qForeign info := classifyAuthority(r) score := r.Score + authorityCoef*float64(info.weight)/100.0 + if r.Superseded { + // Alt-Quelle (pre-eu-v1): Default-Fragen sollen die eu-v1-Norm sehen. Demoted, + // nicht entfernt — fuer Historie/Uebergangsfragen bleibt sie auffindbar. + score -= supersededPenalty + } + if info.jurisdiction == "CH" && !qForeign { score -= foreignPenalty // Fremdrecht bei DE/EU-Frage: demoted, nicht geloescht } else { diff --git a/ai-compliance-sdk/internal/ucca/legal_corpus_structure.go b/ai-compliance-sdk/internal/ucca/legal_corpus_structure.go new file mode 100644 index 00000000..351ff4d1 --- /dev/null +++ b/ai-compliance-sdk/internal/ucca/legal_corpus_structure.go @@ -0,0 +1,167 @@ +package ucca + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "sort" +) + +// LegalActStructure is the composition of one ingested eur-lex legal act — how +// many distinct articles, annexes and recitals it consists of (plus the raw +// chunk count). Backs the coverage page so the ingested corpus is not a black +// box: a developer SEES what each act actually contains, not only its name. +type LegalActStructure struct { + RegulationShort string `json:"regulation_short"` + RegulationName string `json:"regulation_name"` + Articles int `json:"articles"` + Annexes int `json:"annexes"` + Recitals int `json:"recitals"` + Chunks int `json:"chunks"` +} + +const eurlexSource = "eur-lex.europa.eu" + +// legalStructureCollections hold the clean eur-lex legal corpus (chunks tagged +// with chunk_scope = section | annex | recital). +var legalStructureCollections = []string{"bp_compliance_ce", "bp_compliance_datenschutz"} + +// chunkScopeBucket maps a Qdrant chunk_scope to the structure field it feeds. +var chunkScopeBucket = map[string]string{"section": "articles", "annex": "annexes", "recital": "recitals"} + +// CorpusStructure scrolls the eur-lex legal corpus across the legal collections +// and aggregates the per-act composition. The source filter keeps it to a few +// hundred points regardless of total corpus size. Read-only; a collection that +// fails to scroll is skipped rather than failing the whole call. +func (c *LegalRAGClient) CorpusStructure(ctx context.Context) ([]LegalActStructure, error) { + var all []qdrantScrollPoint + for _, coll := range legalStructureCollections { + pts, err := c.scrollLegalCorpus(ctx, coll) + if err != nil { + continue + } + all = append(all, pts...) + } + return aggregateStructure(all), nil +} + +// aggregateStructure counts distinct article labels per (regulation, scope). +// Pure → unit-testable without a vector store. +func aggregateStructure(points []qdrantScrollPoint) []LegalActStructure { + distinct := map[string]map[string]map[string]struct{}{} + names := map[string]string{} + chunks := map[string]int{} + order := []string{} + + for _, pt := range points { + reg := getString(pt.Payload, "regulation_short") + if reg == "" { + continue + } + if _, seen := names[reg]; !seen { + name := getString(pt.Payload, "regulation_name_de") + if name == "" { + name = reg + } + names[reg] = name + distinct[reg] = map[string]map[string]struct{}{} + order = append(order, reg) + } + chunks[reg]++ + bucket, ok := chunkScopeBucket[getString(pt.Payload, "chunk_scope")] + article := getString(pt.Payload, "article") + if !ok || article == "" { + continue + } + if distinct[reg][bucket] == nil { + distinct[reg][bucket] = map[string]struct{}{} + } + distinct[reg][bucket][article] = struct{}{} + } + + out := make([]LegalActStructure, 0, len(order)) + for _, reg := range order { + out = append(out, LegalActStructure{ + RegulationShort: reg, + RegulationName: names[reg], + Articles: len(distinct[reg]["articles"]), + Annexes: len(distinct[reg]["annexes"]), + Recitals: len(distinct[reg]["recitals"]), + Chunks: chunks[reg], + }) + } + sort.SliceStable(out, func(i, j int) bool { + if out[i].Articles != out[j].Articles { + return out[i].Articles > out[j].Articles + } + return out[i].RegulationShort < out[j].RegulationShort + }) + return out +} + +// scrollLegalCorpus pages through one collection, filtered to the eur-lex legal +// corpus, returning minimal-payload points (no text/vectors). +func (c *LegalRAGClient) scrollLegalCorpus(ctx context.Context, collection string) ([]qdrantScrollPoint, error) { + var all []qdrantScrollPoint + var offset interface{} + for { + points, next, err := c.scrollLegalPage(ctx, collection, offset) + if err != nil { + return nil, err + } + all = append(all, points...) + if next == nil { + break + } + offset = next + } + return all, nil +} + +// scrollLegalPage fetches one page of the filtered scroll and returns the +// points plus the next-page offset (nil when exhausted). +func (c *LegalRAGClient) scrollLegalPage(ctx context.Context, collection string, offset interface{}) ([]qdrantScrollPoint, interface{}, error) { + reqBody := map[string]interface{}{ + "limit": 500, + "with_payload": map[string]interface{}{"include": []string{"regulation_short", "regulation_name_de", "chunk_scope", "article"}}, + "with_vectors": false, + "filter": map[string]interface{}{ + "must": []map[string]interface{}{ + {"key": "source", "match": map[string]interface{}{"value": eurlexSource}}, + }, + }, + } + if offset != nil { + reqBody["offset"] = offset + } + jsonBody, err := json.Marshal(reqBody) + if err != nil { + return nil, nil, err + } + url := fmt.Sprintf("%s/collections/%s/points/scroll", c.qdrantURL, collection) + req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(jsonBody)) + if err != nil { + return nil, nil, err + } + req.Header.Set("Content-Type", "application/json") + if c.qdrantAPIKey != "" { + req.Header.Set("api-key", c.qdrantAPIKey) + } + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, nil, err + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, nil, fmt.Errorf("qdrant returned %d: %s", resp.StatusCode, string(body)) + } + var scrollResp qdrantScrollResponse + if err := json.NewDecoder(resp.Body).Decode(&scrollResp); err != nil { + return nil, nil, err + } + return scrollResp.Result.Points, scrollResp.Result.NextPageOffset, nil +} diff --git a/ai-compliance-sdk/internal/ucca/legal_corpus_structure_test.go b/ai-compliance-sdk/internal/ucca/legal_corpus_structure_test.go new file mode 100644 index 00000000..b5baae94 --- /dev/null +++ b/ai-compliance-sdk/internal/ucca/legal_corpus_structure_test.go @@ -0,0 +1,50 @@ +package ucca + +import "testing" + +func structPoint(reg, name, scope, article string) qdrantScrollPoint { + return qdrantScrollPoint{Payload: map[string]interface{}{ + "regulation_short": reg, + "regulation_name_de": name, + "chunk_scope": scope, + "article": article, + }} +} + +func TestAggregateStructure_CountsDistinctPerScope(t *testing.T) { + points := []qdrantScrollPoint{ + structPoint("CRA", "Cyber Resilience Act", "section", "13"), + structPoint("CRA", "Cyber Resilience Act", "section", "13"), // duplicate article → still 1 + structPoint("CRA", "Cyber Resilience Act", "section", "14"), + structPoint("CRA", "Cyber Resilience Act", "annex", "Anhang-I"), + structPoint("CRA", "Cyber Resilience Act", "annex", "Anhang-VII"), + structPoint("DORA", "", "section", "6"), // first sighting has no name → + structPoint("DORA", "", "section", "19"), // regulation_name falls back to short + structPoint("DORA", "", "recital", ""), // empty article → ignored for distinct + structPoint("", "x", "section", "1"), // missing regulation → skipped entirely + } + + got := aggregateStructure(points) + + if len(got) != 2 { + t.Fatalf("want 2 acts, got %d (%+v)", len(got), got) + } + // CRA has more articles → sorts first. + cra := got[0] + if cra.RegulationShort != "CRA" || cra.Articles != 2 || cra.Annexes != 2 || cra.Recitals != 0 || cra.Chunks != 5 { + t.Errorf("CRA wrong: %+v", cra) + } + dora := got[1] + if dora.RegulationShort != "DORA" || dora.Articles != 2 || dora.Chunks != 3 { + t.Errorf("DORA wrong: %+v", dora) + } + if dora.RegulationName != "DORA" { + t.Errorf("DORA name fallback failed: %q", dora.RegulationName) + } +} + +func TestAggregateStructure_Empty(t *testing.T) { + if got := aggregateStructure(nil); len(got) != 0 { + t.Errorf("want empty, got %+v", got) + } +} diff --git a/ai-compliance-sdk/internal/ucca/legal_rag_assess.go b/ai-compliance-sdk/internal/ucca/legal_rag_assess.go new file mode 100644 index 00000000..0e4bf8ae --- /dev/null +++ b/ai-compliance-sdk/internal/ucca/legal_rag_assess.go @@ -0,0 +1,134 @@ +package ucca + +import ( + "fmt" + "strings" +) + +const ( + assessConnectedCap = 12 // cap connected norms surfaced in the assessment + assessCrossRegimeTopN = 5 // window over which "cross regime" is judged + assessReviewMargin = 0.05 // a tighter winner gap → recommend human review +) + +// Assess builds the auditable explanation layer over a ranked result set: +// primary norm, the norms it connects to (citation graph), cross-regime, a +// human-review flag, the winner margin and a short reasoning string. Pure → +// unit-testable. It EXPLAINS the ranking, it does not change it. Returns nil for +// an empty result set. +func Assess(results []LegalSearchResult) *LegalAssessment { + if len(results) == 0 { + return nil + } + // Norm-level view: collapse multiple chunks of the same article/annex so the + // margin and cross-regime are judged between DISTINCT norms, not near-identical + // chunks of one norm (which would make every winner margin ~0). + norms := distinctNorms(results) + p := norms[0] + + primary := primaryLabel(p) + connected := dedupStrings(p.ReferencesOut, p.ReferencesIn, p.CitationUnit) + if len(connected) > assessConnectedCap { + connected = connected[:assessConnectedCap] + } + + window := norms + if len(window) > assessCrossRegimeTopN { + window = window[:assessCrossRegimeTopN] + } + regimes := make(map[string]bool) + for _, r := range window { + if r.RegulationShort != "" { + regimes[r.RegulationShort] = true + } + } + crossRegime := len(regimes) > 1 + + margin := 0.0 + if len(norms) > 1 { + margin = norms[0].Score - norms[1].Score + } + + primaryBinding := p.SourceClass == "binding_law" + humanReview := margin < assessReviewMargin || crossRegime || !primaryBinding + + return &LegalAssessment{ + PrimaryNorm: primary, + PrimaryRegulation: p.RegulationShort, + ConnectedNorms: connected, + CrossRegime: crossRegime, + HumanReviewFlag: humanReview, + WinnerMargin: margin, + ScoreReasoning: assessReasoning(p, margin, crossRegime, primaryBinding), + } +} + +func primaryLabel(p LegalSearchResult) string { + if p.CitationUnit != "" { + return p.CitationUnit + } + if p.ArticleLabel != "" { + return p.ArticleLabel + } + return strings.TrimSpace(p.RegulationShort + " " + p.Article) +} + +// assessReasoning renders a short, human-readable justification (German). +func assessReasoning(p LegalSearchResult, margin float64, crossRegime, primaryBinding bool) string { + label := primaryLabel(p) + parts := make([]string, 0, 4) + if primaryBinding { + parts = append(parts, fmt.Sprintf("Primärtreffer %s: bindendes Recht (Autorität %d).", label, p.AuthorityWeight)) + } else { + parts = append(parts, fmt.Sprintf("Primärtreffer %s ist keine bindende Norm (Leitlinie/Standard) — Quelle prüfen.", label)) + } + if margin > 0 { + parts = append(parts, fmt.Sprintf("Vorsprung %.2f vor #2.", margin)) + } + if margin < assessReviewMargin { + parts = append(parts, "Knapper Vorsprung — Alternativtreffer prüfen.") + } + if crossRegime { + parts = append(parts, "Mehrere Regime betroffen — Querbezug prüfen.") + } + return strings.Join(parts, " ") +} + +// distinctNorms collapses results that share a citation (multiple chunks of the +// same article/annex) to the first — i.e. highest-ranked — occurrence. Results +// without any citation identity are each kept, since they cannot be matched. +func distinctNorms(results []LegalSearchResult) []LegalSearchResult { + seen := make(map[string]bool, len(results)) + out := make([]LegalSearchResult, 0, len(results)) + for _, r := range results { + key := r.CitationUnit + if key == "" { + key = r.ArticleLabel + } + if key != "" { + if seen[key] { + continue + } + seen[key] = true + } + out = append(out, r) + } + return out +} + +// dedupStrings concatenates out+in, drops empties and the excluded value, and +// returns a stable de-duplicated slice (insertion order preserved). +func dedupStrings(out, in []string, exclude string) []string { + seen := map[string]bool{exclude: true} + res := make([]string, 0, len(out)+len(in)) + for _, list := range [][]string{out, in} { + for _, s := range list { + if s == "" || seen[s] { + continue + } + seen[s] = true + res = append(res, s) + } + } + return res +} diff --git a/ai-compliance-sdk/internal/ucca/legal_rag_assess_test.go b/ai-compliance-sdk/internal/ucca/legal_rag_assess_test.go new file mode 100644 index 00000000..5cce3934 --- /dev/null +++ b/ai-compliance-sdk/internal/ucca/legal_rag_assess_test.go @@ -0,0 +1,112 @@ +package ucca + +import "testing" + +func ares(reg, cu, sc string, score float64, weight int, out, in []string) LegalSearchResult { + return LegalSearchResult{ + RegulationShort: reg, CitationUnit: cu, SourceClass: sc, Score: score, + AuthorityWeight: weight, ReferencesOut: out, ReferencesIn: in, + } +} + +func TestAssess_Empty(t *testing.T) { + if Assess(nil) != nil { + t.Error("empty results → nil assessment") + } +} + +func TestAssess_BindingPrimary_NoReview(t *testing.T) { + results := []LegalSearchResult{ + ares("CRA", "Art. 13 CRA", "binding_law", 1.05, 100, + []string{"CRA Anhang I", "Art. 14 CRA"}, []string{"Art. 12 CRA"}), + ares("CRA", "Art. 14 CRA", "binding_law", 0.80, 100, nil, nil), + } + a := Assess(results) + if a == nil { + t.Fatal("nil assessment") + } + if a.PrimaryNorm != "Art. 13 CRA" || a.PrimaryRegulation != "CRA" { + t.Errorf("primary wrong: %+v", a) + } + if len(a.ConnectedNorms) != 3 { // out(2) + in(1), self excluded, deduped + t.Errorf("connected norms: %v", a.ConnectedNorms) + } + if a.CrossRegime { + t.Error("single regime must not be cross-regime") + } + if a.WinnerMargin < 0.24 || a.WinnerMargin > 0.26 { + t.Errorf("margin = %v, want ~0.25", a.WinnerMargin) + } + if a.HumanReviewFlag { + t.Error("clean binding + healthy margin + single regime → no review") + } +} + +func TestAssess_CrossRegimeFlagsReview(t *testing.T) { + a := Assess([]LegalSearchResult{ + ares("CRA", "Art. 13 CRA", "binding_law", 1.05, 100, nil, nil), + ares("DORA", "Art. 6 DORA", "binding_law", 0.70, 100, nil, nil), + }) + if !a.CrossRegime || !a.HumanReviewFlag { + t.Errorf("cross-regime must flag review: %+v", a) + } +} + +func TestAssess_NonBindingFlagsReview(t *testing.T) { + a := Assess([]LegalSearchResult{ + ares("ENISA", "ENISA SBOM", "supervisory_guidance", 0.90, 70, nil, nil), + ares("ENISA", "ENISA X", "supervisory_guidance", 0.40, 70, nil, nil), + }) + if !a.HumanReviewFlag { + t.Error("non-binding primary → review") + } +} + +func TestAssess_TightMarginFlagsReview(t *testing.T) { + a := Assess([]LegalSearchResult{ + ares("CRA", "Art. 13 CRA", "binding_law", 1.00, 100, nil, nil), + ares("CRA", "Art. 14 CRA", "binding_law", 0.98, 100, nil, nil), + }) + if a.WinnerMargin >= 0.05 || !a.HumanReviewFlag { + t.Errorf("tight margin → review: %+v", a) + } +} + +func TestAssess_MarginIsNormLevelNotChunkLevel(t *testing.T) { + // Two near-identical chunks of the SAME norm at the top, then a distinct norm. + results := []LegalSearchResult{ + ares("CRA", "Art. 13 CRA", "binding_law", 1.050, 100, []string{"CRA Anhang I"}, nil), + ares("CRA", "Art. 13 CRA", "binding_law", 1.049, 100, nil, nil), // same norm + ares("CRA", "Art. 14 CRA", "binding_law", 0.800, 100, nil, nil), + } + a := Assess(results) + if a.WinnerMargin < 0.24 || a.WinnerMargin > 0.26 { // Art.13 vs Art.14, not chunk vs chunk + t.Errorf("margin must be norm-level (~0.25), got %v", a.WinnerMargin) + } + if a.HumanReviewFlag { + t.Error("healthy norm-level margin → no review") + } +} + +func TestDistinctNorms(t *testing.T) { + got := distinctNorms([]LegalSearchResult{ + {CitationUnit: "Art. 13 CRA"}, + {CitationUnit: "Art. 13 CRA"}, // duplicate norm → collapsed + {CitationUnit: "Art. 14 CRA"}, + {CitationUnit: ""}, // no identity → kept + {CitationUnit: ""}, // no identity → kept + }) + if len(got) != 4 { + t.Errorf("want 4 (2 distinct + 2 unidentified), got %d", len(got)) + } +} + +func TestDedupStrings(t *testing.T) { + got := dedupStrings([]string{"a", "b", "", "a"}, []string{"b", "c"}, "self") + if len(got) != 3 || got[0] != "a" || got[1] != "b" || got[2] != "c" { + t.Errorf("dedup: %v", got) + } + if len(dedupStrings([]string{"self"}, nil, "self")) != 0 { + t.Error("excluded value must be dropped") + } +} diff --git a/ai-compliance-sdk/internal/ucca/legal_rag_client.go b/ai-compliance-sdk/internal/ucca/legal_rag_client.go index e60bd198..2942265c 100644 --- a/ai-compliance-sdk/internal/ucca/legal_rag_client.go +++ b/ai-compliance-sdk/internal/ucca/legal_rag_client.go @@ -20,6 +20,7 @@ type LegalRAGClient struct { httpClient *http.Client textIndexEnsured map[string]bool hybridEnabled bool + graphEnabled bool } // NewLegalRAGClient creates a new Legal RAG client using Ollama bge-m3 embeddings. @@ -38,6 +39,11 @@ func NewLegalRAGClient() *LegalRAGClient { } hybridEnabled := os.Getenv("RAG_HYBRID_SEARCH") != "false" + // Graph-Expansion ist OPT-IN: kein gemessener Rang-Nutzen ggue. der Binding-Augmentation, + // +1 Qdrant-Call/Suche, Flutungsrisiko ueber Reverse-Kanten. Bleibt als Recall-Sicherheitsnetz + // fuer spaetere Luecken (RAG_GRAPH_EXPANSION=true). Die Graph-Kanten werden in der Response + // zur Begruendung/Vollstaendigkeit genutzt, nicht zur Pool-Expansion (Default). + graphEnabled := os.Getenv("RAG_GRAPH_EXPANSION") == "true" return &LegalRAGClient{ qdrantURL: qdrantURL, @@ -47,6 +53,7 @@ func NewLegalRAGClient() *LegalRAGClient { collection: "bp_compliance_ce", textIndexEnsured: make(map[string]bool), hybridEnabled: hybridEnabled, + graphEnabled: graphEnabled, httpClient: &http.Client{ Timeout: 60 * time.Second, }, @@ -100,6 +107,13 @@ func (c *LegalRAGClient) searchInternal(ctx context.Context, collection string, hits = mergeDedupHits(hits, bindingHits) } + // Graph-Augmentation: verbundene Normen (references_out/in) der Top-Hits ueber die + // praezise Zitations-Kante in den Pool ziehen — z.B. Art. 13 CRA zieht Anhang I (die + // eigentliche Pflichtquelle). Pool-Augmentation only; Re-Rank + topK bleiben. + if c.graphEnabled { + hits = c.expandViaGraph(ctx, collection, hits) + } + results := make([]LegalSearchResult, len(hits)) for i, hit := range hits { // Legal-Metadaten nach rag_reingest_spec.md §2: bevorzugt die normalisierten Felder @@ -131,6 +145,10 @@ func (c *LegalRAGClient) searchInternal(ctx context.Context, collection string, AuthorityWeight: getInt(hit.Payload, "authority_weight"), SourceClass: getString(hit.Payload, "source_class"), Jurisdiction: getString(hit.Payload, "jurisdiction"), + CitationUnit: getString(hit.Payload, "citation_unit"), + ReferencesOut: getStringSlice(hit.Payload, "references_out"), + ReferencesIn: getStringSlice(hit.Payload, "references_in"), + Superseded: getString(hit.Payload, "status") == "superseded", } } diff --git a/ai-compliance-sdk/internal/ucca/legal_rag_graph.go b/ai-compliance-sdk/internal/ucca/legal_rag_graph.go new file mode 100644 index 00000000..753364b1 --- /dev/null +++ b/ai-compliance-sdk/internal/ucca/legal_rag_graph.go @@ -0,0 +1,162 @@ +package ucca + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "sort" +) + +// Graph-augmented retrieval: when a top hit cites an annex/article (references_out) +// or is cited by one (references_in), pull that connected norm into the candidate +// pool via the PRECISE citation graph instead of hoping semantic search surfaces +// it. E.g. a hit on CRA Art. 13 pulls in CRA Anhang I (the actual requirement). +// Pool-augmentation only — authority re-rank + topK slice still apply, so the +// response schema is unchanged. +const ( + graphSeedCount = 5 // only the top hits seed the expansion + graphMaxExpand = 15 // cap connected norms pulled in (avoid pool explosion) + graphHopPenalty = 0.05 // a one-hop neighbour ranks just below its seed +) + +// expandViaGraph augments hits with the norms they cite and the norms that cite +// them. Best-effort: on any error (or nothing to expand) the original hits are +// returned unchanged. +func (c *LegalRAGClient) expandViaGraph(ctx context.Context, collection string, hits []qdrantSearchHit) []qdrantSearchHit { + if len(hits) == 0 { + return hits + } + present := make(map[string]bool, len(hits)) + for _, h := range hits { + if cu := getString(h.Payload, "citation_unit"); cu != "" { + present[cu] = true + } + } + + seeds := hits + if len(seeds) > graphSeedCount { + seeds = seeds[:graphSeedCount] + } + // Forward edges only (references_out = the detail a hit explicitly points to, + // e.g. Art. 13 → Anhang I). Reverse (references_in) has high fan-out for popular + // annexes (Anhang I is cited by 23 articles) → pool flooding; it is surfaced as + // connected-norm metadata in the Phase 2 response instead of expanding the pool. + want := make(map[string]float64) // connected citation_unit -> best seeding score + for _, h := range seeds { + for _, cu := range getStringSlice(h.Payload, "references_out") { + if cu == "" || present[cu] { + continue + } + if s, ok := want[cu]; !ok || h.Score > s { + want[cu] = h.Score + } + } + } + if len(want) == 0 { + return hits + } + + units := topByScore(want, graphMaxExpand) + fetched, err := c.fetchByCitationUnits(ctx, collection, units) + if err != nil || len(fetched) == 0 { + return hits + } + neighbours := make([]qdrantSearchHit, 0, len(fetched)) + for cu, pt := range fetched { + neighbours = append(neighbours, qdrantSearchHit{ID: pt.ID, Score: want[cu] - graphHopPenalty, Payload: pt.Payload}) + } + return mergeDedupHits(hits, neighbours) +} + +// topByScore returns up to n keys with the highest values. Deterministic: ties +// broken by the key string so the cap is stable across runs. +func topByScore(m map[string]float64, n int) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Slice(keys, func(i, j int) bool { + if m[keys[i]] != m[keys[j]] { + return m[keys[i]] > m[keys[j]] + } + return keys[i] < keys[j] + }) + if len(keys) > n { + keys = keys[:n] + } + return keys +} + +// fetchByCitationUnits loads one representative point (the first chunk) per +// citation_unit from the given collection. +func (c *LegalRAGClient) fetchByCitationUnits(ctx context.Context, collection string, units []string) (map[string]qdrantScrollPoint, error) { + should := make([]map[string]interface{}, 0, len(units)) + for _, cu := range units { + should = append(should, map[string]interface{}{"key": "citation_unit", "match": map[string]interface{}{"value": cu}}) + } + reqBody := map[string]interface{}{ + "limit": len(units) * 4, + "with_payload": true, + "with_vectors": false, + "filter": map[string]interface{}{"should": should}, + } + jsonBody, err := json.Marshal(reqBody) + if err != nil { + return nil, err + } + url := fmt.Sprintf("%s/collections/%s/points/scroll", c.qdrantURL, collection) + req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(jsonBody)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + if c.qdrantAPIKey != "" { + req.Header.Set("api-key", c.qdrantAPIKey) + } + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("qdrant scroll returned %d: %s", resp.StatusCode, string(body)) + } + var scrollResp qdrantScrollResponse + if err := json.NewDecoder(resp.Body).Decode(&scrollResp); err != nil { + return nil, err + } + out := make(map[string]qdrantScrollPoint, len(units)) + for _, pt := range scrollResp.Result.Points { + cu := getString(pt.Payload, "citation_unit") + if cu != "" { + if _, seen := out[cu]; !seen { + out[cu] = pt + } + } + } + return out, nil +} + +// getStringSlice extracts a []string from a Qdrant payload list field +// (references_out / references_in are stored as JSON arrays of strings). +func getStringSlice(m map[string]interface{}, key string) []string { + v, ok := m[key] + if !ok { + return nil + } + arr, ok := v.([]interface{}) + if !ok { + return nil + } + out := make([]string, 0, len(arr)) + for _, item := range arr { + if s, ok := item.(string); ok { + out = append(out, s) + } + } + return out +} diff --git a/ai-compliance-sdk/internal/ucca/legal_rag_graph_test.go b/ai-compliance-sdk/internal/ucca/legal_rag_graph_test.go new file mode 100644 index 00000000..917c5e53 --- /dev/null +++ b/ai-compliance-sdk/internal/ucca/legal_rag_graph_test.go @@ -0,0 +1,89 @@ +package ucca + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestGetStringSlice(t *testing.T) { + m := map[string]interface{}{ + "refs": []interface{}{"a", "b", 3, "c"}, // non-strings are skipped + "str": "not-a-list", + } + got := getStringSlice(m, "refs") + if len(got) != 3 || got[0] != "a" || got[2] != "c" { + t.Errorf("refs: %v", got) + } + if getStringSlice(m, "missing") != nil { + t.Error("missing key should be nil") + } + if getStringSlice(m, "str") != nil { + t.Error("non-list should be nil") + } +} + +func TestTopByScore_DeterministicCap(t *testing.T) { + m := map[string]float64{"x": 0.5, "y": 0.9, "z": 0.5, "w": 0.7} + got := topByScore(m, 2) + if len(got) != 2 || got[0] != "y" || got[1] != "w" { + t.Errorf("want [y w], got %v", got) + } + all := topByScore(m, 10) + if all[2] != "x" || all[3] != "z" { // tie 0.5 broken by key string + t.Errorf("tie-break not deterministic: %v", all) + } +} + +func TestExpandViaGraph_NoSeedsOrRefs(t *testing.T) { + c := &LegalRAGClient{} // nil httpClient → must not be called on these paths + if out := c.expandViaGraph(context.Background(), "x", nil); out != nil { + t.Error("empty hits should return nil") + } + hits := []qdrantSearchHit{{ID: 1, Score: 0.8, Payload: map[string]interface{}{"citation_unit": "Art. 1 CRA"}}} + if out := c.expandViaGraph(context.Background(), "x", hits); len(out) != 1 { + t.Errorf("no references → unchanged, got %d", len(out)) + } +} + +func TestExpandViaGraph_PullsConnectedNorm(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "result": map[string]interface{}{ + "points": []map[string]interface{}{ + {"id": 99, "payload": map[string]interface{}{ + "citation_unit": "CRA Anhang I", "chunk_text": "Sicherheitsanforderungen", + "source_class": "binding_law", "authority_weight": 100, "regulation_short": "CRA", + }}, + }, + "next_page_offset": nil, + }, + }) + })) + defer srv.Close() + + c := &LegalRAGClient{qdrantURL: srv.URL, httpClient: srv.Client()} + hits := []qdrantSearchHit{ + {ID: 1, Score: 0.70, Payload: map[string]interface{}{ + "citation_unit": "Art. 13 CRA", "references_out": []interface{}{"CRA Anhang I"}, + }}, + } + out := c.expandViaGraph(context.Background(), "bp_compliance_ce", hits) + if len(out) != 2 { + t.Fatalf("want 2 hits (seed + connected annex), got %d", len(out)) + } + var found *qdrantSearchHit + for i := range out { + if getString(out[i].Payload, "citation_unit") == "CRA Anhang I" { + found = &out[i] + } + } + if found == nil { + t.Fatal("connected norm CRA Anhang I was not pulled into the pool") + } + if found.Score < 0.64 || found.Score > 0.66 { // 0.70 seed − 0.05 hop penalty + t.Errorf("connected score = %v, want ~0.65", found.Score) + } +} diff --git a/ai-compliance-sdk/internal/ucca/legal_rag_supersede_test.go b/ai-compliance-sdk/internal/ucca/legal_rag_supersede_test.go new file mode 100644 index 00000000..c8d3e1e4 --- /dev/null +++ b/ai-compliance-sdk/internal/ucca/legal_rag_supersede_test.go @@ -0,0 +1,30 @@ +package ucca + +import "testing" + +// A superseded alt-source must rank below the same result when it is NOT +// superseded (the eu-v1 norm), but only demoted — the penalty is finite, so it +// stays in the pool and remains findable for history/transition questions. +func TestAuthorityScore_SupersededIsDemotedNotRemoved(t *testing.T) { + fresh := LegalSearchResult{ + Score: 0.65, SourceClass: "binding_law", AuthorityWeight: 100, + Jurisdiction: "EU", RegulationShort: "CRA", Article: "13", + } + old := fresh + old.Superseded = true + + sFresh := authorityScore("CRA Sicherheitsupdates Hersteller", fresh, "", false) + sOld := authorityScore("CRA Sicherheitsupdates Hersteller", old, "", false) + + if sOld >= sFresh { + t.Errorf("superseded must score lower: fresh=%.3f superseded=%.3f", sFresh, sOld) + } + gap := sFresh - sOld + if gap < supersededPenalty-0.001 || gap > supersededPenalty+0.001 { + t.Errorf("demotion should equal supersededPenalty (%.2f), got %.3f", supersededPenalty, gap) + } + // Still a positive, finite score → present in the pool, not hidden. + if sOld <= -1 { + t.Errorf("superseded score collapsed (%.3f) — must remain findable", sOld) + } +} diff --git a/ai-compliance-sdk/internal/ucca/legal_rag_types.go b/ai-compliance-sdk/internal/ucca/legal_rag_types.go index 38fa4738..a47327d2 100644 --- a/ai-compliance-sdk/internal/ucca/legal_rag_types.go +++ b/ai-compliance-sdk/internal/ucca/legal_rag_types.go @@ -27,6 +27,31 @@ type LegalSearchResult struct { AuthorityWeight int `json:"-"` SourceClass string `json:"-"` Jurisdiction string `json:"-"` + + // Zitations-Graph (Phase 2) — intern, speist nur die Assessment-Berechnung + // (verbundene Normen, Begruendung). Pro-Result-Schema bleibt eingefroren. + CitationUnit string `json:"-"` + ReferencesOut []string `json:"-"` + ReferencesIn []string `json:"-"` + + // Supersede-Status (status="superseded", use_for_primary=false) — Alt-Quelle, + // die fuer Default-Fragen demoted wird (nicht versteckt; fuer Historie auffindbar). + Superseded bool `json:"-"` +} + +// LegalAssessment is the auditable explanation layer over a ranked result set: +// which norm is primary, which norms connect to it via the citation graph, +// whether the answer crosses regulatory regimes, and whether a human should +// review. Computed from the already-ranked results — it EXPLAINS retrieval, it +// does not change it (graph edges for reasoning/completeness, not pool-expansion). +type LegalAssessment struct { + PrimaryNorm string `json:"primary_norm"` + PrimaryRegulation string `json:"primary_regulation"` + ConnectedNorms []string `json:"connected_norms"` + CrossRegime bool `json:"cross_regime"` + HumanReviewFlag bool `json:"human_review_flag"` + WinnerMargin float64 `json:"winner_margin"` + ScoreReasoning string `json:"score_reasoning"` } // LegalContext represents aggregated legal context for an assessment. From f11b2e035f9d194fc6c6a98567205449a20f07aa Mon Sep 17 00:00:00 2001 From: Benjamin_Boenisch Date: Wed, 24 Jun 2026 09:01:25 +0000 Subject: [PATCH 02/16] feat(ai-sdk): controlled interpretation-intent guidance override (#34) --- .../internal/ucca/authority_rerank.go | 71 +++++++++++++++--- .../internal/ucca/legal_rag_intent_test.go | 72 +++++++++++++++++++ 2 files changed, 133 insertions(+), 10 deletions(-) create mode 100644 ai-compliance-sdk/internal/ucca/legal_rag_intent_test.go diff --git a/ai-compliance-sdk/internal/ucca/authority_rerank.go b/ai-compliance-sdk/internal/ucca/authority_rerank.go index b4eaff03..a4b7d3d6 100644 --- a/ai-compliance-sdk/internal/ucca/authority_rerank.go +++ b/ai-compliance-sdk/internal/ucca/authority_rerank.go @@ -1,20 +1,61 @@ package ucca -import "sort" +import ( + "sort" + "strings" +) // Re-ranking coefficients (validated in the offline golden harness; Phase A — conservative). const ( - authorityCoef = 0.40 // * weight/100 - jurisdictionGain = 0.05 // binding/guidance from DE or EU - foreignPenalty = 0.60 // foreign law on a DE/EU question (demoted, not removed) - unknownPenalty = 0.08 - domainMatchGain = 0.15 - offDomainPenalty = 0.10 // off-domain binding (demoted, not removed) - scopePenalty = 0.25 // BDSG Teil 3 (law enforcement) on a general DP question - topicGain = 0.18 // amplifier only - supersededPenalty = 0.50 // superseded Alt-Quelle (pre-eu-v1): demoted, nicht versteckt + authorityCoef = 0.40 // * weight/100 + jurisdictionGain = 0.05 // binding/guidance from DE or EU + foreignPenalty = 0.60 // foreign law on a DE/EU question (demoted, not removed) + unknownPenalty = 0.08 + domainMatchGain = 0.15 + offDomainPenalty = 0.10 // off-domain binding (demoted, not removed) + scopePenalty = 0.25 // BDSG Teil 3 (law enforcement) on a general DP question + topicGain = 0.18 // amplifier only + supersededPenalty = 0.50 // superseded Alt-Quelle (pre-eu-v1): demoted, nicht versteckt + guidanceIntentGain = 0.25 // controlled guidance override on explicit interpretation intent + guidanceIntentMargin = 0.05 // ...only if the guideline is semantically competitive with binding ) +// guidanceIntentSignals mark a query that EXPLICITLY asks for an interpretation / +// recommendation by a guidance body, rather than for the binding obligation. Only +// then may a (semantically competitive) guideline outrank the binding norm. +var guidanceIntentSignals = []string{ + "edpb", "europäischer datenschutzausschuss", "europaeischer datenschutzausschuss", + "dsk", "enisa", "bsi", "leitlinie", "guideline", "orientierungshilfe", + "auslegung", "empfiehlt", "empfehlung", "sagt", "laut", +} + +// queryWantsGuidance reports whether the query explicitly asks for guidance/interpretation. +func queryWantsGuidance(query string) bool { + q := strings.ToLower(query) + for _, sig := range guidanceIntentSignals { + if strings.Contains(q, sig) { + return true + } + } + return false +} + +// bestBindingSemantic returns the highest RAW semantic score among binding-law +// results (0 if none / intent not requested). Used as the guard threshold so an +// off-topic guideline cannot ride the interpretation-intent boost. +func bestBindingSemantic(results []LegalSearchResult, wantsGuidance bool) float64 { + if !wantsGuidance { + return 0 + } + best := 0.0 + for _, r := range results { + if r.SourceClass == "binding_law" && r.Score > best { + best = r.Score + } + } + return best +} + // authorityScore computes the normative relevance of a result for a query. It augments the // semantic score with authority/jurisdiction/domain/scope/topic signals. Exposed for tests. func authorityScore(query string, r LegalSearchResult, qDomain string, qForeign bool) float64 { @@ -62,11 +103,21 @@ func rerankByAuthority(query string, results []LegalSearchResult) []LegalSearchR } qDomain := queryDomain(query) qForeign := queryIsForeign(query) + wantsGuidance := queryWantsGuidance(query) + bestBindingSem := bestBindingSemantic(results, wantsGuidance) out := make([]LegalSearchResult, len(results)) copy(out, results) for i := range out { out[i].Score = authorityScore(query, out[i], qDomain, qForeign) + // Interpretations-Intent (eng begrenzt): NUR wenn die Query explizit nach + // Guidance/Auslegung fragt UND die Leitlinie semantisch konkurrenzfaehig ist + // (>= bester binding-Treffer - margin), darf supervisory_guidance die bindende + // Norm ueberholen. Sonst bleibt binding > guidance (Normfrage unveraendert). + if wantsGuidance && out[i].SourceClass == "supervisory_guidance" && + results[i].Score >= bestBindingSem-guidanceIntentMargin { + out[i].Score += guidanceIntentGain + } } sort.SliceStable(out, func(a, b int) bool { return out[a].Score > out[b].Score diff --git a/ai-compliance-sdk/internal/ucca/legal_rag_intent_test.go b/ai-compliance-sdk/internal/ucca/legal_rag_intent_test.go new file mode 100644 index 00000000..a24ec59c --- /dev/null +++ b/ai-compliance-sdk/internal/ucca/legal_rag_intent_test.go @@ -0,0 +1,72 @@ +package ucca + +import "testing" + +func intentRes(reg, sourceClass string, sem float64, weight int) LegalSearchResult { + return LegalSearchResult{ + RegulationShort: reg, SourceClass: sourceClass, Score: sem, + AuthorityWeight: weight, Jurisdiction: "EU", + } +} + +func TestQueryWantsGuidance(t *testing.T) { + wants := []string{ + "Was empfiehlt der EDPB zum DSB?", + "Was sagt die ENISA zu Security Updates?", + "laut DSK ...", + "Orientierungshilfe zur DSFA", + "Welche BSI-Empfehlung gilt?", + "Auslegung der Aufsichtsbehörde", + } + plain := []string{ + "Ab wann braucht man einen Datenschutzbeauftragten?", + "Welche Anforderungen bestehen an Security Updates?", + } + for _, q := range wants { + if !queryWantsGuidance(q) { + t.Errorf("should detect interpretation intent: %q", q) + } + } + for _, q := range plain { + if queryWantsGuidance(q) { + t.Errorf("should NOT detect intent (norm question): %q", q) + } + } +} + +func TestRerank_NormQuestion_BindingStaysTop(t *testing.T) { + // No intent signal → binding wins even though guidance is semantically higher. + results := []LegalSearchResult{ + intentRes("EDPB DPO", "supervisory_guidance", 0.64, 70), + intentRes("DSGVO", "binding_law", 0.58, 100), + } + out := rerankByAuthority("Ab wann braucht man einen Datenschutzbeauftragten?", results) + if out[0].SourceClass != "binding_law" { + t.Errorf("norm question: binding must stay Top-1, got %s", out[0].SourceClass) + } +} + +func TestRerank_InterpretationQuestion_GuidanceMayWin(t *testing.T) { + // Explicit intent + guidance semantically competitive → guidance wins. + results := []LegalSearchResult{ + intentRes("EDPB DPO", "supervisory_guidance", 0.64, 70), + intentRes("DSGVO", "binding_law", 0.58, 100), + } + out := rerankByAuthority("Was empfiehlt der EDPB zum Datenschutzbeauftragten?", results) + if out[0].SourceClass != "supervisory_guidance" { + t.Errorf("interpretation question: guidance should win Top-1, got %s", out[0].SourceClass) + } +} + +func TestRerank_OffTopicGuidance_BlockedByGuard(t *testing.T) { + // Intent present, but guidance semantic is far below the best binding hit → + // the margin guard keeps binding on top (no off-topic guideline override). + results := []LegalSearchResult{ + intentRes("EDPB DPO", "supervisory_guidance", 0.40, 70), + intentRes("DSGVO", "binding_law", 0.58, 100), + } + out := rerankByAuthority("Was empfiehlt der EDPB zum Datenschutzbeauftragten?", results) + if out[0].SourceClass != "binding_law" { + t.Errorf("off-topic guidance must not win even with intent, got %s", out[0].SourceClass) + } +} From e24a551ee44dd629c01b61daf62989c634bef31b Mon Sep 17 00:00:00 2001 From: Benjamin_Boenisch Date: Wed, 24 Jun 2026 09:31:58 +0000 Subject: [PATCH 03/16] fix(ai-sdk): make interpretation-intent override reliably win (#35) --- .../internal/ucca/authority_rerank.go | 37 ++++++++++++++----- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/ai-compliance-sdk/internal/ucca/authority_rerank.go b/ai-compliance-sdk/internal/ucca/authority_rerank.go index a4b7d3d6..84932f5f 100644 --- a/ai-compliance-sdk/internal/ucca/authority_rerank.go +++ b/ai-compliance-sdk/internal/ucca/authority_rerank.go @@ -16,7 +16,7 @@ const ( scopePenalty = 0.25 // BDSG Teil 3 (law enforcement) on a general DP question topicGain = 0.18 // amplifier only supersededPenalty = 0.50 // superseded Alt-Quelle (pre-eu-v1): demoted, nicht versteckt - guidanceIntentGain = 0.25 // controlled guidance override on explicit interpretation intent + guidanceIntentGain = 0.10 // epsilon a qualifying guideline is lifted ABOVE the best binding hit guidanceIntentMargin = 0.05 // ...only if the guideline is semantically competitive with binding ) @@ -110,17 +110,36 @@ func rerankByAuthority(query string, results []LegalSearchResult) []LegalSearchR copy(out, results) for i := range out { out[i].Score = authorityScore(query, out[i], qDomain, qForeign) - // Interpretations-Intent (eng begrenzt): NUR wenn die Query explizit nach - // Guidance/Auslegung fragt UND die Leitlinie semantisch konkurrenzfaehig ist - // (>= bester binding-Treffer - margin), darf supervisory_guidance die bindende - // Norm ueberholen. Sonst bleibt binding > guidance (Normfrage unveraendert). - if wantsGuidance && out[i].SourceClass == "supervisory_guidance" && - results[i].Score >= bestBindingSem-guidanceIntentMargin { - out[i].Score += guidanceIntentGain - } + } + if wantsGuidance { + applyGuidanceIntent(out, results, bestBindingSem) } sort.SliceStable(out, func(a, b int) bool { return out[a].Score > out[b].Score }) return out } + +// applyGuidanceIntent lifts semantically-competitive guidance just ABOVE the best +// binding hit (ordered by semantic), so an EXPLICIT interpretation question can +// return guidance Top-1. Obligation questions (no intent → not called) keep +// binding on top. Guidance below the semantic margin is left untouched, so an +// off-topic guideline can never ride the override — and the lift is computed from +// the binding FINAL score, so authority/topic/domain bonuses cannot edge it out. +func applyGuidanceIntent(out, raw []LegalSearchResult, bestBindingSem float64) { + bestBindingFinal := 0.0 + for i := range out { + if out[i].SourceClass == "binding_law" && out[i].Score > bestBindingFinal { + bestBindingFinal = out[i].Score + } + } + for i := range out { + if out[i].SourceClass != "supervisory_guidance" || raw[i].Score < bestBindingSem-guidanceIntentMargin { + continue + } + lifted := bestBindingFinal + guidanceIntentGain + (raw[i].Score - bestBindingSem) + if lifted > out[i].Score { + out[i].Score = lifted + } + } +} From 05d75e803956970b2aa3518cab0dd4bf5e3542b7 Mon Sep 17 00:00:00 2001 From: Benjamin_Boenisch Date: Wed, 24 Jun 2026 09:58:35 +0000 Subject: [PATCH 04/16] =?UTF-8?q?feat(ai-sdk):=20control-intent=20?= =?UTF-8?q?=E2=80=94=20technical=5Fstandard=20may=20win=20implementation?= =?UTF-8?q?=20questions=20(#36)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../internal/ucca/authority_rerank.go | 81 ++++++++++++------- .../internal/ucca/legal_rag_intent_test.go | 62 ++++++++++++++ 2 files changed, 114 insertions(+), 29 deletions(-) diff --git a/ai-compliance-sdk/internal/ucca/authority_rerank.go b/ai-compliance-sdk/internal/ucca/authority_rerank.go index 84932f5f..1360b1b5 100644 --- a/ai-compliance-sdk/internal/ucca/authority_rerank.go +++ b/ai-compliance-sdk/internal/ucca/authority_rerank.go @@ -7,17 +7,17 @@ import ( // Re-ranking coefficients (validated in the offline golden harness; Phase A — conservative). const ( - authorityCoef = 0.40 // * weight/100 - jurisdictionGain = 0.05 // binding/guidance from DE or EU - foreignPenalty = 0.60 // foreign law on a DE/EU question (demoted, not removed) - unknownPenalty = 0.08 - domainMatchGain = 0.15 - offDomainPenalty = 0.10 // off-domain binding (demoted, not removed) - scopePenalty = 0.25 // BDSG Teil 3 (law enforcement) on a general DP question - topicGain = 0.18 // amplifier only - supersededPenalty = 0.50 // superseded Alt-Quelle (pre-eu-v1): demoted, nicht versteckt - guidanceIntentGain = 0.10 // epsilon a qualifying guideline is lifted ABOVE the best binding hit - guidanceIntentMargin = 0.05 // ...only if the guideline is semantically competitive with binding + authorityCoef = 0.40 // * weight/100 + jurisdictionGain = 0.05 // binding/guidance from DE or EU + foreignPenalty = 0.60 // foreign law on a DE/EU question (demoted, not removed) + unknownPenalty = 0.08 + domainMatchGain = 0.15 + offDomainPenalty = 0.10 // off-domain binding (demoted, not removed) + scopePenalty = 0.25 // BDSG Teil 3 (law enforcement) on a general DP question + topicGain = 0.18 // amplifier only + supersededPenalty = 0.50 // superseded Alt-Quelle (pre-eu-v1): demoted, nicht versteckt + intentLiftGain = 0.10 // epsilon a qualifying interpretative source is lifted ABOVE the best binding + intentLiftMargin = 0.05 // ...only if that source is semantically competitive with binding ) // guidanceIntentSignals mark a query that EXPLICITLY asks for an interpretation / @@ -29,10 +29,19 @@ var guidanceIntentSignals = []string{ "auslegung", "empfiehlt", "empfehlung", "sagt", "laut", } -// queryWantsGuidance reports whether the query explicitly asks for guidance/interpretation. -func queryWantsGuidance(query string) bool { +// controlIntentSignals mark a query that asks HOW to implement / which controls or +// measures fit — rather than WHAT the binding obligation is. Only then may a +// (semantically competitive) technical_standard outrank the binding norm. +var controlIntentSignals = []string{ + "control", "controls", "maßnahme", "massnahme", "schutzmaßnahme", + "best practice", "best-practice", "umsetzen", "implementier", "absicher", + "härt", "haert", "hardening", "nist", "owasp", "grundschutz", + "ccm", "iso 27001", "isms", +} + +func queryMatchesAny(query string, signals []string) bool { q := strings.ToLower(query) - for _, sig := range guidanceIntentSignals { + for _, sig := range signals { if strings.Contains(q, sig) { return true } @@ -40,11 +49,17 @@ func queryWantsGuidance(query string) bool { return false } +// queryWantsGuidance reports whether the query explicitly asks for guidance/interpretation. +func queryWantsGuidance(query string) bool { return queryMatchesAny(query, guidanceIntentSignals) } + +// queryWantsControls reports whether the query asks for implementation controls/measures. +func queryWantsControls(query string) bool { return queryMatchesAny(query, controlIntentSignals) } + // bestBindingSemantic returns the highest RAW semantic score among binding-law -// results (0 if none / intent not requested). Used as the guard threshold so an -// off-topic guideline cannot ride the interpretation-intent boost. -func bestBindingSemantic(results []LegalSearchResult, wantsGuidance bool) float64 { - if !wantsGuidance { +// results (0 if none / no intent). Used as the guard threshold so an off-topic +// interpretative source cannot ride the intent boost. +func bestBindingSemantic(results []LegalSearchResult, wantsIntent bool) float64 { + if !wantsIntent { return 0 } best := 0.0 @@ -104,15 +119,22 @@ func rerankByAuthority(query string, results []LegalSearchResult) []LegalSearchR qDomain := queryDomain(query) qForeign := queryIsForeign(query) wantsGuidance := queryWantsGuidance(query) - bestBindingSem := bestBindingSemantic(results, wantsGuidance) + wantsControls := queryWantsControls(query) + bestBindingSem := bestBindingSemantic(results, wantsGuidance || wantsControls) out := make([]LegalSearchResult, len(results)) copy(out, results) for i := range out { out[i].Score = authorityScore(query, out[i], qDomain, qForeign) } + // Explicit interpretation intent → a competitive guideline may outrank binding; + // explicit implementation intent → a competitive technical_standard may. Both lift + // ABOVE the best binding FINAL, so a pure norm question (neither intent) is untouched. if wantsGuidance { - applyGuidanceIntent(out, results, bestBindingSem) + liftAboveBinding(out, results, bestBindingSem, "supervisory_guidance") + } + if wantsControls { + liftAboveBinding(out, results, bestBindingSem, "technical_standard") } sort.SliceStable(out, func(a, b int) bool { return out[a].Score > out[b].Score @@ -120,13 +142,14 @@ func rerankByAuthority(query string, results []LegalSearchResult) []LegalSearchR return out } -// applyGuidanceIntent lifts semantically-competitive guidance just ABOVE the best -// binding hit (ordered by semantic), so an EXPLICIT interpretation question can -// return guidance Top-1. Obligation questions (no intent → not called) keep -// binding on top. Guidance below the semantic margin is left untouched, so an -// off-topic guideline can never ride the override — and the lift is computed from -// the binding FINAL score, so authority/topic/domain bonuses cannot edge it out. -func applyGuidanceIntent(out, raw []LegalSearchResult, bestBindingSem float64) { +// liftAboveBinding lifts a semantically-competitive interpretative source (the given +// sourceClass — supervisory_guidance or technical_standard) just ABOVE the best binding +// hit, ordered by semantic, so an EXPLICIT guidance/implementation question can return +// that source Top-1. A pure norm question (no intent → not called) keeps binding on top. +// Sources below the semantic margin are left untouched, so an off-topic source can never +// ride the override — and the lift is from the binding FINAL score, so authority/topic/ +// domain bonuses cannot edge it out. +func liftAboveBinding(out, raw []LegalSearchResult, bestBindingSem float64, sourceClass string) { bestBindingFinal := 0.0 for i := range out { if out[i].SourceClass == "binding_law" && out[i].Score > bestBindingFinal { @@ -134,10 +157,10 @@ func applyGuidanceIntent(out, raw []LegalSearchResult, bestBindingSem float64) { } } for i := range out { - if out[i].SourceClass != "supervisory_guidance" || raw[i].Score < bestBindingSem-guidanceIntentMargin { + if out[i].SourceClass != sourceClass || raw[i].Score < bestBindingSem-intentLiftMargin { continue } - lifted := bestBindingFinal + guidanceIntentGain + (raw[i].Score - bestBindingSem) + lifted := bestBindingFinal + intentLiftGain + (raw[i].Score - bestBindingSem) if lifted > out[i].Score { out[i].Score = lifted } diff --git a/ai-compliance-sdk/internal/ucca/legal_rag_intent_test.go b/ai-compliance-sdk/internal/ucca/legal_rag_intent_test.go index a24ec59c..25050b47 100644 --- a/ai-compliance-sdk/internal/ucca/legal_rag_intent_test.go +++ b/ai-compliance-sdk/internal/ucca/legal_rag_intent_test.go @@ -70,3 +70,65 @@ func TestRerank_OffTopicGuidance_BlockedByGuard(t *testing.T) { t.Errorf("off-topic guidance must not win even with intent, got %s", out[0].SourceClass) } } + +func TestQueryWantsControls(t *testing.T) { + wants := []string{ + "Welche Controls passen zu Security Updates?", + "Welche Maßnahmen sollten wir umsetzen?", + "Wie härten wir den Server ab?", + "Gibt es NIST-Controls dafür?", + "OWASP Best Practice für Logging?", + "BSI Grundschutz Bausteine", + } + plain := []string{ + "Welche Anforderungen bestehen an Security Updates?", + "Ab wann braucht man einen Datenschutzbeauftragten?", + } + for _, q := range wants { + if !queryWantsControls(q) { + t.Errorf("should detect control/implementation intent: %q", q) + } + } + for _, q := range plain { + if queryWantsControls(q) { + t.Errorf("should NOT detect control intent (norm question): %q", q) + } + } +} + +func TestRerank_ControlQuestion_StandardMayWin(t *testing.T) { + // Explicit implementation intent + standard semantically competitive → standard wins. + results := []LegalSearchResult{ + intentRes("NIST SP 800-82", "technical_standard", 0.62, 80), + intentRes("CRA", "binding_law", 0.58, 100), + } + out := rerankByAuthority("Welche Controls passen zu Security Updates?", results) + if out[0].SourceClass != "technical_standard" { + t.Errorf("control question: technical_standard should win Top-1, got %s", out[0].SourceClass) + } +} + +func TestRerank_NormQuestion_BindingOverStandard(t *testing.T) { + // "Anforderungen" → no control intent → binding stays Top-1 over the standard. + results := []LegalSearchResult{ + intentRes("NIST SP 800-82", "technical_standard", 0.62, 80), + intentRes("CRA", "binding_law", 0.58, 100), + } + out := rerankByAuthority("Welche Anforderungen bestehen an Security Updates?", results) + if out[0].SourceClass != "binding_law" { + t.Errorf("norm question: binding must stay Top-1 over standard, got %s", out[0].SourceClass) + } +} + +func TestRerank_OffTopicStandard_BlockedByGuard(t *testing.T) { + // Control intent present, but the standard is semantically far below binding → + // the margin guard keeps binding Top-1 (no off-topic standard override). + results := []LegalSearchResult{ + intentRes("NIST SP 800-82", "technical_standard", 0.40, 80), + intentRes("CRA", "binding_law", 0.58, 100), + } + out := rerankByAuthority("Welche Controls passen zu Security Updates?", results) + if out[0].SourceClass != "binding_law" { + t.Errorf("off-topic standard must not win even with control intent, got %s", out[0].SourceClass) + } +} From df7966656aa888480a467272f64fb4f862652840 Mon Sep 17 00:00:00 2001 From: Benjamin_Boenisch Date: Wed, 24 Jun 2026 10:15:17 +0000 Subject: [PATCH 05/16] feat(ai-sdk): classify NIST/OWASP/Grundschutz as technical_standard (#37) --- ai-compliance-sdk/internal/ucca/authority.go | 16 +++++++++++++--- .../internal/ucca/authority_rerank.go | 8 +++++--- .../internal/ucca/authority_test.go | 4 ++++ .../internal/ucca/legal_rag_intent_test.go | 14 ++++++++++++++ 4 files changed, 36 insertions(+), 6 deletions(-) diff --git a/ai-compliance-sdk/internal/ucca/authority.go b/ai-compliance-sdk/internal/ucca/authority.go index 53994f5a..715f80c7 100644 --- a/ai-compliance-sdk/internal/ucca/authority.go +++ b/ai-compliance-sdk/internal/ucca/authority.go @@ -9,8 +9,8 @@ import ( // authorityInfo is the normative classification of a search result, used internally // for re-ranking only (Phase 1 changes ordering, not the response contract). type authorityInfo struct { - weight int // 100 binding_law, 70 guidance, 0 foreign_law, 50 unknown - sourceClass string // binding_law | supervisory_guidance | foreign_law | unknown + weight int // 100 binding, 80 technical_standard, 70 guidance, 0 foreign, 50 unknown + sourceClass string // binding_law | technical_standard | supervisory_guidance | foreign_law | unknown jurisdiction string // DE | EU | CH } @@ -18,7 +18,13 @@ var ( guidanceMarkers = []string{ "DSK", "EDPB", "BfDI", "BFDI", "BayLfD", "Baylfb", "ENISA", "BSI", "EUCC", "Standards Mapping", "Kpnr", "Orientierungshilfe", "Handreichung", "Beschluss", - "Leitlinie", "Guidance", "Empfehlung", "NIST", "OECD", "CISA", "Blue Guide", + "Leitlinie", "Guidance", "Empfehlung", "OECD", "CISA", "Blue Guide", + } + // Technical standards / control frameworks (best-practice controls). Checked BEFORE + // guidanceMarkers so a "BSI Grundschutz" chunk classifies as a standard, not BSI guidance. + standardMarkers = []string{ + "NIST", "OWASP", "Grundschutz", "ISO 27001", "ISO/IEC 27001", + "CSA CCM", "Cloud Controls Matrix", "CIS Benchmark", "CIS Control", } foreignMarkers = []string{"RevDSG", "fedlex", "(CH)"} deMarkers = []string{"BDSG", "DSK", "BfDI", "BFDI", "BayLfD", "Baylfb", "BSI"} @@ -48,6 +54,8 @@ func classifyAuthority(r LegalSearchResult) authorityInfo { switch { case containsAny(hay, foreignMarkers): return authorityInfo{weight: 0, sourceClass: "foreign_law", jurisdiction: "CH"} + case r.Category == "standard" || containsAny(hay, standardMarkers): + return authorityInfo{weight: 80, sourceClass: "technical_standard", jurisdiction: jur} case r.Category == "guidance" || containsAny(hay, guidanceMarkers): return authorityInfo{weight: 70, sourceClass: "supervisory_guidance", jurisdiction: jur} case r.Category == "regulation" || r.Category == "eu_recht" || normPattern.MatchString(r.ArticleLabel): @@ -61,6 +69,8 @@ func sourceClassFromWeight(w int) string { switch { case w >= 100: return "binding_law" + case w >= 80: + return "technical_standard" case w >= 70: return "supervisory_guidance" case w <= 0: diff --git a/ai-compliance-sdk/internal/ucca/authority_rerank.go b/ai-compliance-sdk/internal/ucca/authority_rerank.go index 1360b1b5..e5cbf463 100644 --- a/ai-compliance-sdk/internal/ucca/authority_rerank.go +++ b/ai-compliance-sdk/internal/ucca/authority_rerank.go @@ -64,7 +64,7 @@ func bestBindingSemantic(results []LegalSearchResult, wantsIntent bool) float64 } best := 0.0 for _, r := range results { - if r.SourceClass == "binding_law" && r.Score > best { + if classifyAuthority(r).sourceClass == "binding_law" && r.Score > best { best = r.Score } } @@ -152,12 +152,14 @@ func rerankByAuthority(query string, results []LegalSearchResult) []LegalSearchR func liftAboveBinding(out, raw []LegalSearchResult, bestBindingSem float64, sourceClass string) { bestBindingFinal := 0.0 for i := range out { - if out[i].SourceClass == "binding_law" && out[i].Score > bestBindingFinal { + if classifyAuthority(out[i]).sourceClass == "binding_law" && out[i].Score > bestBindingFinal { bestBindingFinal = out[i].Score } } for i := range out { - if out[i].SourceClass != sourceClass || raw[i].Score < bestBindingSem-intentLiftMargin { + // Classify (not raw payload) so the untagged legacy corpus — e.g. NIST ingested + // before source_class tagging — is still recognized as its interpretative class. + if classifyAuthority(out[i]).sourceClass != sourceClass || raw[i].Score < bestBindingSem-intentLiftMargin { continue } lifted := bestBindingFinal + intentLiftGain + (raw[i].Score - bestBindingSem) diff --git a/ai-compliance-sdk/internal/ucca/authority_test.go b/ai-compliance-sdk/internal/ucca/authority_test.go index d4109c65..5e63e2a6 100644 --- a/ai-compliance-sdk/internal/ucca/authority_test.go +++ b/ai-compliance-sdk/internal/ucca/authority_test.go @@ -14,6 +14,10 @@ func TestClassifyAuthority(t *testing.T) { {"tagged guidance DE", LegalSearchResult{AuthorityWeight: 70, SourceClass: "supervisory_guidance", Jurisdiction: "DE"}, 70, "supervisory_guidance", "DE"}, {"tagged foreign CH", LegalSearchResult{AuthorityWeight: 0, SourceClass: "foreign_law", Jurisdiction: "CH"}, 0, "foreign_law", "CH"}, {"untagged ENISA guidance", LegalSearchResult{RegulationShort: "ENISA", ArticleLabel: "ENISA CRA Standards Mapping"}, 70, "supervisory_guidance", "EU"}, + {"untagged NIST standard", LegalSearchResult{RegulationShort: "NIST SP 800-82r3", ArticleLabel: "AU-8"}, 80, "technical_standard", "EU"}, + {"BSI Grundschutz standard beats BSI guidance", LegalSearchResult{RegulationShort: "BSI Grundschutz", ArticleLabel: "BSI Grundschutz Baustein"}, 80, "technical_standard", "DE"}, + {"weight-only 85 TRGS standard", LegalSearchResult{AuthorityWeight: 85, RegulationShort: "TRGS 529"}, 85, "technical_standard", "EU"}, + {"tagged technical_standard", LegalSearchResult{AuthorityWeight: 80, SourceClass: "technical_standard", Jurisdiction: "EU"}, 80, "technical_standard", "EU"}, {"untagged CRA binding", LegalSearchResult{RegulationShort: "CRA", ArticleLabel: "Art. 13 CRA", Category: "regulation"}, 100, "binding_law", "EU"}, {"untagged BDSG binding DE", LegalSearchResult{RegulationShort: "BDSG", ArticleLabel: "§ 38 BDSG"}, 100, "binding_law", "DE"}, {"untagged RevDSG foreign", LegalSearchResult{RegulationShort: "RevDSG", ArticleLabel: "RevDSG (CH)"}, 0, "foreign_law", "CH"}, diff --git a/ai-compliance-sdk/internal/ucca/legal_rag_intent_test.go b/ai-compliance-sdk/internal/ucca/legal_rag_intent_test.go index 25050b47..7c36c3d8 100644 --- a/ai-compliance-sdk/internal/ucca/legal_rag_intent_test.go +++ b/ai-compliance-sdk/internal/ucca/legal_rag_intent_test.go @@ -132,3 +132,17 @@ func TestRerank_OffTopicStandard_BlockedByGuard(t *testing.T) { t.Errorf("off-topic standard must not win even with control intent, got %s", out[0].SourceClass) } } + +func TestRerank_ControlQuestion_UntaggedNISTLifted(t *testing.T) { + // The existing NIST corpus is UNtagged (no source_class). It must still be classified + // technical_standard via markers and lifted on a control question — the whole reason + // the lift path classifies instead of trusting the raw payload field. + results := []LegalSearchResult{ + {RegulationShort: "NIST SP 800-82r3", ArticleLabel: "AU-8", Score: 0.62}, + {RegulationShort: "CRA", ArticleLabel: "Art. 13 CRA", Category: "regulation", Score: 0.58}, + } + out := rerankByAuthority("Welche Controls passen zu Security Updates?", results) + if out[0].RegulationShort != "NIST SP 800-82r3" { + t.Errorf("untagged NIST should be lifted Top-1 on a control question, got %q", out[0].RegulationShort) + } +} From 9cfe6f83b153b8efd101ffa63d6ec6bf3e2a4db2 Mon Sep 17 00:00:00 2001 From: Benjamin_Boenisch Date: Wed, 24 Jun 2026 11:12:22 +0000 Subject: [PATCH 06/16] feat(ai-sdk): source_role control-pool (controls != only technical_standard) (#38) --- .../internal/ucca/authority_rerank.go | 11 ++- .../internal/ucca/control_role.go | 94 +++++++++++++++++++ .../internal/ucca/control_role_test.go | 50 ++++++++++ .../internal/ucca/legal_rag_intent_test.go | 45 ++++----- 4 files changed, 166 insertions(+), 34 deletions(-) create mode 100644 ai-compliance-sdk/internal/ucca/control_role.go create mode 100644 ai-compliance-sdk/internal/ucca/control_role_test.go diff --git a/ai-compliance-sdk/internal/ucca/authority_rerank.go b/ai-compliance-sdk/internal/ucca/authority_rerank.go index e5cbf463..611b0111 100644 --- a/ai-compliance-sdk/internal/ucca/authority_rerank.go +++ b/ai-compliance-sdk/internal/ucca/authority_rerank.go @@ -120,21 +120,22 @@ func rerankByAuthority(query string, results []LegalSearchResult) []LegalSearchR qForeign := queryIsForeign(query) wantsGuidance := queryWantsGuidance(query) wantsControls := queryWantsControls(query) - bestBindingSem := bestBindingSemantic(results, wantsGuidance || wantsControls) + bestBindingSem := bestBindingSemantic(results, wantsGuidance) out := make([]LegalSearchResult, len(results)) copy(out, results) for i := range out { out[i].Score = authorityScore(query, out[i], qDomain, qForeign) } - // Explicit interpretation intent → a competitive guideline may outrank binding; - // explicit implementation intent → a competitive technical_standard may. Both lift - // ABOVE the best binding FINAL, so a pure norm question (neither intent) is untouched. + // Explicit interpretation intent → a competitive guideline may outrank binding (lift + // above the best binding FINAL). Explicit implementation intent → boost the CONTROL-POOL + // (operational/procedural requirement, control standard, implementation guidance) over + // the abstract obligation, soft-ordered by role. Norm questions (neither) stay untouched. if wantsGuidance { liftAboveBinding(out, results, bestBindingSem, "supervisory_guidance") } if wantsControls { - liftAboveBinding(out, results, bestBindingSem, "technical_standard") + applyControlRoles(out) } sort.SliceStable(out, func(a, b int) bool { return out[a].Score > out[b].Score diff --git a/ai-compliance-sdk/internal/ucca/control_role.go b/ai-compliance-sdk/internal/ucca/control_role.go new file mode 100644 index 00000000..fd2d7ece --- /dev/null +++ b/ai-compliance-sdk/internal/ucca/control_role.go @@ -0,0 +1,94 @@ +package ucca + +import "strings" + +// source_role is the FUNCTIONAL role of a chunk — WHAT must be done (obligation), +// HOW to implement it (operational/procedural requirement, control standard, +// implementation guidance), or how to READ the norm (interpretation/definition). +// It is ORTHOGONAL to source_class (legal authority): source_class decides RANK, +// source_role decides CONTROL-POOL membership for implementation questions. +// Derived deterministically from markers, so the untagged corpus needs no re-tag. +const ( + roleObligation = "obligation" // the abstract duty (the WHAT) + roleOperationalReq = "operational_requirement" // concrete binding requirement (CRA Annex I) + roleProceduralReq = "procedural_requirement" // a process: notification/registration/DPIA/incident report + roleControlStandard = "control_standard" // best-practice control catalog (NIST/OWASP/ISO/CIS) + roleImplGuidance = "implementation_guidance" // advisory how-to (ENISA good practices, BSI) + roleInterpretation = "interpretation" // interprets the norm's MEANING (EDPB guideline) + roleDefinition = "definition" // definitions / scope / recitals +) + +var ( + proceduralMarkers = []string{ + "Meldung", "Meldepflicht", "Notification", "Notifizierung", "Registrierung", + "Registration", "Konformitätserklärung", "Declaration of Conformity", "Incident", + "Berichterstattung", "Reporting", "Folgenabschätzung", "DSFA", "DPIA", "Anzeigepflicht", + } + annexMarkers = []string{"Anhang", "Annex", "Appendix", "Anlage"} + operationalMarkers = []string{"Anforderung", "Requirement", "essential", "wesentliche"} + implMarkers = []string{ + "Good Practice", "Best Practice", "Standards Mapping", "Umsetzung", "Implementation", + "Handreichung", "Maßnahmenkatalog", "ICS", "SCADA", "Technical Guideline", "TIG", + } + definitionMarkers = []string{"Begriffsbestimmung", "Definition"} +) + +// classifyRole derives the functional source_role from chunk metadata + the authority +// class. technical_standard is always a control_standard; guidance splits into +// implementation_guidance (how-to) vs interpretation (meaning); binding splits into +// procedural / operational requirement / definition / plain obligation. +func classifyRole(r LegalSearchResult) string { + cls := classifyAuthority(r).sourceClass + hay := strings.ToLower(r.ArticleLabel + " " + r.RegulationShort + " " + r.RegulationName + " " + r.Article) + switch { + case r.IsRecital: + return roleDefinition + case cls == "technical_standard": + return roleControlStandard + case cls == "supervisory_guidance": + if containsAnyLower(hay, implMarkers) { + return roleImplGuidance + } + return roleInterpretation + case cls == "binding_law": + switch { + case containsAnyLower(hay, definitionMarkers): + return roleDefinition + case containsAnyLower(hay, proceduralMarkers): + return roleProceduralReq + case containsAnyLower(hay, annexMarkers) || containsAnyLower(hay, operationalMarkers): + return roleOperationalReq + default: + return roleObligation + } + default: + return roleObligation + } +} + +// controlRoleBonus is the soft intra-pool preference (User 2026-06-24): +// operational_requirement > procedural_requirement > control_standard > implementation_guidance. +var controlRoleBonus = map[string]float64{ + roleOperationalReq: 0.100, + roleProceduralReq: 0.075, + roleControlStandard: 0.050, + roleImplGuidance: 0.000, +} + +// controlPoolGain lifts EVERY control-pool role over the non-control roles (obligation/ +// interpretation/definition) on an implementation question, so the binding abstract +// obligation does not dominate by authority alone. The obligation is not removed — it +// stays visible as "Rechtsgrundlage" context below the recommended measures. +const controlPoolGain = 0.15 + +// applyControlRoles boosts the control-pool (the four implementation roles) for an +// EXPLICIT implementation question, soft-ordered op_req > procedural > standard > guidance. +// Replaces the earlier "lift technical_standard above binding" — controls are not only +// technical_standard, and the binding operational_requirement (e.g. CRA Annex I) should win. +func applyControlRoles(out []LegalSearchResult) { + for i := range out { + if bonus, ok := controlRoleBonus[classifyRole(out[i])]; ok { + out[i].Score += controlPoolGain + bonus + } + } +} diff --git a/ai-compliance-sdk/internal/ucca/control_role_test.go b/ai-compliance-sdk/internal/ucca/control_role_test.go new file mode 100644 index 00000000..b4872769 --- /dev/null +++ b/ai-compliance-sdk/internal/ucca/control_role_test.go @@ -0,0 +1,50 @@ +package ucca + +import "testing" + +func TestClassifyRole(t *testing.T) { + tests := []struct { + name string + r LegalSearchResult + want string + }{ + {"NIST -> control_standard", LegalSearchResult{RegulationShort: "NIST SP 800-82r3", ArticleLabel: "AU-8"}, roleControlStandard}, + {"OWASP -> control_standard", LegalSearchResult{RegulationShort: "OWASP ASVS"}, roleControlStandard}, + {"CRA Anhang -> operational_requirement", LegalSearchResult{RegulationShort: "CRA", ArticleLabel: "CRA Anhang I", Category: "regulation"}, roleOperationalReq}, + {"CRA Meldepflicht -> procedural_requirement", LegalSearchResult{RegulationShort: "CRA", ArticleLabel: "Art. 14 CRA Meldepflicht", Category: "regulation"}, roleProceduralReq}, + {"ENISA Good Practices -> implementation_guidance", LegalSearchResult{RegulationShort: "ENISA Supply Chain Good Practices"}, roleImplGuidance}, + {"EDPB Leitlinie -> interpretation", LegalSearchResult{RegulationShort: "EDPB DPO", ArticleLabel: "WP243 Leitlinien Datenschutzbeauftragte"}, roleInterpretation}, + {"DORA article -> obligation", LegalSearchResult{RegulationShort: "DORA", ArticleLabel: "Art. 5 DORA", Category: "regulation"}, roleObligation}, + {"DSGVO Begriffsbestimmungen -> definition", LegalSearchResult{RegulationShort: "DSGVO", ArticleLabel: "Art. 4 DSGVO Begriffsbestimmungen", Category: "regulation"}, roleDefinition}, + {"recital -> definition", LegalSearchResult{RegulationShort: "CRA", IsRecital: true}, roleDefinition}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := classifyRole(tt.r); got != tt.want { + t.Errorf("classifyRole() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestApplyControlRoles_PoolPreference(t *testing.T) { + // op_req > procedural > control_standard > impl_guidance; non-control roles get no boost. + roles := []struct { + r LegalSearchResult + wantGain float64 + }{ + {LegalSearchResult{ArticleLabel: "CRA Anhang I", Category: "regulation"}, controlPoolGain + 0.100}, + {LegalSearchResult{ArticleLabel: "Art. 14 CRA Meldepflicht", Category: "regulation"}, controlPoolGain + 0.075}, + {LegalSearchResult{RegulationShort: "NIST SP 800-53"}, controlPoolGain + 0.050}, + {LegalSearchResult{RegulationShort: "ENISA Good Practices"}, controlPoolGain + 0.000}, + {LegalSearchResult{ArticleLabel: "Art. 5 DORA", Category: "regulation"}, 0.0}, // obligation: no boost + } + for _, rc := range roles { + out := []LegalSearchResult{rc.r} + out[0].Score = 1.0 + applyControlRoles(out) + if got := out[0].Score - 1.0; got < rc.wantGain-1e-9 || got > rc.wantGain+1e-9 { + t.Errorf("role %q: gain %.3f, want %.3f", classifyRole(rc.r), got, rc.wantGain) + } + } +} diff --git a/ai-compliance-sdk/internal/ucca/legal_rag_intent_test.go b/ai-compliance-sdk/internal/ucca/legal_rag_intent_test.go index 7c36c3d8..4005304a 100644 --- a/ai-compliance-sdk/internal/ucca/legal_rag_intent_test.go +++ b/ai-compliance-sdk/internal/ucca/legal_rag_intent_test.go @@ -96,20 +96,21 @@ func TestQueryWantsControls(t *testing.T) { } } -func TestRerank_ControlQuestion_StandardMayWin(t *testing.T) { - // Explicit implementation intent + standard semantically competitive → standard wins. +func TestRerank_ControlQuestion_OperationalReqTop(t *testing.T) { + // User priority for implementation questions: operational_requirement (binding concrete, + // CRA Anhang I) > control_standard (NIST). Both are in the control-pool; op_req wins. results := []LegalSearchResult{ - intentRes("NIST SP 800-82", "technical_standard", 0.62, 80), - intentRes("CRA", "binding_law", 0.58, 100), + {RegulationShort: "NIST SP 800-82r3", ArticleLabel: "AU-8", SourceClass: "technical_standard", AuthorityWeight: 80, Jurisdiction: "EU", Score: 0.60}, + {RegulationShort: "CRA", ArticleLabel: "CRA Anhang I", Category: "regulation", Score: 0.58}, } - out := rerankByAuthority("Welche Controls passen zu Security Updates?", results) - if out[0].SourceClass != "technical_standard" { - t.Errorf("control question: technical_standard should win Top-1, got %s", out[0].SourceClass) + out := rerankByAuthority("Welche Controls und Massnahmen passen zu Security Updates?", results) + if out[0].RegulationShort != "CRA" { + t.Errorf("operational_requirement (CRA Anhang I) should be Top-1 over control_standard, got %q", out[0].RegulationShort) } } func TestRerank_NormQuestion_BindingOverStandard(t *testing.T) { - // "Anforderungen" → no control intent → binding stays Top-1 over the standard. + // "Anforderungen" → no control intent → binding obligation stays Top-1 over the standard. results := []LegalSearchResult{ intentRes("NIST SP 800-82", "technical_standard", 0.62, 80), intentRes("CRA", "binding_law", 0.58, 100), @@ -120,29 +121,15 @@ func TestRerank_NormQuestion_BindingOverStandard(t *testing.T) { } } -func TestRerank_OffTopicStandard_BlockedByGuard(t *testing.T) { - // Control intent present, but the standard is semantically far below binding → - // the margin guard keeps binding Top-1 (no off-topic standard override). +func TestRerank_ControlQuestion_PoolBeatsBareObligation(t *testing.T) { + // A control-pool source (NIST control_standard) outranks an abstract obligation with no + // domain/topic advantage, because the implementation intent boosts the control-pool. results := []LegalSearchResult{ - intentRes("NIST SP 800-82", "technical_standard", 0.40, 80), - intentRes("CRA", "binding_law", 0.58, 100), + {RegulationShort: "NIST SP 800-82r3", ArticleLabel: "AU-8", SourceClass: "technical_standard", AuthorityWeight: 80, Jurisdiction: "EU", Score: 0.55}, + {RegulationShort: "XYZ", ArticleLabel: "Art. 5 XYZ", Category: "regulation", Score: 0.58}, } - out := rerankByAuthority("Welche Controls passen zu Security Updates?", results) - if out[0].SourceClass != "binding_law" { - t.Errorf("off-topic standard must not win even with control intent, got %s", out[0].SourceClass) - } -} - -func TestRerank_ControlQuestion_UntaggedNISTLifted(t *testing.T) { - // The existing NIST corpus is UNtagged (no source_class). It must still be classified - // technical_standard via markers and lifted on a control question — the whole reason - // the lift path classifies instead of trusting the raw payload field. - results := []LegalSearchResult{ - {RegulationShort: "NIST SP 800-82r3", ArticleLabel: "AU-8", Score: 0.62}, - {RegulationShort: "CRA", ArticleLabel: "Art. 13 CRA", Category: "regulation", Score: 0.58}, - } - out := rerankByAuthority("Welche Controls passen zu Security Updates?", results) + out := rerankByAuthority("Welche Controls und Massnahmen passen zu Security Updates?", results) if out[0].RegulationShort != "NIST SP 800-82r3" { - t.Errorf("untagged NIST should be lifted Top-1 on a control question, got %q", out[0].RegulationShort) + t.Errorf("control_standard should beat a bare abstract obligation on a control question, got %q", out[0].RegulationShort) } } From e50892a2aa3f625ec3f6729c5d530f504b569dbf Mon Sep 17 00:00:00 2001 From: Benjamin_Boenisch Date: Wed, 24 Jun 2026 12:08:29 +0000 Subject: [PATCH 07/16] =?UTF-8?q?feat(ai-sdk):=20searchControls=20?= =?UTF-8?q?=E2=80=94=20recall=20control=20sources=20on=20implementation=20?= =?UTF-8?q?questions=20(#39)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../internal/ucca/control_role.go | 29 +++++++++++++++++++ .../internal/ucca/control_role_test.go | 29 +++++++++++++++++++ .../internal/ucca/legal_rag_client.go | 9 ++++++ .../internal/ucca/legal_rag_http.go | 28 ++++++++++++++++++ 4 files changed, 95 insertions(+) diff --git a/ai-compliance-sdk/internal/ucca/control_role.go b/ai-compliance-sdk/internal/ucca/control_role.go index fd2d7ece..8cfa509e 100644 --- a/ai-compliance-sdk/internal/ucca/control_role.go +++ b/ai-compliance-sdk/internal/ucca/control_role.go @@ -92,3 +92,32 @@ func applyControlRoles(out []LegalSearchResult) { } } } + +// isControlPoolRole reports whether a role belongs to the control-pool surfaced on +// implementation questions (the four "how to implement" roles). +func isControlPoolRole(role string) bool { + switch role { + case roleOperationalReq, roleProceduralReq, roleControlStandard, roleImplGuidance: + return true + } + return false +} + +// controlRoleOf classifies a raw Qdrant payload into a source_role, so searchControls can +// filter its deep dense pull to the control-pool BEFORE hits are mapped to LegalSearchResult. +func controlRoleOf(payload map[string]interface{}) string { + article := getString(payload, "article") + if article == "" { + article = getString(payload, "section") + } + return classifyRole(LegalSearchResult{ + RegulationShort: getString(payload, "regulation_short"), + RegulationName: getString(payload, "regulation_name_de"), + ArticleLabel: getString(payload, "article_label"), + Article: article, + Category: getString(payload, "category"), + SourceClass: getString(payload, "source_class"), + AuthorityWeight: getInt(payload, "authority_weight"), + IsRecital: getBool(payload, "is_recital"), + }) +} diff --git a/ai-compliance-sdk/internal/ucca/control_role_test.go b/ai-compliance-sdk/internal/ucca/control_role_test.go index b4872769..597516d6 100644 --- a/ai-compliance-sdk/internal/ucca/control_role_test.go +++ b/ai-compliance-sdk/internal/ucca/control_role_test.go @@ -48,3 +48,32 @@ func TestApplyControlRoles_PoolPreference(t *testing.T) { } } } + +func TestIsControlPoolRole(t *testing.T) { + for _, r := range []string{roleOperationalReq, roleProceduralReq, roleControlStandard, roleImplGuidance} { + if !isControlPoolRole(r) { + t.Errorf("%q should be in the control-pool", r) + } + } + for _, r := range []string{roleObligation, roleInterpretation, roleDefinition} { + if isControlPoolRole(r) { + t.Errorf("%q should NOT be in the control-pool", r) + } + } +} + +func TestControlRoleOf_Payload(t *testing.T) { + // searchControls filters its deep dense pull by classifying the raw Qdrant payload. + nist := map[string]interface{}{"regulation_short": "NIST SP 800-82r3", "article": "AU-8"} + if got := controlRoleOf(nist); got != roleControlStandard { + t.Errorf("untagged NIST payload role = %q, want control_standard", got) + } + craAnnex := map[string]interface{}{"regulation_short": "CRA", "article": "Anhang-I", "category": "regulation"} + if got := controlRoleOf(craAnnex); got != roleOperationalReq { + t.Errorf("CRA Anhang payload role = %q, want operational_requirement", got) + } + dora := map[string]interface{}{"regulation_short": "DORA", "article_label": "Art. 5 DORA", "category": "regulation"} + if got := controlRoleOf(dora); isControlPoolRole(got) { + t.Errorf("DORA abstract article role = %q must be excluded from the control-pool", got) + } +} diff --git a/ai-compliance-sdk/internal/ucca/legal_rag_client.go b/ai-compliance-sdk/internal/ucca/legal_rag_client.go index 2942265c..0ac9f489 100644 --- a/ai-compliance-sdk/internal/ucca/legal_rag_client.go +++ b/ai-compliance-sdk/internal/ucca/legal_rag_client.go @@ -107,6 +107,15 @@ func (c *LegalRAGClient) searchInternal(ctx context.Context, collection string, hits = mergeDedupHits(hits, bindingHits) } + // Control-Augmentation: bei expliziter Umsetzungsfrage einen tiefen dense-Pool ziehen und + // nur die Control-Pool-Rollen behalten — so werden NIST/CRA-Anhang (dense rank ~8-9, unter + // dem kleinen top-K) Kandidaten. Re-Rank/applyControlRoles ordnen sie danach. + if queryWantsControls(query) { + if controlHits, cErr := c.searchControls(ctx, collection, embedding); cErr == nil { + hits = mergeDedupHits(hits, controlHits) + } + } + // Graph-Augmentation: verbundene Normen (references_out/in) der Top-Hits ueber die // praezise Zitations-Kante in den Pool ziehen — z.B. Art. 13 CRA zieht Anhang I (die // eigentliche Pflichtquelle). Pool-Augmentation only; Re-Rank + topK bleiben. diff --git a/ai-compliance-sdk/internal/ucca/legal_rag_http.go b/ai-compliance-sdk/internal/ucca/legal_rag_http.go index 5d68181e..c9805d0a 100644 --- a/ai-compliance-sdk/internal/ucca/legal_rag_http.go +++ b/ai-compliance-sdk/internal/ucca/legal_rag_http.go @@ -204,6 +204,34 @@ func (c *LegalRAGClient) searchBinding(ctx context.Context, collection string, e return c.doPointsSearch(ctx, collection, searchReq) } +// controlPoolDepth is how deep the dense control pull reaches. Measured: for an EU-cyber +// control query the relevant control sources sit at dense rank ~8-9 (NIST, CRA Annex), far +// below the client's small top-K — so a fixed dense depth of 60 reliably surfaces them. +const controlPoolDepth = 60 + +// searchControls fetches a DEEP dense pool and keeps only the control-pool roles, so control +// sources that the small top-K (hybrid) search misses become candidates on an implementation +// question. Role is derived in code (no source_role tag needed). AUGMENTS the pool — the +// caller gates it on control-intent. +func (c *LegalRAGClient) searchControls(ctx context.Context, collection string, embedding []float64) ([]qdrantSearchHit, error) { + searchReq := qdrantSearchRequest{ + Vector: embedding, + Limit: controlPoolDepth, + WithPayload: true, + } + hits, err := c.doPointsSearch(ctx, collection, searchReq) + if err != nil { + return nil, err + } + kept := make([]qdrantSearchHit, 0, len(hits)) + for _, h := range hits { + if isControlPoolRole(controlRoleOf(h.Payload)) { + kept = append(kept, h) + } + } + return kept, nil +} + // doPointsSearch issues a POST /points/search and decodes the hits. func (c *LegalRAGClient) doPointsSearch(ctx context.Context, collection string, searchReq qdrantSearchRequest) ([]qdrantSearchHit, error) { jsonBody, err := json.Marshal(searchReq) From a6f1020b2c97d17931609ea33e01835ad62e8cc6 Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Wed, 24 Jun 2026 13:21:58 +0200 Subject: [PATCH 08/16] feat(ai-sdk): IACE warewashing hazard patterns + cross-domain gating MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add commercial-dishwasher hazard patterns (HP2200-HP2206): hot-water/steam scald on door opening, hot surfaces, hot ware, corrosive detergent/rinse-aid burn, respiratory irritation, door pinch and wet-floor slip — each gated by dom_warewashing so they never leak into other machine classes. Add the matching warewashing protective measures (M2200-M2208). Tighten capability-domain gating: emit dom_flame/dom_glue and add welding surface-form gate terms (schweissarbeitsplatz, schweissfunke, lichtbogenzone, ...) so the welding/flame/glue burn patterns stop leaking into thermal-capable machines such as a dishwasher. Co-Authored-By: Claude Opus 4.7 --- .../iace/hazard_patterns_warewashing.go | 132 ++++++++++++++++++ .../iace/hazard_patterns_warewashing_test.go | 112 +++++++++++++++ .../internal/iace/keyword_dictionary.go | 15 ++ .../internal/iace/measures_library.go | 1 + .../iace/measures_library_warewashing.go | 69 +++++++++ .../internal/iace/pattern_domain_gates.go | 14 ++ .../internal/iace/pattern_registry.go | 1 + 7 files changed, 344 insertions(+) create mode 100644 ai-compliance-sdk/internal/iace/hazard_patterns_warewashing.go create mode 100644 ai-compliance-sdk/internal/iace/hazard_patterns_warewashing_test.go create mode 100644 ai-compliance-sdk/internal/iace/measures_library_warewashing.go diff --git a/ai-compliance-sdk/internal/iace/hazard_patterns_warewashing.go b/ai-compliance-sdk/internal/iace/hazard_patterns_warewashing.go new file mode 100644 index 00000000..f01bb94b --- /dev/null +++ b/ai-compliance-sdk/internal/iace/hazard_patterns_warewashing.go @@ -0,0 +1,132 @@ +package iace + +// GetWarewashingPatterns returns hazard patterns for commercial warewashing +// machines (gewerbliche Geschirrspuelmaschinen / Untertisch-, Hauben-, Korb- +// und Bandspuelmaschinen). These capture the machine-specific hazards a +// Fachmann immediately expects but that the generic library did not cover: +// hot-water/steam scalding on door opening, hot surfaces, hot ware, corrosive +// detergent/rinse-aid contact, door pinch and wet-floor slipping. +// +// Every pattern is gated by the capability tag "dom_warewashing" (emitted only +// by warewashing narrative keywords in keyword_dictionary.go), so none of these +// leak into unrelated machine classes. +// +// HP range: HP2200-HP2206. ISO 12100 Annex B section identifiers only (facts); +// product standard EN 60335-2-58 (commercial dishwashing machines). +func GetWarewashingPatterns() []HazardPattern { + return []HazardPattern{ + { + ID: "HP2200", NameDE: "Verbruehung durch Heisswasser/Dampf beim Oeffnen der Tuer", NameEN: "Scalding by hot water/steam when opening the door", + RequiredComponentTags: []string{"dom_warewashing", "steam_emission"}, + GeneratedHazardCats: []string{"thermal_hazard"}, + SuggestedMeasureIDs: []string{"M2200", "M2201", "M2202", "M2208"}, + Priority: 94, + ApplicableLifecycles: []string{"normal_operation", "cleaning"}, + ScenarioDE: "Beim Oeffnen der Tuer waehrend oder unmittelbar nach dem Spuelgang tritt ein Schwall aus heissem Wasser und Wrasen (Dampf) aus der Spuelkammer aus und trifft Gesicht, Haende und Arme des Bedieners.", + TriggerDE: "Tuer wird vor Programmende oder bei noch vorhandenem Restdampf geoeffnet; Tuerverriegelung fehlt oder ist ueberbrueckt; Nachspueltemperatur ca. 85 Grad C.", + HarmDE: "Verbruehung 1.-2. Grades an Gesicht, Haenden und Unterarmen; Augenreizung durch heissen Dampf.", + AffectedDE: "Bedienpersonal (Spuelkraft)", + ZoneDE: "Tuer- und Beschickungsoeffnung der Spuelkammer", + ISO12100Section: "6.2.4", + DefaultSeverity: 3, DefaultExposure: 4, + }, + { + ID: "HP2201", NameDE: "Verbrennung an heissen Oberflaechen (Boiler/Tank/Spuelkammer)", NameEN: "Burn on hot surfaces (boiler/tank/wash chamber)", + RequiredComponentTags: []string{"dom_warewashing", "high_temperature"}, + GeneratedHazardCats: []string{"thermal_hazard"}, + SuggestedMeasureIDs: []string{"M2202", "M055", "M2208"}, + Priority: 90, + ApplicableLifecycles: []string{"cleaning", "maintenance"}, + ScenarioDE: "Beruehrung heisser Oberflaechen von Boiler, Tankheizkoerper oder Spuelkammerwaenden bei Reinigung, Entkalkung oder Wartung fuehrt zu Kontaktverbrennungen.", + TriggerDE: "Reinigung/Entkalkung ohne Abkuehlzeit; Eingriff in die Spuelkammer bei betriebswarmem Geraet.", + HarmDE: "Kontaktverbrennung an Haenden und Unterarmen.", + AffectedDE: "Reinigungspersonal, Wartungspersonal", + ZoneDE: "Boiler, Tankheizkoerper, Spuelkammerwaende", + ISO12100Section: "6.2.4", + DefaultSeverity: 2, DefaultExposure: 3, + }, + { + ID: "HP2202", NameDE: "Verbrennung an heissem Spuelgut beim Entladen", NameEN: "Burn on hot ware when unloading", + RequiredComponentTags: []string{"dom_warewashing", "hot_water"}, + GeneratedHazardCats: []string{"thermal_hazard"}, + SuggestedMeasureIDs: []string{"M2202", "M055", "M2208"}, + Priority: 86, + ApplicableLifecycles: []string{"normal_operation"}, + ScenarioDE: "Geschirr, Glaeser und Bestecke sind nach dem Spuelgang durch die Heisswasser-Nachspuelung sehr heiss; beim Entladen kommt es zu Verbrennungen.", + TriggerDE: "Sofortiges Entnehmen des Spuelguts nach Programmende ohne Abkuehl-/Trocknungszeit.", + HarmDE: "Verbrennung an Haenden/Fingern beim Greifen heisser Teile.", + AffectedDE: "Bedienpersonal (Spuelkraft)", + ZoneDE: "Spuelkammer, Entnahmebereich/Korb", + ISO12100Section: "6.2.4", + DefaultSeverity: 2, DefaultExposure: 3, + }, + { + ID: "HP2203", NameDE: "Chemische Veraetzung (Haut/Augen) durch Reiniger-/Klarspueler-Konzentrat", NameEN: "Chemical burn (skin/eyes) from detergent/rinse-aid concentrate", + RequiredComponentTags: []string{"dom_warewashing", "corrosive_chemical"}, + GeneratedHazardCats: []string{"chemical_risk"}, + SuggestedMeasureIDs: []string{"M2203", "M2204", "M2208"}, + Priority: 92, + ApplicableLifecycles: []string{"normal_operation", "maintenance"}, + ScenarioDE: "Direkter Kontakt mit dem aetzenden (alkalischen) Reiniger- bzw. Klarspueler-Konzentrat beim Nachfuellen, Sauglanzenwechsel oder bei Leckage fuehrt zu Veraetzungen von Haut und Augen.", + TriggerDE: "Gebinde-/Sauglanzenwechsel ohne Schutzausruestung; Umfuellen von Konzentrat; undichte Dosierleitung.", + HarmDE: "Veraetzung von Haut und Augen (alkalische Verletzung), bleibende Augenschaeden moeglich.", + AffectedDE: "Bedienpersonal, Reinigungspersonal beim Chemikalien-Handling", + ZoneDE: "Dosiergeraet, Reiniger-/Klarspueler-Gebinde, Sauglanzen", + ISO12100Section: "6.2.4", + DefaultSeverity: 3, DefaultExposure: 3, + ClarificationQuestionsDE: []string{ + "Liegt fuer alle eingesetzten Reiniger/Klarspueler/Entkalker ein aktuelles Sicherheitsdatenblatt (SDB) am Geraet vor?", + "Ist ein geschlossenes Dosiersystem mit Sauglanzen vorhanden, sodass kein Umfuellen noetig ist?", + }, + }, + { + ID: "HP2204", NameDE: "Reizung/Veraetzung der Atemwege durch Reinigungs-Aerosole/Daempfe", NameEN: "Respiratory irritation from cleaning aerosols/vapours", + RequiredComponentTags: []string{"dom_warewashing", "corrosive_chemical"}, + GeneratedHazardCats: []string{"chemical_risk"}, + SuggestedMeasureIDs: []string{"M2205", "M2203", "M2204"}, + Priority: 82, + ApplicableLifecycles: []string{"normal_operation", "maintenance"}, + ScenarioDE: "Aerosole und Daempfe der Reinigungschemie (insbesondere beim Oeffnen kurz nach dem Spuelgang oder bei der Entkalkung mit Saeure) gelangen in die Atemzone und reizen Atemwege und Schleimhaeute.", + TriggerDE: "Oeffnen bei laufender/heisser Chemie; Entkalkung mit Saeure; unzureichende Lueftung des Aufstellbereichs.", + HarmDE: "Reizung von Atemwegen, Augen und Schleimhaeuten; bei Saeure-/Laugen-Vermischung gefaehrliche Gase.", + AffectedDE: "Bedienpersonal, Reinigungspersonal", + ZoneDE: "Atemzone vor der Spuelkammer, Aufstellbereich", + ISO12100Section: "6.2.4", + DefaultSeverity: 2, DefaultExposure: 2, + ClarificationQuestionsDE: []string{ + "Ist der Aufstellbereich ausreichend be-/entlueftet (Kuechenlueftung)?", + "Wird in der BA vor dem Vermischen von Reiniger und Entkalker/Saeure gewarnt?", + }, + }, + { + ID: "HP2205", NameDE: "Quetschen der Finger an der Tuer/Haube", NameEN: "Finger crushing at the door/hood", + RequiredComponentTags: []string{"dom_warewashing", "access_door"}, + GeneratedHazardCats: []string{"mechanical_hazard"}, + SuggestedMeasureIDs: []string{"M2206", "M003", "M2208"}, + Priority: 78, + ApplicableLifecycles: []string{"normal_operation"}, + ScenarioDE: "Beim Schliessen der Tuer bzw. Absenken der Haube werden Finger zwischen Tuer/Haube und Gehaeuse gequetscht.", + TriggerDE: "Greifen in den Schliessbereich beim Schliessen; hohe Schliesskraft der Haube; scharfe Kanten.", + HarmDE: "Quetschung und Prellung der Finger.", + AffectedDE: "Bedienpersonal (Spuelkraft)", + ZoneDE: "Tuer-/Haubenkante, Schliessbereich", + ISO12100Section: "6.2.3", + DefaultSeverity: 1, DefaultExposure: 3, + }, + { + ID: "HP2206", NameDE: "Ausrutschen auf nassem Boden (Wasseraustritt/Leckage)", NameEN: "Slipping on wet floor (water leakage)", + RequiredComponentTags: []string{"dom_warewashing"}, + GeneratedHazardCats: []string{"mechanical_hazard"}, + SuggestedMeasureIDs: []string{"M2207", "M538", "M2208"}, + Priority: 76, + ApplicableLifecycles: []string{"normal_operation", "cleaning", "maintenance"}, + ScenarioDE: "Aus der Spuelmaschine austretendes Wasser (Beschickung, Tuer oeffnen, Leckage, Tankwasserwechsel) macht den Boden im Aufstellbereich rutschig; der Bediener rutscht aus.", + TriggerDE: "Wasseraustritt beim Oeffnen/Beschicken; undichter Ablauf; fehlender Bodenablauf.", + HarmDE: "Sturz mit Prellungen, Knochenbruechen oder Kopfaufprall.", + AffectedDE: "Bedienpersonal, Reinigungspersonal", + ZoneDE: "Aufstell- und Bedienbereich der Spuelmaschine", + ISO12100Section: "6.3.5.6", + DefaultSeverity: 2, DefaultExposure: 3, + }, + } +} diff --git a/ai-compliance-sdk/internal/iace/hazard_patterns_warewashing_test.go b/ai-compliance-sdk/internal/iace/hazard_patterns_warewashing_test.go new file mode 100644 index 00000000..070ccf33 --- /dev/null +++ b/ai-compliance-sdk/internal/iace/hazard_patterns_warewashing_test.go @@ -0,0 +1,112 @@ +package iace + +import "testing" + +// firedSet runs the engine for the given custom tags and returns the set of +// fired pattern IDs. +func firedSet(customTags []string) map[string]bool { + engine := NewPatternEngine() + out := engine.Match(MatchInput{CustomTags: customTags}) + fired := make(map[string]bool, len(out.MatchedPatterns)) + for _, m := range out.MatchedPatterns { + fired[m.PatternID] = true + } + return fired +} + +// A warewashing narrative emits these capability + functional tags. +var warewashingTags = []string{ + "dom_warewashing", "steam_emission", "hot_water", "high_temperature", + "corrosive_chemical", "access_door", "rotating_part", +} + +func TestWarewashing_PatternsFireForDishwasher(t *testing.T) { + fired := firedSet(warewashingTags) + want := []string{"HP2200", "HP2201", "HP2202", "HP2203", "HP2204", "HP2205", "HP2206"} + for _, id := range want { + if !fired[id] { + t.Errorf("expected warewashing pattern %s to fire for a dishwasher, but it did not", id) + } + } +} + +func TestWarewashing_PatternsDoNotLeakIntoOtherMachines(t *testing.T) { + // A machine with thermal + electrical + chemical capability but NOT a + // dishwasher must never produce warewashing hazards (dom_warewashing gate). + fired := firedSet([]string{"high_temperature", "electrical_part", "chemical_risk", "rotating_part", "moving_part"}) + for _, id := range []string{"HP2200", "HP2201", "HP2202", "HP2203", "HP2204", "HP2205", "HP2206"} { + if fired[id] { + t.Errorf("warewashing pattern %s leaked into a non-dishwasher machine", id) + } + } +} + +func TestWarewashing_WeldingAndGlueDoNotLeakIntoDishwasher(t *testing.T) { + // The gate-term additions must stop the welding/flame/glue burn patterns + // from firing for a dishwasher (they previously leaked via high_temperature + // / electrical_part). dom_welding/dom_flame/dom_glue are absent here. + fired := firedSet(warewashingTags) + leak := map[string]string{ + "HP530": "Lichtbogen-Verbrennung (Schweissen)", + "HP532": "Schweissrauch", + "HP533": "Brand durch Schweissfunken (Schweissen)", + } + for id, name := range leak { + if fired[id] { + t.Errorf("cross-domain pattern %s (%s) leaked into a dishwasher", id, name) + } + } +} + +func TestWarewashing_MeasureIDsExist(t *testing.T) { + lib := GetProtectiveMeasureLibrary() + have := make(map[string]bool, len(lib)) + for _, m := range lib { + have[m.ID] = true + } + for _, p := range GetWarewashingPatterns() { + for _, mid := range p.SuggestedMeasureIDs { + if !have[mid] { + t.Errorf("pattern %s references measure %s which is not in the library", p.ID, mid) + } + } + } +} + +func TestWarewashing_NarrativeEmitsTags(t *testing.T) { + // Closes the loop: a realistic dishwasher description must emit the tags + // the warewashing patterns gate on (otherwise the patterns are dead). + narrative := "Gewerbliche Untertisch-Geschirrspuelmaschine mit Heisswasser-Boiler " + + "und Nachspuelung ca. 85 Grad C, Spuelpumpe mit rotierenden Spuelfeldern, " + + "Dampf-/Wrasenabgabe beim Oeffnen, Reiniger und Klarspueler ueber Dosiergeraet, " + + "Tuer mit Sicherheitsschalter, Eingreifen in die Spuelkammer." + res := ParseNarrative(narrative, "Gewerbliche Geschirrspuelmaschine") + got := make(map[string]bool, len(res.CustomTags)) + for _, tag := range res.CustomTags { + got[tag] = true + } + for _, want := range []string{"dom_warewashing", "steam_emission", "hot_water", "corrosive_chemical", "access_door", "rotating_part"} { + if !got[want] { + t.Errorf("narrative did not emit expected tag %q (got %v)", want, res.CustomTags) + } + } + // And it must NOT emit any welding/flame/glue domain that would re-open leaks. + for _, bad := range []string{"dom_welding", "dom_flame", "dom_glue"} { + if got[bad] { + t.Errorf("dishwasher narrative unexpectedly emitted cross-domain tag %q", bad) + } + } +} + +func TestWarewashing_NewMeasuresPresent(t *testing.T) { + lib := GetProtectiveMeasureLibrary() + have := make(map[string]bool, len(lib)) + for _, m := range lib { + have[m.ID] = true + } + for _, mid := range []string{"M2200", "M2201", "M2202", "M2203", "M2204", "M2205", "M2206", "M2207", "M2208"} { + if !have[mid] { + t.Errorf("expected warewashing measure %s to be registered in the library", mid) + } + } +} diff --git a/ai-compliance-sdk/internal/iace/keyword_dictionary.go b/ai-compliance-sdk/internal/iace/keyword_dictionary.go index 4d0f9817..c48a6ccc 100644 --- a/ai-compliance-sdk/internal/iace/keyword_dictionary.go +++ b/ai-compliance-sdk/internal/iace/keyword_dictionary.go @@ -88,6 +88,21 @@ func GetKeywordDictionary() []KeywordEntry { {Keywords: []string{"folienwickler", "wickelmaschine", "konfektioniermaschine", "folienverpackung", "wellpappe"}, ExtraTags: []string{"dom_converting"}}, {Keywords: []string{"bergbau", "untertage", "tunnelbau", "off-grid"}, ExtraTags: []string{"dom_remote"}}, {Keywords: []string{"asbest", "asbestsanierung", "asbestexposition"}, ExtraTags: []string{"dom_asbestos"}}, + {Keywords: []string{"gasbrenner", "brennerbetrieb", "offene flamme", "flammhaert", "abflammen", "flammrichten"}, ExtraTags: []string{"dom_flame"}}, + {Keywords: []string{"heissleim", "heissleimanlage", "schmelzkleber", "schmelzklebstoff", "klebstoffschmelzer", "leimwerk"}, ExtraTags: []string{"dom_glue"}}, + + // ── Gewerbliche Spuelmaschine / Warewashing ────────────────────── + // dom_warewashing gates the warewashing-specific patterns + // (hazard_patterns_warewashing.go) so they never leak into other + // machine classes. The functional tags (hot_water, steam_emission, + // corrosive_chemical, access_door) are the within-domain triggers. + {Keywords: []string{"spuelmaschine", "geschirrspuelmaschine", "geschirrspueler", "haubenspuelmaschine", "untertischspuelmaschine", "korbspuelmaschine", "bandspuelmaschine", "glaeserspuelmaschine", "bistrospuelmaschine", "warewashing", "dishwasher"}, ExtraTags: []string{"dom_warewashing"}}, + {Keywords: []string{"heisswasser", "nachspuelung", "nachspueltemperatur", "spuelgang", "spuelzyklus", "thermostopp", "thermostop"}, ExtraTags: []string{"hot_water", "high_temperature"}}, + {Keywords: []string{"dampf", "wrasen", "schwaden", "brueden"}, ExtraTags: []string{"steam_emission", "high_temperature"}}, + {Keywords: []string{"boiler", "spuelboiler", "nachspuelboiler", "tankheiz", "boilerheiz"}, ComponentIDs: []string{"C094"}, ExtraTags: []string{"heating_element", "high_temperature"}}, + {Keywords: []string{"reiniger", "klarspueler", "spuelmittel", "reinigungsmittel", "reinigerkonzentrat", "spuelchemie", "dosiergeraet", "dosierpumpe", "sauglanze", "entkalker"}, ExtraTags: []string{"corrosive_chemical"}}, + {Keywords: []string{"spuelarm", "spuelfeld", "wascharm", "spruehfeld"}, ComponentIDs: []string{"C004"}, ExtraTags: []string{"rotating_part"}}, + {Keywords: []string{"spuelkammer", "spueltuer", "geraetetuer", "haubentuer", "klapptuer"}, ExtraTags: []string{"access_door"}}, // Ghost-Closure (Emit-Seite): macht die 34 toten Required-Tags // emittierbar, jeweils NUR via domaenenspezifische Keywords -> die 120 // Ghost-Patterns feuern wieder, aber nur fuer ihre echte Maschine (kein diff --git a/ai-compliance-sdk/internal/iace/measures_library.go b/ai-compliance-sdk/internal/iace/measures_library.go index 4de0919d..2090a4d6 100644 --- a/ai-compliance-sdk/internal/iace/measures_library.go +++ b/ai-compliance-sdk/internal/iace/measures_library.go @@ -22,6 +22,7 @@ func GetProtectiveMeasureLibrary() []ProtectiveMeasureEntry { all = append(all, getGTBremseMeasures()...) // GT-Bremse-Coverage-Gaps (M483-M522) all = append(all, GetCRAMeasures()...) // CRA / DIN EN 40000-1-2 cyber-resilience (M540-M548) all = append(all, getLiftEndstopMeasures()...) // Lift/hoist endstop (M600-M604) — bridges OSHA MD library + all = append(all, getWarewashingMeasures()...) // Commercial dishwasher (M2200-M2208) — scald/chemical/door/slip return all } diff --git a/ai-compliance-sdk/internal/iace/measures_library_warewashing.go b/ai-compliance-sdk/internal/iace/measures_library_warewashing.go new file mode 100644 index 00000000..b3ba6bc6 --- /dev/null +++ b/ai-compliance-sdk/internal/iace/measures_library_warewashing.go @@ -0,0 +1,69 @@ +package iace + +// getWarewashingMeasures returns protective measures for commercial warewashing +// machines (gewerbliche Geschirrspuelmaschinen): hot-water/steam scalding, +// hot surfaces, corrosive cleaning chemicals, door pinch and wet-floor slip. +// They complement the generic thermal/mechanical/material measures with the +// machine-specific controls a Fachmann expects for this product class. +// +// M-ID range: M2200-M2208. Norm identifiers only (facts) — no norm text is +// reproduced (DIN/Beuth license). Lead standard: EN 60335-2-58 (safety of +// commercial electric dishwashing machines). +func getWarewashingMeasures() []ProtectiveMeasureEntry { + return []ProtectiveMeasureEntry{ + {ID: "M2200", ReductionType: "design", SubType: "interlock", + Name: "Tuer-/Haubenverriegelung beendet Spuelgang vor dem Oeffnen", + Description: "Die Tuer bzw. Haube ist so mit der Steuerung verriegelt, dass beim Oeffnen Spuelpumpe und Nachspuelung sofort abschalten und ein Oeffnen erst nach Programmende (bzw. nach Abbau des Restdampfs) freigegeben wird. Verhindert den Schwall aus Heisswasser/Wrasen und den Kontakt mit noch rotierenden Spuelfeldern.", + HazardCategory: "thermal", + Examples: []string{"Tuerkontaktschalter schaltet Pumpe + Heizung beim Oeffnen ab", "Rastposition mit Restdampf-Verzoegerung vor Freigabe"}, + NormReferences: []string{"EN 60335-2-58", "EN ISO 12100 — Inhaerent sichere Konstruktion"}}, + {ID: "M2201", ReductionType: "design", SubType: "thermal", + Name: "Wrasen-/Dampfreduzierung (Kondensations- / Waermerueckgewinnungssystem)", + Description: "Der beim Oeffnen austretende Wrasen wird durch ein Kondensations- bzw. Waermerueckgewinnungssystem reduziert, sodass beim Entnehmen kein gefaehrlicher Dampfschwall entsteht. Senkt zugleich die Restwaerme- und Feuchtebelastung am Arbeitsplatz.", + HazardCategory: "thermal", + Examples: []string{"Umluft-Waermerueckgewinnung reduziert austretenden Wrasen", "Kondensationshaube ueber der Spuelkammer"}, + NormReferences: []string{"EN 60335-2-58"}}, + {ID: "M2202", ReductionType: "protection", SubType: "monitoring", + Name: "Thermostop / Temperaturueberwachung von Boiler und Tank", + Description: "Boiler- und Tanktemperatur werden ueberwacht; ein Thermostop gibt den naechsten Schritt erst frei, wenn die Solltemperatur erreicht ist, und begrenzt die maximale Nachspueltemperatur. Schuetzt vor Verbruehung durch unkontrolliert heisses Nachspuelwasser.", + HazardCategory: "thermal", + Examples: []string{"Temperatursensor in Boiler und Tank mit Abschaltgrenze", "Thermostop-Funktion im Spuelprogramm"}, + NormReferences: []string{"EN 60335-2-58", "EN ISO 13732-1"}}, + {ID: "M2203", ReductionType: "design", SubType: "containment", + Name: "Geschlossenes Dosiersystem mit Sauglanzen und Niveauueberwachung", + Description: "Reiniger und Klarspueler werden ausschliesslich ueber ein geschlossenes Dosiersystem mit Sauglanzen aus dem Originalgebinde gefoerdert (Niveau-Ueberwachung statt Umfuellen). Direkter Haut-/Augenkontakt mit dem aetzenden Konzentrat beim Nachfuellen wird konstruktiv vermieden.", + HazardCategory: "material_environmental", + Examples: []string{"Sauglanze mit Leermeldung im Reiniger-Kanister", "Kein Umfuellen — Gebindewechsel ohne offenen Chemiekontakt"}, + NormReferences: []string{"EN 60335-2-58", "Verordnung (EG) Nr. 1272/2008 (CLP/GHS)"}}, + {ID: "M2204", ReductionType: "information", SubType: "ppe", + Name: "PSA (Augen-/Hautschutz) + GHS-Kennzeichnung und Sicherheitsdatenblatt", + Description: "Fuer Handhabung, Gebindewechsel und Entkalkung werden Augen- und Handschutz vorgeschrieben; Reiniger/Klarspueler/Entkalker sind GHS-gekennzeichnet und das Sicherheitsdatenblatt liegt am Geraet vor. Stellt die sichere Handhabung der aetzenden Konzentrate sicher.", + HazardCategory: "material_environmental", + Examples: []string{"Schutzbrille + chemikalienbestaendige Handschuhe bei Gebindewechsel", "GHS-Etikett und SDB im Chemikalienschrank am Geraet"}, + NormReferences: []string{"Verordnung (EG) Nr. 1272/2008 (CLP/GHS)", "TRGS 500"}}, + {ID: "M2205", ReductionType: "protection", SubType: "ventilation", + Name: "Be-/Entlueftung bzw. geschlossene Haube gegen Chemie-Aerosole und Wrasen", + Description: "Der Aufstellbereich ist ausreichend be- und entlueftet bzw. die Spuelkammer bleibt waehrend des Programms geschlossen, sodass Reinigungs-Aerosole und heisser Wrasen nicht in die Atemzone des Bedieners gelangen.", + HazardCategory: "material_environmental", + Examples: []string{"Kuechenlueftung ueber dem Spuelbereich", "Programmstart nur bei geschlossener Haube"}, + NormReferences: []string{"EN 60335-2-58", "TRGS 500"}}, + {ID: "M2206", ReductionType: "design", SubType: "geometry", + Name: "Tuerkanten mit geringer Schliesskraft / Einklemmschutz", + Description: "Die Tuer-/Haubenmechanik ist so gestaltet (gefuehrte Bewegung, begrenzte Schliesskraft, abgerundete Kanten), dass beim Schliessen keine Finger gequetscht werden.", + HazardCategory: "mechanical", + Examples: []string{"Gefuehrte Haube mit gedaempfter Schliessbewegung", "Abgerundete Tuerkanten ohne Quetschspalt"}, + NormReferences: []string{"EN 60335-2-58", "EN ISO 12100 — Geometrie und Anordnung"}}, + {ID: "M2207", ReductionType: "design", SubType: "environment", + Name: "Rutschhemmender Bodenbelag + Ablauf/Leckagewanne im Aufstellbereich", + Description: "Im Aufstell- und Bedienbereich der Spuelmaschine sorgen rutschhemmender Bodenbelag und ein definierter Ablauf bzw. eine Leckagewanne dafuer, dass austretendes Wasser nicht zur Sturzgefahr wird.", + HazardCategory: "mechanical", + Examples: []string{"Rutschhemmender Industrieboden (Bewertungsgruppe R11/R12)", "Bodenablauf bzw. Leckagewanne unter dem Geraet"}, + NormReferences: []string{"ASR A1.5/1,2", "DGUV Regel 108-003"}}, + {ID: "M2208", ReductionType: "information", SubType: "signage", + Name: "Warnhinweis heisser Dampf/Heisswasser — Tuer erst nach Programmende oeffnen", + Description: "Am Geraet und in der Betriebsanleitung wird vor heissem Dampf und Heisswasser gewarnt und das Oeffnen der Tuer erst nach Programmende mit vorsichtigem Anheben vorgeschrieben. Sprachneutrale Piktogramme ergaenzen den Hinweis.", + HazardCategory: "general", + Examples: []string{"Warnpiktogramm 'Heisser Dampf' an der Tuer", "BA-Hinweis 'Tuer nach Programmende langsam oeffnen'"}, + NormReferences: []string{"ISO 7010", "EN 60335-2-58"}}, + } +} diff --git a/ai-compliance-sdk/internal/iace/pattern_domain_gates.go b/ai-compliance-sdk/internal/iace/pattern_domain_gates.go index cb41af55..5ba2b582 100644 --- a/ai-compliance-sdk/internal/iace/pattern_domain_gates.go +++ b/ai-compliance-sdk/internal/iace/pattern_domain_gates.go @@ -46,6 +46,20 @@ var domainGateTerms = map[string]string{ "widerstandsschweiss": "dom_welding", "lichtbogenschweiss": "dom_welding", "schutzgasschweiss": "dom_welding", "punktschweiss": "dom_welding", "schweisselektrod": "dom_welding", "elektrodenspalt": "dom_welding", + // Schweissen — Oberflaechenformen die bisher ungegatet leakten (z.B. in + // thermische Hazards einer Spuelmaschine ueber high_temperature/electrical_part) + "schweissarbeitsplatz": "dom_welding", "schweissfunke": "dom_welding", + "schweisshelm": "dom_welding", "schweisserschutz": "dom_welding", + "lichtbogenzone": "dom_welding", "lichtbogen-verbrennung": "dom_welding", + "schweissrauch": "dom_welding", "schweissgeraet": "dom_welding", + "schweisszone": "dom_welding", "schweissbrenner": "dom_welding", + "schweissspritzer": "dom_welding", "schweissstrom": "dom_welding", + // Offene Flamme / Brenner (Gasbrenner, Flammhaerten, Abflammen) + "offene flamme": "dom_flame", "brennerbereich": "dom_flame", + "flammenzone": "dom_flame", "gasbrenner": "dom_flame", + // Heissleim / Schmelzkleber + "heissleimanlage": "dom_glue", "klebstoffschmelzer": "dom_glue", + "heisskleber": "dom_glue", "schmelzkleber": "dom_glue", // Solar / PV "pv-modul": "dom_solar", "photovoltaik": "dom_solar", "pv-anlage": "dom_solar", "dc-steckverbindung": "dom_solar", "solarmodul": "dom_solar", diff --git a/ai-compliance-sdk/internal/iace/pattern_registry.go b/ai-compliance-sdk/internal/iace/pattern_registry.go index bb7a8cc5..565015d0 100644 --- a/ai-compliance-sdk/internal/iace/pattern_registry.go +++ b/ai-compliance-sdk/internal/iace/pattern_registry.go @@ -44,6 +44,7 @@ func collectAllPatterns() []HazardPattern { patterns = append(patterns, GetCRAPatterns()...) // HP1910-HP1918 CRA / DIN EN 40000-1-2 cyber-resilience spur patterns = append(patterns, GetSecondaryHarmDemoPatterns()...) // HP2000-HP2001 secondary harm chain demos (Cola splitter, Pharma) patterns = append(patterns, GetLiftEndstopPatterns()...) // HP2100-HP2102 lift body-part crush at endstops + patterns = append(patterns, GetWarewashingPatterns()...) // HP2200-HP2206 commercial dishwasher (scald/chemical/door/slip) patterns = applyMachineTypeOverrides(patterns) // Fill MachineTypes on legacy patterns to prevent drift patterns = applyDomainGates(patterns) // Capability-domain gate: stop domain-specific patterns leaking cross-machine return patterns From 79ad95e2441b6903a1dd5d67f1e5dfc0c690f23a Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Wed, 24 Jun 2026 14:01:28 +0200 Subject: [PATCH 09/16] feat(ai-sdk): keep cyber/AI hazards out of the traditional CE hazard log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit InitializeProject created hazards for every matched pattern, so native cybersecurity/AI topics (unauthorized access, firmware manipulation, missing SBOM, ...) mixed into the ISO 12100 hazard log. Route the security categories (frontend groups I. Cyber/Netzwerk + J. KI) to the CRA module instead — generically for EVERY project, enforced centrally in InitializeProject. The split is by the nature of the hazard, not the component: functional-safety control faults stay in CE (software faults, lost safety functions, config errors, bus failures, botched updates) — they are random/systematic faults, not attacks, and feed the CRA safety-function bridge. This holds whether the controller is a bought-in CE-marked PLC or the manufacturer's own control. Co-Authored-By: Claude Opus 4.7 --- .../api/handlers/iace_handler_init.go | 7 +++ .../api/handlers/iace_handler_init_cyber.go | 45 +++++++++++++++++++ .../handlers/iace_handler_init_cyber_test.go | 37 +++++++++++++++ 3 files changed, 89 insertions(+) create mode 100644 ai-compliance-sdk/internal/api/handlers/iace_handler_init_cyber.go create mode 100644 ai-compliance-sdk/internal/api/handlers/iace_handler_init_cyber_test.go diff --git a/ai-compliance-sdk/internal/api/handlers/iace_handler_init.go b/ai-compliance-sdk/internal/api/handlers/iace_handler_init.go index 0ecf2188..c3c0a9ec 100644 --- a/ai-compliance-sdk/internal/api/handlers/iace_handler_init.go +++ b/ai-compliance-sdk/internal/api/handlers/iace_handler_init.go @@ -211,6 +211,13 @@ func (h *IACEHandler) InitializeProject(c *gin.Context) { } for _, cat := range mp.HazardCats { + // Native cyber/AI categories (frontend groups I+J) belong to the + // CRA module, not the traditional CE (ISO 12100) hazard log. + // Enforced centrally here so it holds for EVERY project. + if isCyberSecurityCategory(cat) { + fmt.Printf("CYBER-SKIP: cat=%s pattern=%s — routed to CRA module\n", cat, mp.PatternID) + continue + } maxForCat := categoryHazardCap(cat, len(comps)) if catCount[cat] >= maxForCat { continue diff --git a/ai-compliance-sdk/internal/api/handlers/iace_handler_init_cyber.go b/ai-compliance-sdk/internal/api/handlers/iace_handler_init_cyber.go new file mode 100644 index 00000000..116cdabd --- /dev/null +++ b/ai-compliance-sdk/internal/api/handlers/iace_handler_init_cyber.go @@ -0,0 +1,45 @@ +package handlers + +// Safety/Security separation for the IACE hazard log. +// +// The traditional CE risk assessment (Maschinenrichtlinie / EN ISO 12100) and +// the cybersecurity assessment (Cyber Resilience Act) are two distinct steps. +// IACE owns the traditional, physical + functional-safety hazards; the CRA +// module (/sdk/iace/{id}/cra) owns the native cyber/AI topics and re-examines +// which safety functions a cyber attack can re-open (see iace-safety-bridge). +// +// The split is by the NATURE of the hazard, not by the component: a control +// fault, bus failure or botched update is FUNCTIONAL safety (random/systematic +// fault) and stays in CE — independent of whether the controller is a bought-in +// CE-marked PLC or the manufacturer's own embedded control. Only the security +// PROPERTIES against malicious actors (access control, firmware/update +// integrity, SBOM, vulnerability handling, default passwords) are CRA. +// +// Functional-safety control categories (software_control, software_fault, +// safety_function_failure, configuration_error, communication_failure, +// update_failure, sensor_fault, …) therefore intentionally STAY in IACE — they +// are the safety functions whose loss the CRA bridge re-examines. +// +// Enforced centrally in InitializeProject so it holds for EVERY project. +var nativeCyberSecurityCategories = map[string]bool{ + // I. Cyber / Netzwerk — security against malicious actors + "unauthorized_access": true, + "firmware_corruption": true, + "cyber_resilience": true, + "logging_audit_failure": true, + "cyber_network": true, + "sensor_spoofing": true, + // J. KI-spezifisch + "ai_specific": true, + "ai_misclassification": true, + "false_classification": true, + "model_drift": true, + "data_poisoning": true, + "unintended_bias": true, +} + +// isCyberSecurityCategory reports whether a hazard category is a native cyber/AI +// topic that belongs to the CRA module rather than the traditional CE hazard log. +func isCyberSecurityCategory(category string) bool { + return nativeCyberSecurityCategories[category] +} diff --git a/ai-compliance-sdk/internal/api/handlers/iace_handler_init_cyber_test.go b/ai-compliance-sdk/internal/api/handlers/iace_handler_init_cyber_test.go new file mode 100644 index 00000000..579496b0 --- /dev/null +++ b/ai-compliance-sdk/internal/api/handlers/iace_handler_init_cyber_test.go @@ -0,0 +1,37 @@ +package handlers + +import "testing" + +func TestIsCyberSecurityCategory_RoutedToCRA(t *testing.T) { + cyber := []string{ + "unauthorized_access", "firmware_corruption", "cyber_resilience", + "logging_audit_failure", "cyber_network", "sensor_spoofing", + "ai_specific", "ai_misclassification", "false_classification", + "model_drift", "data_poisoning", "unintended_bias", + } + for _, c := range cyber { + if !isCyberSecurityCategory(c) { + t.Errorf("category %q must be routed to the CRA module, not the traditional IACE log", c) + } + } +} + +func TestIsCyberSecurityCategory_StaysInIACE(t *testing.T) { + // Physical + functional-safety categories must remain in the traditional CE + // hazard log. communication_failure (bus failure -> loss of control) and + // update_failure (botched update -> lost safety function) are FUNCTIONAL + // faults, not attacks, so they stay too. + keep := []string{ + "mechanical_hazard", "electrical_hazard", "thermal_hazard", + "pneumatic_hydraulic", "noise_vibration", "ergonomic_hazard", + "material_environmental", "chemical_risk", "fire_explosion", + "software_control", "software_fault", "safety_function_failure", + "configuration_error", "sensor_fault", "hmi_error", + "communication_failure", "update_failure", + } + for _, c := range keep { + if isCyberSecurityCategory(c) { + t.Errorf("category %q must stay in the traditional IACE log, not be routed to CRA", c) + } + } +} From cf86dc241b3cd3bd4083966f683e2701a862823e Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Wed, 24 Jun 2026 21:51:26 +0200 Subject: [PATCH 10/16] test(ai-sdk): GT #3 (commercial dishwasher) + fix Drehtisch keyword mislabel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add ground_truth_warewashing.json + TestWarewashing_GTCoverage. The test runs the UC-M narrative through the SAME chain as production (ParseNarrative -> engine -> relevance + cyber filter), so keyword/gating fixes are measured on the real hazard set, and false positives show up as "extra". Class A (generic keyword hygiene): spuelarm/spuelfeld no longer map to library component C004 ("Drehtisch" / rotary table) — that mislabelled the spray arm. Keep the rotating_part tag. Removes the bogus "Drehtisch" hazard. GT #3 baseline -> after Class A: recall 80% (unchanged), one false positive (Drehtisch) removed. Kistenhub 97.1% and Bremse pinned mappings unchanged. Co-Authored-By: Claude Opus 4.7 --- .../internal/iace/gt_warewashing_test.go | 146 +++++++++++ .../internal/iace/keyword_dictionary.go | 6 +- .../testdata/ground_truth_warewashing.json | 233 ++++++++++++++++++ 3 files changed, 384 insertions(+), 1 deletion(-) create mode 100644 ai-compliance-sdk/internal/iace/gt_warewashing_test.go create mode 100644 ai-compliance-sdk/internal/iace/testdata/ground_truth_warewashing.json diff --git a/ai-compliance-sdk/internal/iace/gt_warewashing_test.go b/ai-compliance-sdk/internal/iace/gt_warewashing_test.go new file mode 100644 index 00000000..456619c7 --- /dev/null +++ b/ai-compliance-sdk/internal/iace/gt_warewashing_test.go @@ -0,0 +1,146 @@ +package iace + +import ( + "encoding/json" + "os" + "path/filepath" + "sort" + "testing" +) + +// GT #3 — commercial UNDERCOUNTER dishwasher (Winterhalter UC-M). Self-assessed +// ground truth: we can judge what a dishwasher is. The test runs the narrative +// through the SAME chain as production (ParseNarrative -> engine -> relevance +// filter + cyber-skip), so keyword/gating fixes are measured on the hazard set +// the user actually sees — not the raw pattern flood. + +// Condensed UC-M limits_form narrative. Deliberately includes "Cool-Ausfuehrung" +// and "Filter" so the known false components (Kuehlaggregat, Absauganlage) are +// reproduced and visible in the baseline. +const warewashingNarrative = `Gewerbliche Untertisch-Geschirrspuelmaschine fuer Gastronomie-Kueche, ` + + `vernetzt ueber LAN und WLAN (Connected Wash Internetportal). Heisswasser-Boiler mit ` + + `Nachspueltemperatur ca. 85 Grad C, Tank mit Hygiene-Tankheizkoerper. Spuelpumpe 150-200 l/min ` + + `mit rotierenden Spuelfeldern und Spuelarmen, Ablaufpumpe. Eingebautes Dosiergeraet fuer Reiniger ` + + `und Klarspueler (aetzende Konzentrate). 4-fach-Laugenfiltration mit Filter. Doppelwandige Tuer ` + + `mit Sicherheitsschalter und Rastposition (Thermostopp). Elektromotor (Drehstrom) 400 V. ` + + `Touch-Steuerung (SPS) mit Bedienfeld und HMI, USB-Schnittstelle fuer Softwareupdates, ` + + `PIN-geschuetzter Servicetechniker-Fernzugriff. Cool-Ausfuehrung mit kalter Nachspuelung. ` + + `Untertischmontage. Eingreifen in die Spuelkammer moeglich. Aerosole und Daempfe der ` + + `Reinigungschemie gelangen in die Atemzone. Manuelles Be- und Entladen der Spuelkoerbe von Hand. ` + + `Reinigung und Wartung durch Servicetechniker. Branche Lebensmittel und Getraenke.` + +// warewashingCyberCategories mirrors handlers.nativeCyberSecurityCategories — +// native cyber/AI hazards are routed to the CRA module, not the CE hazard log. +var warewashingCyberCategories = map[string]bool{ + "unauthorized_access": true, "firmware_corruption": true, "cyber_resilience": true, + "logging_audit_failure": true, "cyber_network": true, "sensor_spoofing": true, + "ai_specific": true, "ai_misclassification": true, "false_classification": true, + "model_drift": true, "data_poisoning": true, "unintended_bias": true, +} + +// warewashingEngineOutput runs the production chain and returns the filtered +// hazards/mitigations the user would see for the UC-M. +func warewashingEngineOutput() ([]Hazard, []Mitigation, int) { + res := ParseNarrative(warewashingNarrative, "Gewerbliche Untertisch-Geschirrspuelmaschine (vernetzt)") + + var compIDs, compNames []string + for _, c := range res.Components { + if c.Negated { + continue + } + compIDs = append(compIDs, c.LibraryID) + compNames = append(compNames, c.NameDE) + } + var energyIDs []string + for _, e := range res.EnergySources { + energyIDs = append(energyIDs, e.SourceID) + } + lifecycles := append([]string{}, res.LifecyclePhases...) + lifecycles = append(lifecycles, "normal_operation", "maintenance", "cleaning", "setup", "fault_clearing") + + input := MatchInput{ + ComponentLibraryIDs: compIDs, + EnergySourceIDs: energyIDs, + LifecyclePhases: lifecycles, + CustomTags: res.CustomTags, + OperationalStates: append(res.OperationalStates, "normal_operation", "cleaning", "maintenance"), + HumanRoles: res.Roles, + MachineTypes: []string{"food_processing", "Gewerbliche Untertisch-Geschirrspuelmaschine (vernetzt)"}, + } + + out := NewPatternEngine().Match(input) + + var kept []PatternMatch + for _, pm := range out.MatchedPatterns { + if !IsPatternRelevant(pm, warewashingNarrative, compNames) { + continue + } + allCyber := len(pm.HazardCats) > 0 + for _, c := range pm.HazardCats { + if !warewashingCyberCategories[c] { + allCyber = false + } + } + if allCyber { + continue + } + kept = append(kept, pm) + } + filtered := *out + filtered.MatchedPatterns = kept + hazards, mitigations := patternsToHazardsAndMitigations(&filtered) + return hazards, mitigations, len(kept) +} + +func TestWarewashing_GTCoverage(t *testing.T) { + gtPath := filepath.Join("testdata", "ground_truth_warewashing.json") + raw, err := os.ReadFile(gtPath) + if err != nil { + t.Fatalf("read GT: %v", err) + } + var gt GroundTruth + if err := json.Unmarshal(raw, >); err != nil { + t.Fatalf("parse GT: %v", err) + } + + hazards, mitigations, nPatterns := warewashingEngineOutput() + t.Logf("Engine: %d patterns kept (relevance+cyber filter) -> %d hazards", nPatterns, len(hazards)) + + result := CompareBenchmark(>, hazards, mitigations) + precision := 0.0 + if result.TotalEngine > 0 { + precision = float64(len(result.MatchedPairs)) / float64(result.TotalEngine) + } + t.Logf("=== Warewashing-GT (GT #3) Baseline ===") + t.Logf("Recall (Coverage): %.1f%% (%d/%d matched, %d missing)", + result.CoverageScore*100, len(result.MatchedPairs), result.TotalGT, len(result.MissingFromEngine)) + t.Logf("Precision: %.1f%% (%d engine hazards, %d extra)", + precision*100, result.TotalEngine, len(result.ExtraInEngine)) + + if len(result.MissingFromEngine) > 0 { + t.Logf("--- MISSING (recall gaps) ---") + for _, m := range result.MissingFromEngine { + t.Logf(" MISS %s: %s", m.Nr, abbrev(m.HazardType, 60)) + } + } + if len(result.ExtraInEngine) > 0 { + t.Logf("--- EXTRA (false positives / precision loss) ---") + names := make([]string, 0, len(result.ExtraInEngine)) + for _, e := range result.ExtraInEngine { + n := e.Name + if n == "" { + n = e.Scenario + } + names = append(names, "["+e.Category+"] "+n) + } + sort.Strings(names) + for _, n := range names { + t.Logf(" EXTRA %s", abbrev(n, 85)) + } + } + + // Loose smoke floor for the baseline — fixes should push recall up, not down. + if result.CoverageScore < 0.4 { + t.Errorf("warewashing recall below 40%% floor: %.1f%%", result.CoverageScore*100) + } +} diff --git a/ai-compliance-sdk/internal/iace/keyword_dictionary.go b/ai-compliance-sdk/internal/iace/keyword_dictionary.go index c48a6ccc..5cf36550 100644 --- a/ai-compliance-sdk/internal/iace/keyword_dictionary.go +++ b/ai-compliance-sdk/internal/iace/keyword_dictionary.go @@ -101,7 +101,11 @@ func GetKeywordDictionary() []KeywordEntry { {Keywords: []string{"dampf", "wrasen", "schwaden", "brueden"}, ExtraTags: []string{"steam_emission", "high_temperature"}}, {Keywords: []string{"boiler", "spuelboiler", "nachspuelboiler", "tankheiz", "boilerheiz"}, ComponentIDs: []string{"C094"}, ExtraTags: []string{"heating_element", "high_temperature"}}, {Keywords: []string{"reiniger", "klarspueler", "spuelmittel", "reinigungsmittel", "reinigerkonzentrat", "spuelchemie", "dosiergeraet", "dosierpumpe", "sauglanze", "entkalker"}, ExtraTags: []string{"corrosive_chemical"}}, - {Keywords: []string{"spuelarm", "spuelfeld", "wascharm", "spruehfeld"}, ComponentIDs: []string{"C004"}, ExtraTags: []string{"rotating_part"}}, + // Spuelarm/Spuelfeld emit only the rotating_part capability tag. They are + // NOT mapped to a library component — C004 is a "Drehtisch" (rotary table) + // and that mislabels the spray arm. Keyword->component must be semantically + // honest (generic hygiene; surfaced by the warewashing GT). + {Keywords: []string{"spuelarm", "spuelfeld", "wascharm", "spruehfeld"}, ExtraTags: []string{"rotating_part"}}, {Keywords: []string{"spuelkammer", "spueltuer", "geraetetuer", "haubentuer", "klapptuer"}, ExtraTags: []string{"access_door"}}, // Ghost-Closure (Emit-Seite): macht die 34 toten Required-Tags // emittierbar, jeweils NUR via domaenenspezifische Keywords -> die 120 diff --git a/ai-compliance-sdk/internal/iace/testdata/ground_truth_warewashing.json b/ai-compliance-sdk/internal/iace/testdata/ground_truth_warewashing.json new file mode 100644 index 00000000..725972a0 --- /dev/null +++ b/ai-compliance-sdk/internal/iace/testdata/ground_truth_warewashing.json @@ -0,0 +1,233 @@ +{ + "machine_name": "Gewerbliche Untertisch-Geschirrspuelmaschine (Winterhalter UC-M)", + "machine_description": "Untertisch-Gewerbespuelmaschine, vernetzt (Connected Wash), Heisswasser-Boiler, Spuelpumpe mit rotierenden Spuelfeldern, Tuer mit Sicherheitsschalter, Reiniger-/Klarspueler-Dosierung.", + "source": "Selbstbewertung GT #3 (Fachmann-Erwartung, EN 60335-2-58 + EN ISO 12100)", + "version": "1.0", + "entries": [ + { + "nr": "1.1", + "hazard_group": "Thermische Gefährdungen", + "hazard_group_applicable": true, + "hazard_type": "Verbrühung durch Heißwasser und Dampf", + "hazard_cause": "Beim Öffnen der Tür während oder kurz nach dem Spülgang tritt heißes Wasser und Wrasen (Dampf) aus der Spülkammer aus und trifft Gesicht, Hände und Arme", + "lifecycle_phases": ["Betrieb", "Reinigung"], + "component_zone": "Tür und Beschickungsöffnung der Spülkammer", + "risk_in": {"f": 4, "w": 3, "p": 2, "s": 3, "r": 27}, + "measures": ["Türverriegelung beendet Spülgang vor dem Öffnen", "Wrasen-/Dampfreduzierung", "Warnhinweis heißer Dampf"], + "measure_type": "KM", + "risk_out": {"f": 2, "w": 1, "p": 1, "s": 2, "r": 8}, + "norm_references": ["EN 60335-2-58"], + "sufficient": true + }, + { + "nr": "1.2", + "hazard_group": "Thermische Gefährdungen", + "hazard_group_applicable": true, + "hazard_type": "Verbrennung an heißen Oberflächen", + "hazard_cause": "Berührung heißer Oberflächen von Boiler, Tankheizkörper oder Spülkammerwänden bei Reinigung, Entkalkung oder Wartung", + "lifecycle_phases": ["Reinigung", "Instandhaltung"], + "component_zone": "Boiler, Tankheizkörper, Spülkammerwände", + "risk_in": {"f": 3, "w": 2, "p": 2, "s": 2, "r": 14}, + "measures": ["Temperaturbegrenzung zugänglicher Oberflächen", "Warnhinweis heiße Oberfläche"], + "measure_type": "KM", + "risk_out": {"f": 1, "w": 1, "p": 1, "s": 2, "r": 6}, + "norm_references": ["EN ISO 13732-1"], + "sufficient": true + }, + { + "nr": "1.3", + "hazard_group": "Thermische Gefährdungen", + "hazard_group_applicable": true, + "hazard_type": "Verbrennung an heißem Spülgut", + "hazard_cause": "Geschirr und Gläser sind nach der Heißwasser-Nachspülung sehr heiß, beim Entladen kommt es zu Verbrennungen an den Händen", + "lifecycle_phases": ["Betrieb"], + "component_zone": "Spülkammer, Entnahmebereich, Korb", + "risk_in": {"f": 3, "w": 3, "p": 2, "s": 2, "r": 16}, + "measures": ["Abkühl-/Trocknungszeit", "Warnhinweis heißes Spülgut"], + "measure_type": "BI", + "risk_out": {"f": 1, "w": 1, "p": 1, "s": 2, "r": 6}, + "norm_references": ["EN 60335-2-58"], + "sufficient": true + }, + { + "nr": "2.1", + "hazard_group": "Gefährdungen durch Materialien und Substanzen", + "hazard_group_applicable": true, + "hazard_type": "Verätzung von Haut und Augen durch Reiniger-/Klarspüler-Konzentrat", + "hazard_cause": "Direkter Kontakt mit dem ätzenden Reiniger- bzw. Klarspüler-Konzentrat beim Nachfüllen, Sauglanzenwechsel oder bei Leckage des Dosiergeräts", + "lifecycle_phases": ["Betrieb", "Instandhaltung"], + "component_zone": "Dosiergerät, Reiniger- und Klarspüler-Gebinde, Sauglanzen", + "risk_in": {"f": 3, "w": 3, "p": 2, "s": 3, "r": 24}, + "measures": ["Geschlossenes Dosiersystem mit Sauglanzen", "PSA Augen-/Hautschutz", "GHS-Kennzeichnung und Sicherheitsdatenblatt"], + "measure_type": "KM", + "risk_out": {"f": 1, "w": 1, "p": 1, "s": 3, "r": 9}, + "norm_references": ["Verordnung (EG) Nr. 1272/2008", "TRGS 500"], + "sufficient": true + }, + { + "nr": "2.2", + "hazard_group": "Gefährdungen durch Materialien und Substanzen", + "hazard_group_applicable": true, + "hazard_type": "Reizung der Atemwege durch Reinigungs-Aerosole und Dämpfe", + "hazard_cause": "Einatmen von Aerosolen und Dämpfen der Reinigungschemie beim Öffnen kurz nach dem Spülgang oder bei der Entkalkung mit Säure", + "lifecycle_phases": ["Betrieb", "Instandhaltung"], + "component_zone": "Atemzone vor der Spülkammer, Aufstellbereich", + "risk_in": {"f": 2, "w": 2, "p": 2, "s": 2, "r": 12}, + "measures": ["Be-/Entlüftung", "geschlossene Haube", "Warnung vor Vermischen von Reiniger und Säure"], + "measure_type": "BI", + "risk_out": {"f": 1, "w": 1, "p": 1, "s": 2, "r": 6}, + "norm_references": ["TRGS 500"], + "sufficient": true + }, + { + "nr": "3.1", + "hazard_group": "Elektrische Gefährdungen", + "hazard_group_applicable": true, + "hazard_type": "Elektrischer Schlag in Nassumgebung", + "hazard_cause": "Berührung spannungsführender Teile bei unzureichendem IP-Schutz, defekten Kabeldurchführungen oder Feuchtigkeit im Steuerungsgehäuse", + "lifecycle_phases": ["Betrieb", "Reinigung", "Instandhaltung"], + "component_zone": "Steuerungsgehäuse, Kabelübergänge, Antriebsgehäuse", + "risk_in": {"f": 3, "w": 2, "p": 3, "s": 4, "r": 32}, + "measures": ["IP-Schutz gegen eindringendes Wasser", "Fehlerstrom-Schutzeinrichtung (RCD)"], + "measure_type": "KM", + "risk_out": {"f": 1, "w": 1, "p": 1, "s": 4, "r": 12}, + "norm_references": ["IEC 60335-1"], + "sufficient": true + }, + { + "nr": "3.2", + "hazard_group": "Elektrische Gefährdungen", + "hazard_group_applicable": true, + "hazard_type": "Kurzschluss und Brand bei Reinigung am Schaltschrank", + "hazard_cause": "Reinigung ohne vorherige Freischaltung oder mit Hochdruckreiniger am elektrisch aktiven Schaltschrank führt zu Kurzschluss und Brand", + "lifecycle_phases": ["Reinigung", "Instandhaltung"], + "component_zone": "Schaltschrank, elektrisch aktive Komponenten", + "risk_in": {"f": 2, "w": 2, "p": 2, "s": 3, "r": 18}, + "measures": ["Netztrenneinrichtung", "Warnhinweis Reinigung nur spannungsfrei, kein Hochdruckreiniger"], + "measure_type": "KM", + "risk_out": {"f": 1, "w": 1, "p": 1, "s": 3, "r": 9}, + "norm_references": ["IEC 60204-1"], + "sufficient": true + }, + { + "nr": "3.3", + "hazard_group": "Elektrische Gefährdungen", + "hazard_group_applicable": true, + "hazard_type": "Motorüberlast mit Überhitzung", + "hazard_cause": "Blockierter oder überlasteter Pumpenmotor überhitzt, Wicklungsbrand und Rauchentwicklung", + "lifecycle_phases": ["Betrieb"], + "component_zone": "Motorgehäuse, Umgebung", + "risk_in": {"f": 2, "w": 2, "p": 2, "s": 2, "r": 12}, + "measures": ["Überstromschutz", "Motorschutzschalter"], + "measure_type": "KM", + "risk_out": {"f": 1, "w": 1, "p": 1, "s": 2, "r": 6}, + "norm_references": ["IEC 60204-1"], + "sufficient": true + }, + { + "nr": "4.1", + "hazard_group": "Mechanische Gefährdungen", + "hazard_group_applicable": true, + "hazard_type": "Ausrutschen auf nassem Boden", + "hazard_cause": "Aus der Spülmaschine austretendes Wasser durch Leckage oder beim Öffnen macht den Boden im Aufstellbereich rutschig, Person rutscht aus und stürzt", + "lifecycle_phases": ["Betrieb", "Reinigung", "Instandhaltung"], + "component_zone": "Aufstell- und Bedienbereich der Spülmaschine", + "risk_in": {"f": 3, "w": 3, "p": 2, "s": 2, "r": 16}, + "measures": ["Rutschhemmender Bodenbelag", "Bodenablauf bzw. Leckagewanne"], + "measure_type": "KM", + "risk_out": {"f": 1, "w": 1, "p": 1, "s": 2, "r": 6}, + "norm_references": ["ASR A1.5/1,2"], + "sufficient": true + }, + { + "nr": "4.2", + "hazard_group": "Mechanische Gefährdungen", + "hazard_group_applicable": true, + "hazard_type": "Quetschen der Finger an der Tür/Haube", + "hazard_cause": "Beim Schließen der Tür bzw. Absenken der Haube werden Finger zwischen Tür/Haube und Gehäuse gequetscht", + "lifecycle_phases": ["Betrieb"], + "component_zone": "Tür- und Haubenkante, Schließbereich", + "risk_in": {"f": 3, "w": 2, "p": 2, "s": 1, "r": 7}, + "measures": ["Geringe Schließkraft, Einklemmschutz", "Abgerundete Türkanten"], + "measure_type": "KM", + "risk_out": {"f": 1, "w": 1, "p": 1, "s": 1, "r": 3}, + "norm_references": ["EN ISO 12100"], + "sufficient": true + }, + { + "nr": "4.3", + "hazard_group": "Mechanische Gefährdungen", + "hazard_group_applicable": true, + "hazard_type": "Kontakt mit rotierendem Spülarm bei geöffneter Tür", + "hazard_cause": "Eingreifen in die Spülkammer bei noch nachlaufendem rotierendem Spülarm/Spülfeld nach dem Öffnen der Tür", + "lifecycle_phases": ["Betrieb", "Reinigung"], + "component_zone": "Spülkammer, Spülarm und Spülfeld", + "risk_in": {"f": 2, "w": 2, "p": 2, "s": 1, "r": 6}, + "measures": ["Türverriegelung stoppt Spülarm beim Öffnen"], + "measure_type": "KM", + "risk_out": {"f": 1, "w": 1, "p": 1, "s": 1, "r": 3}, + "norm_references": ["EN ISO 12100"], + "sufficient": true + }, + { + "nr": "5.1", + "hazard_group": "Ergonomische Gefährdungen", + "hazard_group_applicable": true, + "hazard_type": "Belastung des Bewegungsapparats durch wiederholte Be- und Entladung", + "hazard_cause": "Wiederholtes Heben und Bücken beim manuellen Be- und Entladen der Spülkörbe am Untertischgerät", + "lifecycle_phases": ["Betrieb"], + "component_zone": "Be- und Entladestelle, Spülkorb", + "risk_in": {"f": 4, "w": 3, "p": 2, "s": 1, "r": 9}, + "measures": ["Ergonomische Arbeitshöhe", "Be-/Entladung auf günstiger Greifhöhe"], + "measure_type": "KM", + "risk_out": {"f": 2, "w": 1, "p": 1, "s": 1, "r": 4}, + "norm_references": ["EN 1005-2"], + "sufficient": true + }, + { + "nr": "5.2", + "hazard_group": "Ergonomische Gefährdungen", + "hazard_group_applicable": true, + "hazard_type": "Zwangshaltung durch ungünstige Bedienelement-Position", + "hazard_cause": "Bedienelemente am HMI außerhalb der ergonomisch günstigen Reichweite führen bei dauerhafter Bedienung zu Zwangshaltung", + "lifecycle_phases": ["Betrieb"], + "component_zone": "Bedienstand HMI, Steuerpult", + "risk_in": {"f": 3, "w": 2, "p": 1, "s": 1, "r": 6}, + "measures": ["Bedienelemente in ergonomisch günstiger Höhe"], + "measure_type": "KM", + "risk_out": {"f": 1, "w": 1, "p": 1, "s": 1, "r": 3}, + "norm_references": ["EN 894-3"], + "sufficient": true + }, + { + "nr": "6.1", + "hazard_group": "zusätzliche Gefährdungen", + "hazard_group_applicable": true, + "hazard_type": "Verlust einer Sicherheitsfunktion durch Steuerungs- oder Softwarefehler", + "hazard_cause": "Steuerungs- oder Softwarefehler der eigenen Maschinensteuerung führt zu unkontrolliertem Verhalten oder Verlust einer Sicherheitsfunktion", + "lifecycle_phases": ["Betrieb", "Instandhaltung"], + "component_zone": "Gesamte Maschine, Steuerung", + "risk_in": {"f": 2, "w": 2, "p": 2, "s": 3, "r": 18}, + "measures": ["Sichere Fehlerbehandlung", "Sichere Software-Fallbacks", "Watchdog"], + "measure_type": "KM", + "risk_out": {"f": 1, "w": 1, "p": 1, "s": 3, "r": 9}, + "norm_references": ["EN ISO 13849-1"], + "sufficient": true + }, + { + "nr": "6.2", + "hazard_group": "zusätzliche Gefährdungen", + "hazard_group_applicable": true, + "hazard_type": "Verlust der Sicherheitsfunktion nach fehlerhaftem Software-Update", + "hazard_cause": "Korrupte oder inkompatible Firmware nach fehlerhaftem Update über die USB-Schnittstelle lässt die Steuerung undefiniert verhalten oder Sicherheitsfunktion verlieren", + "lifecycle_phases": ["Instandhaltung"], + "component_zone": "Gesamte Maschine, Steuerung, Update-Schnittstelle", + "risk_in": {"f": 2, "w": 2, "p": 2, "s": 3, "r": 18}, + "measures": ["Atomares Update mit Rückfall auf lauffähige Version", "Kompatibilitätsprüfung vor Update"], + "measure_type": "KM", + "risk_out": {"f": 1, "w": 1, "p": 1, "s": 3, "r": 9}, + "norm_references": ["EN ISO 13849-1"], + "sufficient": true + } + ] +} From 5318a70f9e5612b140a33f458e811e7a96adfa19 Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Wed, 24 Jun 2026 22:13:34 +0200 Subject: [PATCH 11/16] =?UTF-8?q?feat(ai-sdk):=20interlocked-enclosure=20m?= =?UTF-8?q?odel=20=E2=80=94=20guard-open=20re-scoping=20of=20contact=20haz?= =?UTF-8?q?ards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Class C (phase-aware, generic EN ISO 14120). A contact/entanglement hazard from a moving part is removed during NORMAL operation when the part is behind an interlocked guard; it remains only when the guard is open (maintenance/cleaning). - New HazardPattern.GuardableByEnclosure flag; set on HP096 (friction at rotating surfaces) and HP101 (entanglement of hair/clothing). - Narrative emits interlocked_enclosure for an interlocked door/hood. - pattern_enclosure.go: suppressedByEnclosure (drop in normal-op-only contexts) + guardedLifecycles (re-scope to maintenance/cleaning). - GT #3 gains the maintenance-phase entanglement/friction rows. Generic + regression-safe: machines that do not emit interlocked_enclosure are unaffected. GT #3 recall 80% -> 82.4%, one false positive removed (Aufwickeln). Kistenhub 97.1% and all 26 Bremse pinned mappings unchanged. Co-Authored-By: Claude Opus 4.7 --- .../internal/iace/hazard_pattern_types.go | 7 +++ .../iace/hazard_patterns_extended_dguv.go | 2 + .../internal/iace/keyword_dictionary.go | 6 +++ .../internal/iace/pattern_enclosure.go | 44 +++++++++++++++++++ .../internal/iace/pattern_engine.go | 7 ++- .../testdata/ground_truth_warewashing.json | 30 +++++++++++++ 6 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 ai-compliance-sdk/internal/iace/pattern_enclosure.go diff --git a/ai-compliance-sdk/internal/iace/hazard_pattern_types.go b/ai-compliance-sdk/internal/iace/hazard_pattern_types.go index 624862ad..9b63d207 100644 --- a/ai-compliance-sdk/internal/iace/hazard_pattern_types.go +++ b/ai-compliance-sdk/internal/iace/hazard_pattern_types.go @@ -62,6 +62,13 @@ type HazardPattern struct { // "hazard" = source only, "hazardous_situation" = person exposed, "harm" = injury. // Empty = default (hazardous_situation). GeneratedHazardType string `json:"generated_hazard_type,omitempty"` + // GuardableByEnclosure marks a contact/entanglement hazard that an interlocked + // enclosure removes during normal operation. When the project emits the + // "interlocked_enclosure" tag, such a pattern is re-scoped to maintenance/ + // cleaning (guard open) and does NOT fire as a normal-operation hazard. + // Generic EN ISO 14120 logic — surfaced by the warewashing GT (the spray + // arm rotates behind the interlocked door). + GuardableByEnclosure bool `json:"guardable_by_enclosure,omitempty"` // RequiredFailureModes restricts this pattern to fire only when at least one // of the listed failure modes is relevant (by ComponentType match against project components). // Empty/nil = fires regardless of failure modes (backwards compatible). diff --git a/ai-compliance-sdk/internal/iace/hazard_patterns_extended_dguv.go b/ai-compliance-sdk/internal/iace/hazard_patterns_extended_dguv.go index 5a888cb5..2e810efb 100644 --- a/ai-compliance-sdk/internal/iace/hazard_patterns_extended_dguv.go +++ b/ai-compliance-sdk/internal/iace/hazard_patterns_extended_dguv.go @@ -37,6 +37,7 @@ func GetDGUVExtendedPatterns() []HazardPattern { }, { ID: "HP096", NameDE: "Reibung/Abrieb durch rotierende Oberflaechen", NameEN: "Friction/abrasion by rotating surfaces", + GuardableByEnclosure: true, RequiredComponentTags: []string{"rotating_part"}, RequiredEnergyTags: []string{}, GeneratedHazardCats: []string{"mechanical_hazard"}, @@ -88,6 +89,7 @@ func GetDGUVExtendedPatterns() []HazardPattern { }, { ID: "HP101", NameDE: "Aufwickeln von Kleidung/Haaren", NameEN: "Winding up of clothing/hair", + GuardableByEnclosure: true, RequiredComponentTags: []string{"rotating_part"}, RequiredEnergyTags: []string{"rotational"}, GeneratedHazardCats: []string{"mechanical_hazard"}, diff --git a/ai-compliance-sdk/internal/iace/keyword_dictionary.go b/ai-compliance-sdk/internal/iace/keyword_dictionary.go index 5cf36550..abea20ea 100644 --- a/ai-compliance-sdk/internal/iace/keyword_dictionary.go +++ b/ai-compliance-sdk/internal/iace/keyword_dictionary.go @@ -201,6 +201,12 @@ func GetKeywordDictionary() []KeywordEntry { {Keywords: []string{"lichtgitter", "lichtvorhang", "light curtain", "light grid"}, ComponentIDs: []string{"C102"}, ExtraTags: []string{"safety_device"}}, {Keywords: []string{"sicherheitsschalter", "safety switch"}, ComponentIDs: []string{"C104"}, ExtraTags: []string{"safety_device", "interlocked"}}, {Keywords: []string{"zuhaltung", "guard locking", "interlock"}, ComponentIDs: []string{"C105"}, ExtraTags: []string{"safety_device", "interlocked"}}, + // interlocked_enclosure signals that moving parts are inaccessible behind a + // guard that is monitored/locked — feeds the GuardableByEnclosure re-scoping + // (contact/entanglement becomes a maintenance/guard-open hazard, not a + // normal-operation one). Emitted only by explicit "interlocked door/guard" + // vocabulary so it does not trigger for machines with exposed motion. + {Keywords: []string{"tuer mit sicherheitsschalter", "verriegelte tuer", "verriegelte haube", "verriegelte einhausung", "sicherheitstuer", "tuerverriegelung", "haube mit sicherheitsschalter"}, ExtraTags: []string{"interlocked_enclosure"}}, {Keywords: []string{"zweihand", "two-hand", "zweihandschaltung"}, ComponentIDs: []string{"C106"}, ExtraTags: []string{"safety_device", "two_hand_control_required"}}, {Keywords: []string{"schaltmatte", "safety mat"}, ComponentIDs: []string{"C108"}, ExtraTags: []string{"safety_device"}}, {Keywords: []string{"seilzug", "pull wire"}, ComponentIDs: []string{"C109"}, ExtraTags: []string{"safety_device"}}, diff --git a/ai-compliance-sdk/internal/iace/pattern_enclosure.go b/ai-compliance-sdk/internal/iace/pattern_enclosure.go new file mode 100644 index 00000000..a10cdf0e --- /dev/null +++ b/ai-compliance-sdk/internal/iace/pattern_enclosure.go @@ -0,0 +1,44 @@ +package iace + +// Interlocked-enclosure model (EN ISO 14120 / EN ISO 12100). +// +// A contact or entanglement hazard from a moving part is removed during NORMAL +// operation when that part is inaccessible behind an interlocked guard. The +// hazard then remains only when the guard is open — maintenance, cleaning or +// fault clearing. Patterns flagged GuardableByEnclosure express this; a project +// emits the "interlocked_enclosure" tag (interlocked door/hood, see +// keyword_dictionary.go) to declare the guard. +// +// This is GENERIC: it applies to every enclosed machine (dishwasher spray arm, +// enclosed mixer, centrifuge ...) and is regression-safe — machines that do not +// emit interlocked_enclosure are unaffected. + +const ( + phaseMaintenance = "maintenance" + phaseCleaning = "cleaning" + phaseFaultClearing = "fault_clearing" +) + +// suppressedByEnclosure reports whether a guardable hazard must be dropped: the +// part is enclosed AND none of the project's lifecycle phases opens the guard. +func suppressedByEnclosure(p HazardPattern, tagSet map[string]bool, lifecycles []string) bool { + if !p.GuardableByEnclosure || !tagSet["interlocked_enclosure"] || len(lifecycles) == 0 { + return false + } + for _, lc := range lifecycles { + if lc == phaseMaintenance || lc == phaseCleaning || lc == phaseFaultClearing { + return false // guard is open in some phase → hazard remains there + } + } + return true +} + +// guardedLifecycles re-scopes a guardable hazard to the guard-open phases when +// the project declares an interlocked enclosure, so it is documented as a +// maintenance/cleaning hazard rather than a normal-operation one. +func guardedLifecycles(p HazardPattern, tagSet map[string]bool) []string { + if p.GuardableByEnclosure && tagSet["interlocked_enclosure"] { + return []string{phaseMaintenance, phaseCleaning} + } + return p.ApplicableLifecycles +} diff --git a/ai-compliance-sdk/internal/iace/pattern_engine.go b/ai-compliance-sdk/internal/iace/pattern_engine.go index 7197cbf4..cc37a560 100644 --- a/ai-compliance-sdk/internal/iace/pattern_engine.go +++ b/ai-compliance-sdk/internal/iace/pattern_engine.go @@ -223,7 +223,7 @@ func (e *PatternEngine) Match(input MatchInput) *MatchOutput { HumanRoles: p.HumanRoles, GeneratedHazardType: p.GeneratedHazardType, MatchedFailureModes: matchedFMs, - ApplicableLifecycles: p.ApplicableLifecycles, + ApplicableLifecycles: guardedLifecycles(p, tagSet), SuggestedMeasureIDs: p.SuggestedMeasureIDs, ClarificationQuestionsDE: p.ClarificationQuestionsDE, ISO12100Section: p.ISO12100Section, @@ -411,6 +411,11 @@ func patternMatches(p HazardPattern, tagSet map[string]bool, input MatchInput) b } } + // Interlocked-enclosure gate (guardable contact/entanglement). See pattern_enclosure.go. + if suppressedByEnclosure(p, tagSet, input.LifecyclePhases) { + return false + } + return true } diff --git a/ai-compliance-sdk/internal/iace/testdata/ground_truth_warewashing.json b/ai-compliance-sdk/internal/iace/testdata/ground_truth_warewashing.json index 725972a0..3c050083 100644 --- a/ai-compliance-sdk/internal/iace/testdata/ground_truth_warewashing.json +++ b/ai-compliance-sdk/internal/iace/testdata/ground_truth_warewashing.json @@ -228,6 +228,36 @@ "risk_out": {"f": 1, "w": 1, "p": 1, "s": 3, "r": 9}, "norm_references": ["EN ISO 13849-1"], "sufficient": true + }, + { + "nr": "4.4", + "hazard_group": "Mechanische Gefährdungen", + "hazard_group_applicable": true, + "hazard_type": "Erfassen/Aufwickeln an rotierenden Teilen bei geöffneter Schutztür", + "hazard_cause": "Bei geöffneter Tür im Wartungs- oder Reinigungsfall können lose Kleidung oder Haare an noch zugänglichen rotierenden Wellen erfasst und aufgewickelt werden", + "lifecycle_phases": ["Instandhaltung", "Reinigung"], + "component_zone": "Rotierende Wellen, Spülarm bei geöffneter Schutztür", + "risk_in": {"f": 1, "w": 1, "p": 2, "s": 3, "r": 12}, + "measures": ["Rotation stoppt bei geöffneter Tür durch Verriegelung", "Warnhinweis"], + "measure_type": "KM", + "risk_out": {"f": 1, "w": 1, "p": 1, "s": 3, "r": 6}, + "norm_references": ["EN ISO 14120"], + "sufficient": true + }, + { + "nr": "4.5", + "hazard_group": "Mechanische Gefährdungen", + "hazard_group_applicable": true, + "hazard_type": "Reibung/Hautabschürfung an rotierenden Teilen bei geöffneter Schutztür", + "hazard_cause": "Berührung rotierender Wellen oder Oberflächen bei geöffneter Tür im Wartungsfall führt zu Hautabschürfungen durch Reibung", + "lifecycle_phases": ["Instandhaltung"], + "component_zone": "Rotierende Welle bei geöffneter Schutztür", + "risk_in": {"f": 1, "w": 1, "p": 2, "s": 2, "r": 8}, + "measures": ["Rotation stoppt bei geöffneter Tür durch Verriegelung"], + "measure_type": "KM", + "risk_out": {"f": 1, "w": 1, "p": 1, "s": 2, "r": 4}, + "norm_references": ["EN ISO 14120"], + "sufficient": true } ] } From bde6e76a57722a5da9f59a492b0e2fed54f9dc9a Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Wed, 24 Jun 2026 23:01:19 +0200 Subject: [PATCH 12/16] =?UTF-8?q?fix(ai-sdk):=20keyword=20precision=20?= =?UTF-8?q?=E2=80=94=20stop=20adjective/generic=20ghost=20components?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Class D (generic keyword hygiene, GT-guarded). Two over-broad keyword->component mappings produced ghost components: - "kuehl"/"cool" -> Kuehlaggregat (C095) matched product variants ("Cool-Ausfuehrung") and outputs ("kuehle Glaeser"). Narrowed to cooling-UNIT terms (kuehlaggregat, kuehlanlage, kuehler, kaeltemaschine, chiller, rueckkuehl). - "filter" -> Absauganlage/Oelnebelabscheider (C124) matched any filter (Laugen-/Wasser-/Oelfilter). Keep "filteranlage" only. No pattern or GT test depends on these mappings (Kistenhub/Bremse use hand-crafted inputs). UC-M now parses 6 plausible components (was 8 incl. the two ghosts). Warewashing GT recall 82.4% and Kistenhub/Bremse pins unchanged. Co-Authored-By: Claude Opus 4.7 --- .../internal/iace/gt_warewashing_test.go | 11 +++++++++++ ai-compliance-sdk/internal/iace/keyword_dictionary.go | 9 +++++++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/ai-compliance-sdk/internal/iace/gt_warewashing_test.go b/ai-compliance-sdk/internal/iace/gt_warewashing_test.go index 456619c7..1d0a6552 100644 --- a/ai-compliance-sdk/internal/iace/gt_warewashing_test.go +++ b/ai-compliance-sdk/internal/iace/gt_warewashing_test.go @@ -103,6 +103,17 @@ func TestWarewashing_GTCoverage(t *testing.T) { t.Fatalf("parse GT: %v", err) } + { + res := ParseNarrative(warewashingNarrative, "Gewerbliche Untertisch-Geschirrspuelmaschine (vernetzt)") + var cn []string + for _, c := range res.Components { + if !c.Negated { + cn = append(cn, c.NameDE) + } + } + t.Logf("Parsed components: %v", cn) + } + hazards, mitigations, nPatterns := warewashingEngineOutput() t.Logf("Engine: %d patterns kept (relevance+cyber filter) -> %d hazards", nPatterns, len(hazards)) diff --git a/ai-compliance-sdk/internal/iace/keyword_dictionary.go b/ai-compliance-sdk/internal/iace/keyword_dictionary.go index abea20ea..001561e7 100644 --- a/ai-compliance-sdk/internal/iace/keyword_dictionary.go +++ b/ai-compliance-sdk/internal/iace/keyword_dictionary.go @@ -219,7 +219,9 @@ func GetKeywordDictionary() []KeywordEntry { // ── Absaugung / Umwelt ────────────────────────────────────────── {Keywords: []string{"absaug", "extraction", "abscheider"}, ComponentIDs: []string{"C124"}, ExtraTags: []string{"noise_source"}}, - {Keywords: []string{"filter", "filteranlage"}, ComponentIDs: []string{"C124"}, ExtraTags: []string{}}, + // "filteranlage" only — bare "filter" falsely mapped any filter (Laugen-, + // Wasser-, Oel-, Netzfilter) to the oil-mist extractor C124. + {Keywords: []string{"filteranlage"}, ComponentIDs: []string{"C124"}, ExtraTags: []string{}}, // ── IT / Netzwerk ─────────────────────────────────────────────── {Keywords: []string{"switch", "netzwerk"}, ComponentIDs: []string{"C111"}, ExtraTags: []string{"networked"}}, @@ -248,7 +250,10 @@ func GetKeywordDictionary() []KeywordEntry { {Keywords: []string{"biege", "bend"}, ComponentIDs: []string{"C019"}, ExtraTags: []string{"high_force"}}, {Keywords: []string{"stanz", "stamp", "punch"}, ComponentIDs: []string{"C018"}, ExtraTags: []string{"high_force", "crush_point"}}, {Keywords: []string{"heiz", "heater", "heating"}, ComponentIDs: []string{"C094"}, EnergyIDs: []string{"EN06"}, ExtraTags: []string{"high_temperature"}}, - {Keywords: []string{"kuehl", "cool"}, ComponentIDs: []string{"C095"}, ExtraTags: []string{}}, + // Cooling UNIT only — not the bare adjectives "kuehl"/"cool", which falsely + // matched product-variant names ("Cool-Ausfuehrung") and outputs ("kuehle + // Glaeser"). Keyword->component must name an actual component. + {Keywords: []string{"kuehlaggregat", "kuehlanlage", "kuehler", "kaeltemaschine", "chiller", "rueckkuehl"}, ComponentIDs: []string{"C095"}, ExtraTags: []string{}}, {Keywords: []string{"luefter", "fan", "geblaese"}, ComponentIDs: []string{"C096"}, ExtraTags: []string{"rotating_part", "noise_source"}}, {Keywords: []string{"spannvorrichtung", "fixture", "clamp"}, ComponentIDs: []string{"C100"}, ExtraTags: []string{"clamping_part"}}, From 8563798c4f9af1b2e548c9423d9b691e64badd0b Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Wed, 24 Jun 2026 23:06:01 +0200 Subject: [PATCH 13/16] =?UTF-8?q?fix(ai-sdk):=20one=20hazard=20per=20patte?= =?UTF-8?q?rn=20in=20init=20=E2=80=94=20drop=20cross-category=20duplicates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Class E1. A multi-category pattern (e.g. "Motorueberlast" [electrical, thermal], "Lagerschaden" [mechanical, thermal]) created one hazard per category, so the same scenario+zone appeared twice in the CE hazard log under different labels. InitializeProject now breaks after the primary (first eligible) category — one hazard per pattern. This aligns production with the GT benchmark, which already scores one hazard per matched pattern. Cyber-skip, per-category cap and cross-pattern measure-merge still use continue (unchanged). Handlers + iace suites green; Kistenhub/Bremse unchanged. Note (E2, not fixed): some scenarios exist as TWO separate patterns (e.g. "Sicherheitssoftware manipuliert" in hazard_patterns_final_c.go and _final_d.go) — library redundancy that E1's per-pattern break cannot merge. Left for a separate, GT-guarded library-dedup audit. Co-Authored-By: Claude Opus 4.7 --- ai-compliance-sdk/internal/api/handlers/iace_handler_init.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ai-compliance-sdk/internal/api/handlers/iace_handler_init.go b/ai-compliance-sdk/internal/api/handlers/iace_handler_init.go index c3c0a9ec..ced8b04e 100644 --- a/ai-compliance-sdk/internal/api/handlers/iace_handler_init.go +++ b/ai-compliance-sdk/internal/api/handlers/iace_handler_init.go @@ -298,6 +298,10 @@ func (h *IACEHandler) InitializeProject(c *gin.Context) { if len(mp.SuggestedMeasureIDs) > 0 { hazardPatternMeasures[hz.ID] = mp.SuggestedMeasureIDs } + // E1: one hazard per pattern — keep only the primary (first + // eligible) category; a secondary category would be the same + // scenario+zone under a different label (cross-category duplicate). + break } } } From fe5dc59152a2ecab73da7dd5d3c04e20b5a35713 Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Wed, 24 Jun 2026 23:46:19 +0200 Subject: [PATCH 14/16] =?UTF-8?q?test(ai-sdk):=20GT=20#3=20completeness=20?= =?UTF-8?q?=E2=80=94=208=20shared=20white-goods=20hazards=20+=20CNC=20gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of the commercial white-goods expansion (EN ISO 10472 family). Extend GT #3 with 8 completeness hazards a Fachmann expects but that were neither in the GT nor previously questioned: dry-run boiler overheating, residual/stored electrical energy, sharp-edge cut, tipping, interlock-failure, unexpected restart, backflow (EN 1717), microbial/legionella. Enrich the UC-M narrative with the real features so existing library patterns can fire. Result: 4/8 auto-covered by existing patterns (dry-run, residual voltage, tipping, interlock-failure) — recall 84% (21/25). Remaining gaps documented: spray-arm contact (4.3), sharp-edge cut (4.6), backflow (2.3), restart (6.4). Gate the re-surfaced CNC leak ("spanende Bearbeitung", high_temperature-only) via dom_cnc. Kistenhub 97.1% and Bremse pinned mappings unchanged. Co-Authored-By: Claude Opus 4.7 --- .../internal/iace/gt_warewashing_test.go | 27 +++- .../internal/iace/pattern_domain_gates.go | 1 + .../testdata/ground_truth_warewashing.json | 120 ++++++++++++++++++ 3 files changed, 147 insertions(+), 1 deletion(-) diff --git a/ai-compliance-sdk/internal/iace/gt_warewashing_test.go b/ai-compliance-sdk/internal/iace/gt_warewashing_test.go index 1d0a6552..6644bd36 100644 --- a/ai-compliance-sdk/internal/iace/gt_warewashing_test.go +++ b/ai-compliance-sdk/internal/iace/gt_warewashing_test.go @@ -27,7 +27,12 @@ const warewashingNarrative = `Gewerbliche Untertisch-Geschirrspuelmaschine fuer `PIN-geschuetzter Servicetechniker-Fernzugriff. Cool-Ausfuehrung mit kalter Nachspuelung. ` + `Untertischmontage. Eingreifen in die Spuelkammer moeglich. Aerosole und Daempfe der ` + `Reinigungschemie gelangen in die Atemzone. Manuelles Be- und Entladen der Spuelkoerbe von Hand. ` + - `Reinigung und Wartung durch Servicetechniker. Branche Lebensmittel und Getraenke.` + `Reinigung und Wartung durch Servicetechniker. Branche Lebensmittel und Getraenke. ` + + `Siebe und scharfe Blechkanten in der Spuelkammer. Boiler kann bei Wassermangel trockenlaufen. ` + + `Frequenzumrichter und Elektronik mit Restspannung nach dem Abschalten. Wartung nur im ` + + `freigeschalteten Zustand; Gefahr des unerwarteten Wiederanlaufs. Frischwasseranschluss mit ` + + `Rueckflussverhinderer gegen Ruecksaugen in das Trinkwassernetz. Stehwasser im Boiler ` + + `(Hygiene/Legionellen). Standsicherheit bei Untertischmontage.` // warewashingCyberCategories mirrors handlers.nativeCyberSecurityCategories — // native cyber/AI hazards are routed to the CRA module, not the CE hazard log. @@ -134,6 +139,26 @@ func TestWarewashing_GTCoverage(t *testing.T) { t.Logf(" MISS %s: %s", m.Nr, abbrev(m.HazardType, 60)) } } + + // Measure completeness: which generated hazards have NO protective measure? + t.Logf("--- Measure completeness ---") + t.Logf("Measure coverage (GT-matched): %.0f%%", result.MeasureCoverage*100) + withMeas := make(map[string]bool) + for _, m := range mitigations { + withMeas[m.HazardID.String()] = true + } + noMeasure := 0 + for _, h := range hazards { + if !withMeas[h.ID.String()] { + noMeasure++ + n := h.Name + if n == "" { + n = h.Scenario + } + t.Logf(" NO-MEASURE: [%s] %s", h.Category, abbrev(n, 60)) + } + } + t.Logf("Hazards without any measure: %d/%d", noMeasure, len(hazards)) if len(result.ExtraInEngine) > 0 { t.Logf("--- EXTRA (false positives / precision loss) ---") names := make([]string, 0, len(result.ExtraInEngine)) diff --git a/ai-compliance-sdk/internal/iace/pattern_domain_gates.go b/ai-compliance-sdk/internal/iace/pattern_domain_gates.go index 5ba2b582..834d0f5a 100644 --- a/ai-compliance-sdk/internal/iace/pattern_domain_gates.go +++ b/ai-compliance-sdk/internal/iace/pattern_domain_gates.go @@ -67,6 +67,7 @@ var domainGateTerms = map[string]string{ "gondel": "dom_wind", "rotorblatt": "dom_wind", "windenergieanlage": "dom_wind", // CNC / Zerspanung "drehmaschine": "dom_cnc", "fraesmaschine": "dom_cnc", + "spanende": "dom_cnc", "spanenden bearbeitung": "dom_cnc", // Landwirtschaft "maehdrescher": "dom_agri", "ballenpresse": "dom_agri", "feldhaecksler": "dom_agri", // Roll-/Fahrtreppe diff --git a/ai-compliance-sdk/internal/iace/testdata/ground_truth_warewashing.json b/ai-compliance-sdk/internal/iace/testdata/ground_truth_warewashing.json index 3c050083..11d5ebda 100644 --- a/ai-compliance-sdk/internal/iace/testdata/ground_truth_warewashing.json +++ b/ai-compliance-sdk/internal/iace/testdata/ground_truth_warewashing.json @@ -258,6 +258,126 @@ "risk_out": {"f": 1, "w": 1, "p": 1, "s": 2, "r": 4}, "norm_references": ["EN ISO 14120"], "sufficient": true + }, + { + "nr": "1.4", + "hazard_group": "Thermische Gefährdungen", + "hazard_group_applicable": true, + "hazard_type": "Trockenlauf-Überhitzung von Boiler/Heizung", + "hazard_cause": "Das Heizelement bzw. der Boiler läuft bei Wassermangel trocken, überhitzt und kann einen Brand oder eine Verbrühung durch überhitztes Wasser auslösen", + "lifecycle_phases": ["Betrieb"], + "component_zone": "Boiler, Tankheizkörper, Heizelement", + "risk_in": {"f": 2, "w": 2, "p": 2, "s": 3, "r": 18}, + "measures": ["Trockengehschutz / Niveauüberwachung der Heizung", "Temperaturbegrenzer (STB)"], + "measure_type": "KM", + "risk_out": {"f": 1, "w": 1, "p": 1, "s": 3, "r": 9}, + "norm_references": ["EN 60335-2-58", "EN 60335-1"], + "sufficient": true + }, + { + "nr": "3.4", + "hazard_group": "Elektrische Gefährdungen", + "hazard_group_applicable": true, + "hazard_type": "Restspannung / gespeicherte elektrische Energie nach Abschalten", + "hazard_cause": "Nach dem Abschalten der Spannungsversorgung stehen durch Kondensatoren im Frequenzumrichter oder Netzfilter noch gefährliche Berührungsspannungen an", + "lifecycle_phases": ["Instandhaltung", "Fehlersuche und -beseitigung"], + "component_zone": "Frequenzumrichter, Netzfilter, Schaltschrank", + "risk_in": {"f": 1, "w": 2, "p": 3, "s": 4, "r": 24}, + "measures": ["Sichere Energieentladung nach Abschalten", "Warnhinweis Restspannung, Entladezeit abwarten"], + "measure_type": "KM", + "risk_out": {"f": 1, "w": 1, "p": 1, "s": 4, "r": 12}, + "norm_references": ["IEC 60204-1"], + "sufficient": true + }, + { + "nr": "4.6", + "hazard_group": "Mechanische Gefährdungen", + "hazard_group_applicable": true, + "hazard_type": "Schnittverletzung an scharfen Kanten", + "hazard_cause": "Schneiden an scharfen Blechkanten, Sieben oder dem Ablaufpumpen-Laufrad beim Reinigen oder Eingreifen in die Spülkammer", + "lifecycle_phases": ["Reinigung", "Instandhaltung"], + "component_zone": "Zugängliche Kanten, Siebe, Spülkammer, Ablaufpumpe", + "risk_in": {"f": 3, "w": 2, "p": 2, "s": 1, "r": 7}, + "measures": ["Brechen oder Runden aller zugänglichen Kanten"], + "measure_type": "KM", + "risk_out": {"f": 1, "w": 1, "p": 1, "s": 1, "r": 3}, + "norm_references": ["EN ISO 12100"], + "sufficient": true + }, + { + "nr": "4.7", + "hazard_group": "Mechanische Gefährdungen", + "hazard_group_applicable": true, + "hazard_type": "Kippen / mangelnde Standsicherheit", + "hazard_cause": "Unzureichende Standsicherheit bei Untertischmontage, Transport oder Installation führt zum Kippen oder Umstürzen der Maschine", + "lifecycle_phases": ["Transport", "Montage und Installation"], + "component_zone": "Gesamte Maschine, Aufstellbereich", + "risk_in": {"f": 1, "w": 1, "p": 2, "s": 2, "r": 8}, + "measures": ["Standsichere Aufstellung / Befestigung", "Kippsichere Konstruktion"], + "measure_type": "KM", + "risk_out": {"f": 1, "w": 1, "p": 1, "s": 2, "r": 4}, + "norm_references": ["EN ISO 12100"], + "sufficient": true + }, + { + "nr": "2.3", + "hazard_group": "Gefährdungen durch Materialien und Substanzen", + "hazard_group_applicable": true, + "hazard_type": "Rückfluss / Kontamination des Trinkwassers", + "hazard_cause": "Verschmutztes Spül- oder Chemiewasser wird ohne Rückflussverhinderer in das Trinkwassernetz zurückgesaugt und kontaminiert es", + "lifecycle_phases": ["Betrieb"], + "component_zone": "Frischwasseranschluss, Wasserzulauf", + "risk_in": {"f": 2, "w": 2, "p": 2, "s": 3, "r": 18}, + "measures": ["Rückflussverhinderer / Systemtrenner nach EN 1717", "Freier Auslauf"], + "measure_type": "KM", + "risk_out": {"f": 1, "w": 1, "p": 1, "s": 3, "r": 9}, + "norm_references": ["EN 1717", "EN 60335-2-58"], + "sufficient": true + }, + { + "nr": "2.4", + "hazard_group": "Gefährdungen durch Materialien und Substanzen", + "hazard_group_applicable": true, + "hazard_type": "Mikrobielle Belastung / Legionellen im Stehwasser", + "hazard_cause": "Stehwasser im Boiler oder Tank bei niedrigen Temperaturen begünstigt mikrobielles Wachstum und Legionellen, die über Aerosole eingeatmet werden", + "lifecycle_phases": ["Betrieb", "Instandhaltung"], + "component_zone": "Boiler, Tank, Stehwasser", + "risk_in": {"f": 1, "w": 1, "p": 2, "s": 3, "r": 12}, + "measures": ["Thermische Desinfektion / ausreichende Wassertemperatur", "Regelmäßiger Wasserwechsel"], + "measure_type": "KM", + "risk_out": {"f": 1, "w": 1, "p": 1, "s": 3, "r": 9}, + "norm_references": ["EN 60335-2-58"], + "sufficient": true + }, + { + "nr": "6.3", + "hazard_group": "zusätzliche Gefährdungen", + "hazard_group_applicable": true, + "hazard_type": "Versagen der Tür-/Schutzeinrichtungs-Verriegelung", + "hazard_cause": "Die Verriegelung des Tür-Sicherheitsschalters versagt oder wird überbrückt, sodass der Zugriff in die Spülkammer bei laufendem Spülgang (Heißwasser, rotierender Spülarm) möglich wird", + "lifecycle_phases": ["Betrieb", "Instandhaltung"], + "component_zone": "Tür-Sicherheitsschalter, Verriegelung, Spülkammer", + "risk_in": {"f": 3, "w": 2, "p": 2, "s": 3, "r": 21}, + "measures": ["Sichere Verriegelung mit Fehlerüberwachung (PL nach ISO 13849)", "Zwangsöffnende Kontakte"], + "measure_type": "KM", + "risk_out": {"f": 1, "w": 1, "p": 1, "s": 3, "r": 9}, + "norm_references": ["EN ISO 14119", "EN ISO 13849-1"], + "sufficient": true + }, + { + "nr": "6.4", + "hazard_group": "zusätzliche Gefährdungen", + "hazard_group_applicable": true, + "hazard_type": "Unerwarteter Wiederanlauf bei Wartung", + "hazard_cause": "Während Wartung oder Reinigung läuft die Maschine durch fehlende Freischaltung (LOTO) oder automatischen Wiederanlauf unerwartet an", + "lifecycle_phases": ["Instandhaltung", "Reinigung"], + "component_zone": "Gesamte Maschine, Antriebe, Pumpe", + "risk_in": {"f": 2, "w": 2, "p": 2, "s": 3, "r": 18}, + "measures": ["Freischalten und gegen Wiedereinschalten sichern (LOTO)", "Kein automatischer Wiederanlauf"], + "measure_type": "KM", + "risk_out": {"f": 1, "w": 1, "p": 1, "s": 3, "r": 9}, + "norm_references": ["IEC 60204-1", "EN ISO 12100"], + "sufficient": true } ] } From d9d04deb00e04f376d6ae60e4690729fdd089018 Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Thu, 25 Jun 2026 00:05:30 +0200 Subject: [PATCH 15/16] =?UTF-8?q?feat(ai-sdk):=20close=20the=204=20GT=20#3?= =?UTF-8?q?=20recall=20gaps=20=E2=80=94=20backflow,=20cut,=20restart,=20sp?= =?UTF-8?q?ray-arm?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 complete. GT #3 recall 84% -> 100% (25/25 matched), no regression: - HP2207 backflow / potable-water contamination (EN 1717) + measure M2209 (Rueckflussverhinderer / Systemtrenner) — the only genuinely new hazard. - HP2208 cut on sharp edges/screens (new sharp_edge tag from scharfe-Kante/Sieb). - HP2209 unexpected restart during maintenance (dedicated dom_warewashing pattern; avoids flooding the log via the broad moving_part tag). - Spray-arm contact now covered by the enclosure-re-scoped contact patterns. Kistenhub 97.1% and Bremse pinned mappings unchanged; 0/28 hazards without a measure. Completes the commercial-dishwasher (white-goods Phase 1) coverage. Co-Authored-By: Claude Opus 4.7 --- .../iace/hazard_patterns_warewashing.go | 46 +++++++++++++++++++ .../internal/iace/keyword_dictionary.go | 3 ++ .../iace/measures_library_warewashing.go | 6 +++ 3 files changed, 55 insertions(+) diff --git a/ai-compliance-sdk/internal/iace/hazard_patterns_warewashing.go b/ai-compliance-sdk/internal/iace/hazard_patterns_warewashing.go index f01bb94b..39e05465 100644 --- a/ai-compliance-sdk/internal/iace/hazard_patterns_warewashing.go +++ b/ai-compliance-sdk/internal/iace/hazard_patterns_warewashing.go @@ -128,5 +128,51 @@ func GetWarewashingPatterns() []HazardPattern { ISO12100Section: "6.3.5.6", DefaultSeverity: 2, DefaultExposure: 3, }, + { + ID: "HP2207", NameDE: "Rueckfluss / Kontamination des Trinkwassers", NameEN: "Backflow / potable-water contamination", + RequiredComponentTags: []string{"dom_warewashing", "backflow_risk"}, + GeneratedHazardCats: []string{"material_environmental"}, + SuggestedMeasureIDs: []string{"M2209"}, + Priority: 84, + ApplicableLifecycles: []string{"normal_operation"}, + ScenarioDE: "Verschmutztes Spuel- oder Chemiewasser wird ueber den Frischwasseranschluss in das Trinkwassernetz zurueckgesaugt und kontaminiert es (Ruecksaugen bei Unterdruck im Netz).", + TriggerDE: "Fehlender oder defekter Rueckflussverhinderer/Systemtrenner; Unterdruck im Trinkwassernetz; kein freier Auslauf.", + HarmDE: "Gesundheitsgefaehrdung Dritter durch kontaminiertes Trinkwasser (Chemie, Keime).", + AffectedDE: "Verbraucher am selben Trinkwassernetz, Betreiber", + ZoneDE: "Frischwasseranschluss, Wasserzulauf", + ISO12100Section: "6.2.4", + DefaultSeverity: 3, DefaultExposure: 2, + }, + { + ID: "HP2208", NameDE: "Schnittverletzung an scharfen Kanten/Sieben", NameEN: "Cut injury on sharp edges/screens", + RequiredComponentTags: []string{"dom_warewashing", "sharp_edge"}, + GeneratedHazardCats: []string{"mechanical_hazard"}, + SuggestedMeasureIDs: []string{"M003"}, + Priority: 74, + ApplicableLifecycles: []string{"cleaning", "maintenance"}, + ScenarioDE: "Schneiden an scharfen Blechkanten, Sieben oder dem Ablaufpumpen-Laufrad beim Reinigen oder Eingreifen in die Spuelkammer.", + TriggerDE: "Entnehmen/Reinigen der Siebe; Eingreifen an scharfen Kanten ohne Schutzhandschuhe.", + HarmDE: "Schnittwunden an Haenden und Fingern.", + AffectedDE: "Reinigungspersonal, Bedienpersonal", + ZoneDE: "Zugaengliche Kanten, Siebe, Spuelkammer, Ablaufpumpe", + ISO12100Section: "6.2.2.1", + DefaultSeverity: 1, DefaultExposure: 3, + }, + { + ID: "HP2209", NameDE: "Unerwarteter Wiederanlauf bei Wartung/Reinigung", NameEN: "Unexpected restart during maintenance/cleaning", + RequiredComponentTags: []string{"dom_warewashing", "programmable"}, + RequiredLifecycles: []string{"maintenance", "cleaning", "fault_clearing"}, + GeneratedHazardCats: []string{"safety_function_failure"}, + SuggestedMeasureIDs: []string{"M042"}, + Priority: 80, + ApplicableLifecycles: []string{"maintenance", "cleaning"}, + ScenarioDE: "Waehrend Wartung oder Reinigung laeuft die Maschine durch fehlende Freischaltung (LOTO) oder automatischen Wiederanlauf unerwartet an (Pumpe, Spuelgang).", + TriggerDE: "Kein Freischalten/Sichern gegen Wiedereinschalten; automatischer Wiederanlauf nach Netzunterbrechung.", + HarmDE: "Verbruehung, Quetschen oder elektrischer Schlag durch unerwartet anlaufende Maschine.", + AffectedDE: "Wartungspersonal, Reinigungspersonal", + ZoneDE: "Gesamte Maschine, Pumpe, Antriebe", + ISO12100Section: "6.2.11.4", + DefaultSeverity: 3, DefaultExposure: 2, + }, } } diff --git a/ai-compliance-sdk/internal/iace/keyword_dictionary.go b/ai-compliance-sdk/internal/iace/keyword_dictionary.go index 001561e7..0522bd6a 100644 --- a/ai-compliance-sdk/internal/iace/keyword_dictionary.go +++ b/ai-compliance-sdk/internal/iace/keyword_dictionary.go @@ -107,6 +107,9 @@ func GetKeywordDictionary() []KeywordEntry { // honest (generic hygiene; surfaced by the warewashing GT). {Keywords: []string{"spuelarm", "spuelfeld", "wascharm", "spruehfeld"}, ExtraTags: []string{"rotating_part"}}, {Keywords: []string{"spuelkammer", "spueltuer", "geraetetuer", "haubentuer", "klapptuer"}, ExtraTags: []string{"access_door"}}, + // Frischwasseranschluss an das Trinkwassernetz -> Rueckfluss/Ruecksaug-Risiko (EN 1717). + {Keywords: []string{"rueckfluss", "rueckflussverhinderer", "ruecksaug", "trinkwasser", "frischwasseranschluss", "systemtrenner"}, ExtraTags: []string{"backflow_risk"}}, + {Keywords: []string{"scharfe kante", "scharfkant", "blechkante", "scharfe blechkante", "sieb", "siebe"}, ExtraTags: []string{"sharp_edge"}}, // Ghost-Closure (Emit-Seite): macht die 34 toten Required-Tags // emittierbar, jeweils NUR via domaenenspezifische Keywords -> die 120 // Ghost-Patterns feuern wieder, aber nur fuer ihre echte Maschine (kein diff --git a/ai-compliance-sdk/internal/iace/measures_library_warewashing.go b/ai-compliance-sdk/internal/iace/measures_library_warewashing.go index b3ba6bc6..8ac6e4a5 100644 --- a/ai-compliance-sdk/internal/iace/measures_library_warewashing.go +++ b/ai-compliance-sdk/internal/iace/measures_library_warewashing.go @@ -65,5 +65,11 @@ func getWarewashingMeasures() []ProtectiveMeasureEntry { HazardCategory: "general", Examples: []string{"Warnpiktogramm 'Heisser Dampf' an der Tuer", "BA-Hinweis 'Tuer nach Programmende langsam oeffnen'"}, NormReferences: []string{"ISO 7010", "EN 60335-2-58"}}, + {ID: "M2209", ReductionType: "design", SubType: "containment", + Name: "Rueckflussverhinderer / Systemtrenner nach EN 1717", + Description: "Der Frischwasseranschluss ist durch einen Rueckflussverhinderer bzw. Systemtrenner der passenden Schutzklasse oder durch einen freien Auslauf gegen Ruecksaugen verschmutzten Wassers in das Trinkwassernetz gesichert.", + HazardCategory: "material_environmental", + Examples: []string{"Systemtrenner Typ BA nach EN 1717", "Freier Auslauf Typ AB ueber dem hoechsten Wasserstand"}, + NormReferences: []string{"EN 1717", "EN 60335-2-58"}}, } } From 31222885b34e9e3d3bcd98c262258654fd9f6fd1 Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Thu, 25 Jun 2026 01:54:36 +0200 Subject: [PATCH 16/16] feat(ai-sdk): control-intent result diversity + standard-name classifier override MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On an implementation question impl_guidance (ENISA) keeps its earned semantic Top-1, but the top-K now surfaces the best operational_requirement and control_standard from the pool (ensureControlDiversity) — so different source roles are visible instead of one role flooding the list, without forcing the binding sources to Top-1. A recognised standard NAME (NIST/OWASP/ISO 27001/CIS/CSA CCM/Grundschutz) now overrides a mis-applied supervisory_guidance source_class in classifyAuthority, so those standards classify and rank as technical_standard (control_standard role). The corpus tags many standards as guidance (weight 70); the name wins. Co-Authored-By: Claude Opus 4.7 --- ai-compliance-sdk/internal/ucca/authority.go | 9 ++- .../internal/ucca/authority_test.go | 1 + .../internal/ucca/control_role.go | 51 +++++++++++++++++ .../internal/ucca/control_role_test.go | 55 +++++++++++++++++++ .../internal/ucca/legal_rag_client.go | 9 +++ 5 files changed, 124 insertions(+), 1 deletion(-) diff --git a/ai-compliance-sdk/internal/ucca/authority.go b/ai-compliance-sdk/internal/ucca/authority.go index 715f80c7..6627b57e 100644 --- a/ai-compliance-sdk/internal/ucca/authority.go +++ b/ai-compliance-sdk/internal/ucca/authority.go @@ -40,6 +40,14 @@ func classifyAuthority(r LegalSearchResult) authorityInfo { if jur == "" { jur = inferJurisdiction(r) } + hay := r.ArticleLabel + " " + r.RegulationShort + " " + r.RegulationName + " " + r.RegulationCode + // A recognised standard NAME (NIST/OWASP/ISO 27001/CIS/CSA CCM/Grundschutz) is authoritative + // even when the corpus mis-tagged the chunk as supervisory_guidance (weight 70) — many + // standards were ingested with a generic guidance source_class. The name wins, so they + // classify (and rank) as technical_standard / control_standard. binding_law is preserved. + if r.SourceClass != "binding_law" && containsAny(hay, standardMarkers) { + return authorityInfo{weight: 80, sourceClass: "technical_standard", jurisdiction: jur} + } if r.SourceClass != "" { w := r.AuthorityWeight if w == 0 && r.SourceClass == "binding_law" { @@ -50,7 +58,6 @@ func classifyAuthority(r LegalSearchResult) authorityInfo { if r.AuthorityWeight > 0 { return authorityInfo{weight: r.AuthorityWeight, sourceClass: sourceClassFromWeight(r.AuthorityWeight), jurisdiction: jur} } - hay := r.ArticleLabel + " " + r.RegulationShort + " " + r.RegulationName + " " + r.RegulationCode switch { case containsAny(hay, foreignMarkers): return authorityInfo{weight: 0, sourceClass: "foreign_law", jurisdiction: "CH"} diff --git a/ai-compliance-sdk/internal/ucca/authority_test.go b/ai-compliance-sdk/internal/ucca/authority_test.go index 5e63e2a6..ade01b18 100644 --- a/ai-compliance-sdk/internal/ucca/authority_test.go +++ b/ai-compliance-sdk/internal/ucca/authority_test.go @@ -15,6 +15,7 @@ func TestClassifyAuthority(t *testing.T) { {"tagged foreign CH", LegalSearchResult{AuthorityWeight: 0, SourceClass: "foreign_law", Jurisdiction: "CH"}, 0, "foreign_law", "CH"}, {"untagged ENISA guidance", LegalSearchResult{RegulationShort: "ENISA", ArticleLabel: "ENISA CRA Standards Mapping"}, 70, "supervisory_guidance", "EU"}, {"untagged NIST standard", LegalSearchResult{RegulationShort: "NIST SP 800-82r3", ArticleLabel: "AU-8"}, 80, "technical_standard", "EU"}, + {"mis-tagged NIST guidance -> standard by name", LegalSearchResult{SourceClass: "supervisory_guidance", AuthorityWeight: 70, RegulationShort: "NIST SP 800-82r3", ArticleLabel: "NIST SP 800-82r3"}, 80, "technical_standard", "EU"}, {"BSI Grundschutz standard beats BSI guidance", LegalSearchResult{RegulationShort: "BSI Grundschutz", ArticleLabel: "BSI Grundschutz Baustein"}, 80, "technical_standard", "DE"}, {"weight-only 85 TRGS standard", LegalSearchResult{AuthorityWeight: 85, RegulationShort: "TRGS 529"}, 85, "technical_standard", "EU"}, {"tagged technical_standard", LegalSearchResult{AuthorityWeight: 80, SourceClass: "technical_standard", Jurisdiction: "EU"}, 80, "technical_standard", "EU"}, diff --git a/ai-compliance-sdk/internal/ucca/control_role.go b/ai-compliance-sdk/internal/ucca/control_role.go index 8cfa509e..68a630c4 100644 --- a/ai-compliance-sdk/internal/ucca/control_role.go +++ b/ai-compliance-sdk/internal/ucca/control_role.go @@ -121,3 +121,54 @@ func controlRoleOf(payload map[string]interface{}) string { IsRecital: getBool(payload, "is_recital"), }) } + +// ensureControlDiversity guarantees that the returned top-K of a control question surfaces at +// least one operational_requirement and one control_standard WHEN the pool contains them — +// without forcing them to Top-1. implementation_guidance (e.g. ENISA good practices) keeps its +// earned semantic lead; the rule only promotes the best hit of a missing control role into the +// top-K by overwriting the lowest-ranked redundant guidance slot. So an implementation question +// shows the relevant source ROLES (binding requirement + standard + guidance) side by side +// instead of one role flooding the list. The promoted hit's original (now duplicate) position +// stays in the tail and is dropped by the caller's truncation to topK. +func ensureControlDiversity(results []LegalSearchResult, topK int) []LegalSearchResult { + if topK <= 0 || topK >= len(results) { + return results // everything is already returned — nothing to promote + } + roleAt := make([]string, len(results)) + for i := range results { + roleAt[i] = classifyRole(results[i]) + } + present := make(map[string]bool, topK) + for i := 0; i < topK; i++ { + present[roleAt[i]] = true + } + for _, want := range []string{roleOperationalReq, roleControlStandard} { + if present[want] { + continue + } + src := -1 + for i := topK; i < len(results); i++ { + if roleAt[i] == want { + src = i + break + } + } + if src < 0 { + continue // role absent from the whole pool — nothing to promote + } + dst := -1 + for j := topK - 1; j >= 0; j-- { + if roleAt[j] == roleImplGuidance { + dst = j + break + } + } + if dst < 0 { + continue // no redundant guidance to sacrifice — leave the head untouched + } + results[dst] = results[src] + roleAt[dst] = want + present[want] = true + } + return results +} diff --git a/ai-compliance-sdk/internal/ucca/control_role_test.go b/ai-compliance-sdk/internal/ucca/control_role_test.go index 597516d6..9842ae73 100644 --- a/ai-compliance-sdk/internal/ucca/control_role_test.go +++ b/ai-compliance-sdk/internal/ucca/control_role_test.go @@ -77,3 +77,58 @@ func TestControlRoleOf_Payload(t *testing.T) { t.Errorf("DORA abstract article role = %q must be excluded from the control-pool", got) } } + +func headHasRole(head []LegalSearchResult, role string) bool { + for _, r := range head { + if classifyRole(r) == role { + return true + } + } + return false +} + +func TestEnsureControlDiversity(t *testing.T) { + ig := func(n string) LegalSearchResult { + return LegalSearchResult{RegulationShort: "ENISA " + n + " Good Practices"} + } + opReq := LegalSearchResult{RegulationShort: "CRA", ArticleLabel: "CRA Anhang I", Category: "regulation"} + std := LegalSearchResult{RegulationShort: "NIST SP 800-53"} + + t.Run("injects missing op_req + control_standard, guidance keeps Top-1", func(t *testing.T) { + out := ensureControlDiversity([]LegalSearchResult{ig("A"), ig("B"), ig("C"), std, opReq}, 3) + head := out[:3] + if classifyRole(head[0]) != roleImplGuidance { + t.Errorf("Top-1 should stay implementation_guidance, got %q", classifyRole(head[0])) + } + if !headHasRole(head, roleOperationalReq) { + t.Error("top-K must contain an operational_requirement after diversity") + } + if !headHasRole(head, roleControlStandard) { + t.Error("top-K must contain a control_standard after diversity") + } + }) + + t.Run("no-op when both roles already present", func(t *testing.T) { + out := ensureControlDiversity([]LegalSearchResult{opReq, std, ig("A"), ig("B")}, 3) + if classifyRole(out[0]) != roleOperationalReq || classifyRole(out[1]) != roleControlStandard { + t.Error("already-diverse top-K must be left untouched") + } + }) + + t.Run("absent role is not forced (no panic)", func(t *testing.T) { + out := ensureControlDiversity([]LegalSearchResult{ig("A"), ig("B"), ig("C"), std}, 3) + if !headHasRole(out[:3], roleControlStandard) { + t.Error("present control_standard should be injected") + } + if headHasRole(out[:3], roleOperationalReq) { + t.Error("operational_requirement absent from the pool must NOT appear") + } + }) + + t.Run("topK covering the whole pool is unchanged", func(t *testing.T) { + out := ensureControlDiversity([]LegalSearchResult{ig("A"), opReq}, 5) + if len(out) != 2 || classifyRole(out[0]) != roleImplGuidance { + t.Error("topK >= len must return results unchanged") + } + }) +} diff --git a/ai-compliance-sdk/internal/ucca/legal_rag_client.go b/ai-compliance-sdk/internal/ucca/legal_rag_client.go index 0ac9f489..d0bb408e 100644 --- a/ai-compliance-sdk/internal/ucca/legal_rag_client.go +++ b/ai-compliance-sdk/internal/ucca/legal_rag_client.go @@ -166,6 +166,15 @@ func (c *LegalRAGClient) searchInternal(ctx context.Context, collection string, // Response-Schema unveraendert. Score traegt den Authority-Score, damit nachgelagerte // Multi-Collection-Merges (Advisor) die Ordnung bewahren. results = rerankByAuthority(query, results) + + // Control-Diversity: auf einer Umsetzungsfrage darf impl_guidance (ENISA) Top-1 bleiben, + // aber die Top-K soll mindestens eine binding operational_requirement (CRA Anhang I) und + // einen control_standard (NIST/ISO) zeigen, falls im Pool — Quellenarten sichtbar machen + // statt sie kuenstlich auf Top-1 zu heben. Nur Reihenfolge, vor der Truncation. + if queryWantsControls(query) { + results = ensureControlDiversity(results, topK) + } + if topK > 0 && len(results) > topK { results = results[:topK] }