From 4818fc51c2b4749a5a9338128355a51d4533a833 Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Mon, 29 Jun 2026 14:44:53 +0200 Subject: [PATCH 1/2] fix(ucca): guidance-intent erkennt direkt benannte Guidance-Dokumente queryWantsGuidance verfehlte rein dokument-namige Fragen ("Welche Kriterien nennt WP248 ...", "Was sagt GL 07/2020 ..."): guidanceIntentSignals enthielt zwar Herausgeber (edpb/dsk/enisa) und Verben (empfiehlt/laut), aber keine Working-Paper-/Guideline-Identifier. Dadurch loeste der Authority-Lift nicht aus -> binding_law (bzw. im homogenen Korpus sogar off-domain MaschVO/CRA) verdraengte die Guidance aus den Top-K. Fix: WP2xx / GL 0x / "working paper" als Guidance-Signal ergaenzt. Generisch ueber alle WP-/GL-Dokumente, KEINE doc-spezifische Regel (Query->Intent, nicht Query->konkretes Dokument). Validierung (homogener Build-Korpus, bge-m3 + Qdrant Cosine): - 10 Hard Cases: 8/10 -> 10/10 (WP248/WP260 zurueck in Top-8) - ComplianceBench-100: 0/100 Norm-Fragen veraendert (Freeze-Regression gruen), 18/18 Guidance-Intent-Fragen verbessert (binding -> korrekte Guidance-Klasse) - Hybrid == Dense (Keyword-RRF war NICHT die Ursache, der Lift-Gate war es) Co-Authored-By: Claude Opus 4.7 --- .../internal/ucca/authority_rerank.go | 4 + .../internal/ucca/guidance_fix_e2e_test.go | 105 ++++++++++++++++++ 2 files changed, 109 insertions(+) create mode 100644 ai-compliance-sdk/internal/ucca/guidance_fix_e2e_test.go diff --git a/ai-compliance-sdk/internal/ucca/authority_rerank.go b/ai-compliance-sdk/internal/ucca/authority_rerank.go index 79042937..e6a30b85 100644 --- a/ai-compliance-sdk/internal/ucca/authority_rerank.go +++ b/ai-compliance-sdk/internal/ucca/authority_rerank.go @@ -28,6 +28,10 @@ var guidanceIntentSignals = []string{ "edpb", "europäischer datenschutzausschuss", "europaeischer datenschutzausschuss", "dsk", "enisa", "bsi", "leitlinie", "guideline", "orientierungshilfe", "auslegung", "empfiehlt", "empfehlung", "sagt", "laut", + // Guidance-Dokumente direkt benannt (WP29-Working-Papers WP2xx + EDPB-Guidelines "GL 0x/20xx"): + // "Welche Kriterien nennt WP248 ..." / "Was sagt GL 07/2020 ..." tragen Guidance-Intent ohne + // die Verben oben. Fix: queryWantsGuidance verfehlte rein-doc-namige Formulierungen. + "wp2", "wp 2", "wp29", "working paper", "gl 0", } // controlIntentSignals mark a query that asks HOW to implement / which controls or diff --git a/ai-compliance-sdk/internal/ucca/guidance_fix_e2e_test.go b/ai-compliance-sdk/internal/ucca/guidance_fix_e2e_test.go new file mode 100644 index 00000000..d97c6e16 --- /dev/null +++ b/ai-compliance-sdk/internal/ucca/guidance_fix_e2e_test.go @@ -0,0 +1,105 @@ +package ucca + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strings" + "testing" +) + +// TestGuidanceFixE2E runs the 10 hard cases through the REAL LegalRAGClient against the +// homogeneous build collection. Guarded by RUN_E2E=1. Reports the rank of the expected +// document within the returned top-K — proving whether the guidanceIntentSignals fix lifts +// guidance (WP248/WP260) back into the prompt. Toggle RAG_HYBRID_SEARCH to compare modes. +func TestGuidanceFixE2E(t *testing.T) { + if os.Getenv("RUN_E2E") != "1" { + t.Skip("set RUN_E2E=1 + QDRANT_URL/OLLAMA_URL to run") + } + c := NewLegalRAGClient() + coll := os.Getenv("E2E_COLLECTION") + if coll == "" { + coll = "bp_compliance_kb_2026_1_build" + } + cases := []struct{ id, q, expect string }{ + {"GQ-0012", "Welche neun Kriterien nennt WP248 fuer ein voraussichtlich hohes Risiko?", "WP248"}, + {"GQ-0013", "Ab wie vielen der WP248-Kriterien ist in der Regel eine Datenschutz-Folgenabschaetzung erforderlich?", "WP248"}, + {"GQ-0023", "Welche Anforderungen stellt WP260 an eine klare und einfache Sprache?", "WP260"}, + {"GQ-0024", "Was versteht WP260 unter Layered Privacy Notices?", "WP260"}, + {"GQ-0054", "Welche grundlegenden Cybersecurity-Anforderungen enthaelt Annex I Part I?", "CRA"}, + {"GQ-0060", "Wann muss eine aktiv ausgenutzte Schwachstelle gemeldet werden?", "CRA"}, + {"GQ-0074", "Benoetigt eine SPS ohne Netzwerkanschluss eine CRA-Bewertung?", "CRA"}, + {"GQ-0079", "Welche grundlegenden Sicherheits- und Gesundheitsschutzanforderungen enthaelt Anhang III?", "MASCHVO"}, + {"GQ-0091", "Welche Anforderungen gelten fuer wesentliche Veraenderungen einer Maschine?", "MASCHVO"}, + {"GQ-0070", "Wie greifen CRA und Maschinenverordnung bei einer vernetzten Maschine ineinander?", "CRA"}, + } + fmt.Printf("\n### hybrid=%v collection=%s\n", os.Getenv("RAG_HYBRID_SEARCH") != "false", coll) + for _, tc := range cases { + res, err := c.SearchCollection(context.Background(), coll, tc.q, nil, 8) + if err != nil { + t.Fatalf("%s: %v", tc.id, err) + } + rank := -1 + for i, r := range res { + lab := strings.ToUpper(r.RegulationCode + " " + r.ArticleLabel) + if strings.Contains(lab, tc.expect) { + rank = i + 1 + break + } + } + top1 := "" + if len(res) > 0 { + top1 = res[0].RegulationCode + " (" + res[0].SourceClass + ")" + } + status := "FAIL" + if rank > 0 { + status = "OK" + } + fmt.Printf("%-9s expect=%-8s rank_in_top8=%-2d %-5s top1=%s\n", tc.id, tc.expect, rank, status, top1) + } +} + +// TestBenchE2E runs the FULL ComplianceBench (E2E_BENCH_FILE) through the real client and +// prints, per question, the ordered top-8 regulation codes. Diffing BEFORE vs AFTER proves +// the fix only perturbs guidance-intent queries (gated on queryWantsGuidance) and never the +// norm questions — the Knowledge-Freeze regression guard. +func TestBenchE2E(t *testing.T) { + if os.Getenv("RUN_E2E") != "1" { + t.Skip("set RUN_E2E=1 + E2E_BENCH_FILE") + } + path := os.Getenv("E2E_BENCH_FILE") + if path == "" { + t.Skip("E2E_BENCH_FILE not set") + } + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var bench struct { + Questions []struct { + ID string `json:"id"` + Question string `json:"question"` + } `json:"questions"` + } + if err := json.Unmarshal(raw, &bench); err != nil { + t.Fatal(err) + } + c := NewLegalRAGClient() + coll := os.Getenv("E2E_COLLECTION") + if coll == "" { + coll = "bp_compliance_kb_2026_1_build" + } + fmt.Printf("### BENCH n=%d hybrid=%v\n", len(bench.Questions), os.Getenv("RAG_HYBRID_SEARCH") != "false") + for _, q := range bench.Questions { + res, err := c.SearchCollection(context.Background(), coll, q.Question, nil, 8) + if err != nil { + t.Fatalf("%s: %v", q.ID, err) + } + codes := make([]string, 0, len(res)) + for _, r := range res { + codes = append(codes, strings.ReplaceAll(r.RegulationCode, ";", ",")) + } + fmt.Printf("BENCH|%s|%s\n", q.ID, strings.Join(codes, ";")) + } +} From 9760dca443d7a180de93e8730ad8a3e626dea059 Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Tue, 30 Jun 2026 08:17:16 +0200 Subject: [PATCH 2/2] =?UTF-8?q?feat(ucca):=20Multi-Regulation-Retrieval=20?= =?UTF-8?q?f=C3=BCr=20Cross-Regulation-Fragen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nennt eine Query EXPLIZIT >=2 Regelwerke ("Wie greifen CRA und Maschinen- verordnung ineinander?"), retrievt searchInternal pro Regelwerk separat (regulation_code/regulation_id-Filter) und merged — damit BEIDE Domänen im Prompt landen statt nur der keyword-dominanten. Generisch (Query->Regelwerke, KEINE doc-spezifische Logik), gegated auf >=2 erkannte Regelwerke; sonst unveränderter Single-Domain-Pfad. Behebt GQ-0070: vorher CRA x8 / null MaschVO -> Modell halluzinierte MaschVO=2019/2144 + falsche "CRA ausgenommen"-Konklusion. Nachher CRA + MaschVO im Prompt -> korrekt "beide gleichzeitig anwendbar" + Art. 20(9) Konformitätsvermutung, gegroundet. Validierung (Build-Collection, echtes SearchCollection): - Unit: detectRegulations-Scoping (>=2 -> multi, 1/0 -> single) - 5 Cross-Reg-Fälle (0070 + DSGVO+TDDDG/CRA+NIS2/DORA+NIS2/AI Act+DSGVO): beide Regelwerke in Top-8 - CB-100 Freeze-Regression: NUR GQ-0070 + GQ-0095 geändert (beide echte Cross-Reg, beide verbessert), 98/100 byte-identisch - 10 Hard Cases: 9 Single-Domain unverändert, 0070 behält CRA Rang 1 Filter erweitert auf regulation_id UND regulation_code (rückwärtskompatibel, aktiviert die re-ingestierte Build-Collection). Co-Authored-By: Claude Opus 4.7 --- .../internal/ucca/legal_rag_client.go | 51 ++----- .../internal/ucca/legal_rag_http.go | 28 ++-- .../internal/ucca/multi_regulation.go | 143 ++++++++++++++++++ .../internal/ucca/multi_regulation_test.go | 92 +++++++++++ 4 files changed, 265 insertions(+), 49 deletions(-) create mode 100644 ai-compliance-sdk/internal/ucca/multi_regulation.go create mode 100644 ai-compliance-sdk/internal/ucca/multi_regulation_test.go diff --git a/ai-compliance-sdk/internal/ucca/legal_rag_client.go b/ai-compliance-sdk/internal/ucca/legal_rag_client.go index d0bb408e..c8c0c3c7 100644 --- a/ai-compliance-sdk/internal/ucca/legal_rag_client.go +++ b/ai-compliance-sdk/internal/ucca/legal_rag_client.go @@ -78,6 +78,19 @@ func (c *LegalRAGClient) Search(ctx context.Context, query string, regulationIDs // If hybrid search is enabled, it uses the Qdrant Query API with RRF fusion // (dense + full-text). Falls back to dense-only /points/search on failure. func (c *LegalRAGClient) searchInternal(ctx context.Context, collection string, query string, regulationIDs []string, topK int) ([]LegalSearchResult, error) { + // Multi-Regulation-Retrieval: nennt die Query EXPLIZIT >=2 Regelwerke (z.B. "CRA und + // Maschinenverordnung"), wird pro Regelwerk separat retrieved + gemergt, damit BEIDE + // Domaenen im Prompt landen statt nur der keyword-dominanten. Generisch (Query->Regelwerke, + // keine doc-spezifische Logik); nur wenn der Caller nicht ohnehin schon auf Regulierungen + // filtert. Best-effort: leeres/fehlerhaftes Multi-Ergebnis faellt auf die Standardsuche zurueck. + if len(regulationIDs) == 0 { + if regs := detectRegulations(query); len(regs) >= 2 { + if mr, mErr := c.searchMultiRegulation(ctx, collection, query, regs, topK); mErr == nil && len(mr) > 0 { + return mr, nil + } + } + } + embedding, err := c.generateEmbedding(ctx, query) if err != nil { return nil, fmt.Errorf("failed to generate embedding: %w", err) @@ -123,43 +136,7 @@ func (c *LegalRAGClient) searchInternal(ctx context.Context, collection string, 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 - // (article_label/regulation_code/article/...); Fallback auf alte Feldnamen, solange der - // Korpus noch nicht re-ingestiert ist (regulation_id, section="§ 38"). - regCode := getString(hit.Payload, "regulation_code") - if regCode == "" { - regCode = getString(hit.Payload, "regulation_id") - } - article := getString(hit.Payload, "article") - if article == "" { - article = getString(hit.Payload, "section") - } - results[i] = LegalSearchResult{ - Text: getString(hit.Payload, "chunk_text"), - RegulationCode: regCode, - RegulationName: getString(hit.Payload, "regulation_name_de"), - RegulationShort: getString(hit.Payload, "regulation_short"), - Category: getString(hit.Payload, "category"), - ArticleLabel: getString(hit.Payload, "article_label"), - Article: article, - Paragraph: getString(hit.Payload, "paragraph"), - Sub: getString(hit.Payload, "sub"), - IsRecital: getBool(hit.Payload, "is_recital"), - CitationStyle: getString(hit.Payload, "citation_style"), - Pages: getIntSlice(hit.Payload, "pages"), - SourceURL: getString(hit.Payload, "source"), - Score: hit.Score, - 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", - } - } + results := hitsToResults(hits) // Authority-aware Re-Ranking: bindendes Recht der passenden Jurisdiktion/Domaene nach // oben, Guidance/Fremdrecht/Off-Domain runter (nichts wird geloescht). Reihenfolge only, diff --git a/ai-compliance-sdk/internal/ucca/legal_rag_http.go b/ai-compliance-sdk/internal/ucca/legal_rag_http.go index c9805d0a..dd660ed6 100644 --- a/ai-compliance-sdk/internal/ucca/legal_rag_http.go +++ b/ai-compliance-sdk/internal/ucca/legal_rag_http.go @@ -122,12 +122,14 @@ func (c *LegalRAGClient) searchHybrid(ctx context.Context, collection string, em } if len(regulationIDs) > 0 { - conditions := make([]qdrantCondition, len(regulationIDs)) - for i, regID := range regulationIDs { - conditions[i] = qdrantCondition{ - Key: "regulation_id", - Match: qdrantMatch{Value: regID}, - } + // Match BOTH the legacy field (regulation_id) and the normalized field + // (regulation_code) so per-regulation filtering works on the re-ingested corpus too. + conditions := make([]qdrantCondition, 0, len(regulationIDs)*2) + for _, regID := range regulationIDs { + conditions = append(conditions, + qdrantCondition{Key: "regulation_id", Match: qdrantMatch{Value: regID}}, + qdrantCondition{Key: "regulation_code", Match: qdrantMatch{Value: regID}}, + ) } queryReq.Filter = &qdrantFilter{Should: conditions} } @@ -175,12 +177,14 @@ func (c *LegalRAGClient) searchDense(ctx context.Context, collection string, emb } if len(regulationIDs) > 0 { - conditions := make([]qdrantCondition, len(regulationIDs)) - for i, regID := range regulationIDs { - conditions[i] = qdrantCondition{ - Key: "regulation_id", - Match: qdrantMatch{Value: regID}, - } + // Match BOTH the legacy field (regulation_id) and the normalized field + // (regulation_code) so per-regulation filtering works on the re-ingested corpus too. + conditions := make([]qdrantCondition, 0, len(regulationIDs)*2) + for _, regID := range regulationIDs { + conditions = append(conditions, + qdrantCondition{Key: "regulation_id", Match: qdrantMatch{Value: regID}}, + qdrantCondition{Key: "regulation_code", Match: qdrantMatch{Value: regID}}, + ) } searchReq.Filter = &qdrantFilter{Should: conditions} } diff --git a/ai-compliance-sdk/internal/ucca/multi_regulation.go b/ai-compliance-sdk/internal/ucca/multi_regulation.go new file mode 100644 index 00000000..3a71dd54 --- /dev/null +++ b/ai-compliance-sdk/internal/ucca/multi_regulation.go @@ -0,0 +1,143 @@ +package ucca + +import ( + "context" + "fmt" + "strings" +) + +// multiRegMinPerRegulation is the minimum number of hits fetched per named regulation, so +// each domain is fairly represented even when topK/len(regs) would be tiny. +const multiRegMinPerRegulation = 3 + +// regulationCatalog maps a regulation to (a) the aliases that signal it is EXPLICITLY named +// in a query and (b) the regulation_code/regulation_id values used to filter the corpus. +// Deterministic + generic: a query naming >=2 regulations triggers per-regulation retrieval +// so a cross-regulation question returns every named domain — NOT a doc-specific rule. +var regulationCatalog = []struct { + Canonical string + Aliases []string + CodeValues []string +}{ + {"CRA", []string{"cra", "cyber resilience"}, []string{"CRA"}}, + {"MaschVO", []string{"maschinenverordnung", "maschvo", "machinery regulation"}, []string{"MASCHVO", "MaschVO"}}, + {"NIS2", []string{"nis2", "nis-2", "nis 2"}, []string{"NIS2"}}, + {"DORA", []string{"dora"}, []string{"DORA"}}, + {"Data Act", []string{"data act", "datengesetz"}, []string{"DATA ACT", "DataAct"}}, + {"AI Act", []string{"ai act", "ki-vo", "ki-verordnung", "ai-verordnung"}, []string{"AI ACT", "AIAct"}}, + {"DSGVO", []string{"dsgvo", "gdpr"}, []string{"DSGVO"}}, + {"TDDDG", []string{"tdddg"}, []string{"TDDDG"}}, + {"BDSG", []string{"bdsg"}, []string{"BDSG"}}, +} + +type detectedRegulation struct { + Canonical string + CodeValues []string +} + +// detectRegulations returns the DISTINCT regulations explicitly named in the query. >=2 of +// them is the trigger for multi-regulation retrieval. Pure + deterministic, no LLM. +func detectRegulations(query string) []detectedRegulation { + q := strings.ToLower(query) + var out []detectedRegulation + for _, r := range regulationCatalog { + for _, a := range r.Aliases { + if strings.Contains(q, a) { + out = append(out, detectedRegulation{Canonical: r.Canonical, CodeValues: r.CodeValues}) + break + } + } + } + return out +} + +func hitID(h qdrantSearchHit) string { return fmt.Sprintf("%v", h.ID) } + +// searchMultiRegulation retrieves each explicitly-named regulation SEPARATELY (per-regulation +// filter) and merges, so a cross-regulation query ("Wie greifen CRA und MaschVO ineinander?") +// returns BOTH domains in the prompt instead of only the keyword-dominant one. Generic over any +// named pair (DSGVO+TDDDG, CRA+NIS2, DORA+NIS2, AI Act+DSGVO, ...). The merged pool is +// authority-reranked once. Pure pool-construction; topK contract preserved. +func (c *LegalRAGClient) searchMultiRegulation(ctx context.Context, collection, query string, regs []detectedRegulation, topK int) ([]LegalSearchResult, error) { + embedding, err := c.generateEmbedding(ctx, query) + if err != nil { + return nil, fmt.Errorf("failed to generate embedding: %w", err) + } + perReg := topK / len(regs) + if perReg < multiRegMinPerRegulation { + perReg = multiRegMinPerRegulation + } + var merged []qdrantSearchHit + seen := make(map[string]bool) + for _, r := range regs { + var hits []qdrantSearchHit + if c.hybridEnabled { + if h, hErr := c.searchHybrid(ctx, collection, embedding, r.CodeValues, perReg); hErr == nil { + hits = h + } + } + if hits == nil { + if h, dErr := c.searchDense(ctx, collection, embedding, r.CodeValues, perReg); dErr == nil { + hits = h + } + } + for _, h := range hits { + id := hitID(h) + if seen[id] { + continue + } + seen[id] = true + merged = append(merged, h) + } + } + if len(merged) == 0 { + return nil, fmt.Errorf("multi-regulation search returned no hits") + } + results := hitsToResults(merged) + results = rerankByAuthority(query, results) + if topK > 0 && len(results) > topK { + results = results[:topK] + } + return results, nil +} + +// hitsToResults maps raw Qdrant hits to LegalSearchResult, preferring the normalized payload +// fields (regulation_code/article_label/...) with fallback to the legacy names (regulation_id, +// section) while the corpus is mid-re-ingestion. Shared by searchInternal + searchMultiRegulation. +func hitsToResults(hits []qdrantSearchHit) []LegalSearchResult { + results := make([]LegalSearchResult, len(hits)) + for i, hit := range hits { + regCode := getString(hit.Payload, "regulation_code") + if regCode == "" { + regCode = getString(hit.Payload, "regulation_id") + } + article := getString(hit.Payload, "article") + if article == "" { + article = getString(hit.Payload, "section") + } + results[i] = LegalSearchResult{ + Text: getString(hit.Payload, "chunk_text"), + RegulationCode: regCode, + RegulationName: getString(hit.Payload, "regulation_name_de"), + RegulationShort: getString(hit.Payload, "regulation_short"), + Category: getString(hit.Payload, "category"), + ArticleLabel: getString(hit.Payload, "article_label"), + Article: article, + Paragraph: getString(hit.Payload, "paragraph"), + Sub: getString(hit.Payload, "sub"), + IsRecital: getBool(hit.Payload, "is_recital"), + CitationStyle: getString(hit.Payload, "citation_style"), + Pages: getIntSlice(hit.Payload, "pages"), + SourceURL: getString(hit.Payload, "source"), + Score: hit.Score, + 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", + } + } + return results +} diff --git a/ai-compliance-sdk/internal/ucca/multi_regulation_test.go b/ai-compliance-sdk/internal/ucca/multi_regulation_test.go new file mode 100644 index 00000000..69d7a423 --- /dev/null +++ b/ai-compliance-sdk/internal/ucca/multi_regulation_test.go @@ -0,0 +1,92 @@ +package ucca + +import ( + "context" + "fmt" + "os" + "strings" + "testing" +) + +// TestDetectRegulations is a pure unit test of the multi-regulation TRIGGER (no Qdrant): +// only an explicit naming of >=2 regulations enables multi-regulation retrieval. A single +// named regulation, or a topical question that doesn't name one, stays single-domain. +func TestDetectRegulations(t *testing.T) { + cases := []struct { + q string + want int + }{ + {"Welche neun Kriterien nennt WP248 fuer ein voraussichtlich hohes Risiko?", 0}, + {"Welche Anforderungen gelten fuer wesentliche Veraenderungen einer Maschine?", 0}, // "Maschine" != MaschVO + {"Benoetigt eine SPS ohne Netzwerkanschluss eine CRA-Bewertung?", 1}, // 1 -> single + {"Wie greifen CRA und Maschinenverordnung bei einer vernetzten Maschine ineinander?", 2}, + {"Wie greifen DSGVO und TDDDG bei der Nutzung von Cookies ineinander?", 2}, + {"Wie verhalten sich DORA und NIS2 fuer ein Finanzunternehmen?", 2}, + {"Wie greifen AI Act und DSGVO bei einem KI-System ineinander?", 2}, + } + for _, c := range cases { + if got := len(detectRegulations(c.q)); got != c.want { + t.Errorf("detectRegulations(%q) = %d, want %d", c.q, got, c.want) + } + } +} + +// TestMultiRegE2E (RUN_E2E=1) verifies against the build collection that an explicit +// cross-regulation query returns BOTH named domains in the top-K — the core acceptance +// gate for multi-regulation retrieval. +func TestMultiRegE2E(t *testing.T) { + if os.Getenv("RUN_E2E") != "1" { + t.Skip("set RUN_E2E=1 + QDRANT_URL/OLLAMA_URL") + } + c := NewLegalRAGClient() + coll := os.Getenv("E2E_COLLECTION") + if coll == "" { + coll = "bp_compliance_kb_2026_1_build" + } + cases := []struct { + id string + q string + want []string + }{ + {"GQ-0070 CRA+MaschVO", "Wie greifen CRA und Maschinenverordnung bei einer vernetzten Maschine ineinander?", []string{"CRA", "MASCH"}}, + {"DSGVO+TDDDG", "Wie greifen DSGVO und TDDDG bei der Nutzung von Cookies und Tracking-Technologien ineinander?", []string{"DSGVO", "TDDDG"}}, + {"CRA+NIS2", "Wie verhalten sich CRA und NIS2 bei einem vernetzten Produkt eines wichtigen Unternehmens zueinander?", []string{"CRA", "NIS2"}}, + {"DORA+NIS2", "Wie greifen DORA und NIS2 bei einem Finanzunternehmen ineinander?", []string{"DORA", "NIS2"}}, + {"AI Act+DSGVO", "Wie greifen AI Act und DSGVO bei einem KI-System ineinander, das personenbezogene Daten verarbeitet?", []string{"AI ACT", "DSGVO"}}, + } + for _, tc := range cases { + res, err := c.SearchCollection(context.Background(), coll, tc.q, nil, 8) + if err != nil { + t.Fatalf("%s: %v", tc.id, err) + } + present := map[string]bool{} + for _, r := range res { + present[strings.ToUpper(r.RegulationCode)] = true + } + ok := true + for _, w := range tc.want { + found := false + for cd := range present { + if strings.Contains(cd, w) { + found = true + break + } + } + if !found { + ok = false + } + } + codes := make([]string, 0, len(present)) + for cd := range present { + codes = append(codes, cd) + } + status := "OK" + if !ok { + status = "FAIL" + } + fmt.Printf("%-22s want=%v present=%v %s\n", tc.id, tc.want, codes, status) + if !ok { + t.Errorf("%s: not all named regulations in top-8 (want %v, got %v)", tc.id, tc.want, codes) + } + } +}