Compare commits

..

2 Commits

Author SHA1 Message Date
Benjamin Admin 31222885b3 feat(ai-sdk): control-intent result diversity + standard-name classifier override
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 <noreply@anthropic.com>
2026-06-25 01:54:36 +02:00
Benjamin Admin d9d04deb00 feat(ai-sdk): close the 4 GT #3 recall gaps — backflow, cut, restart, spray-arm
CI / detect-changes (push) Successful in 6s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / build-sha-integrity (push) Successful in 5s
CI / validate-canonical-controls (push) Successful in 4s
CI / loc-budget (push) Successful in 17s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Has been skipped
CI / test-go (push) Successful in 58s
CI / iace-gt-coverage (push) Successful in 14s
CI / test-python-backend (push) Has been skipped
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
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 <noreply@anthropic.com>
2026-06-25 00:05:30 +02:00
8 changed files with 179 additions and 1 deletions
@@ -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,
},
}
}
@@ -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
@@ -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"}},
}
}
+8 -1
View File
@@ -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"}
@@ -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"},
@@ -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
}
@@ -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")
}
})
}
@@ -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]
}