Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f0120b237e | |||
| 1d65d99d5f | |||
| f2d445b891 | |||
| 08086ee75f | |||
| 1e5aaf7103 | |||
| af11d21f6e | |||
| e2c74fd243 | |||
| 8ed99c255d | |||
| 3389fa3e7a | |||
| 79abf23ea8 | |||
| d5925e57af | |||
| 1877829b1d | |||
| 866889b453 | |||
| 9760dca443 | |||
| e5e7b825af | |||
| 4818fc51c2 |
@@ -51,8 +51,8 @@ describe('advisor-rag', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('queryAdvisorRAG', () => {
|
||||
it('fragt alle 6 Collections ab und formatiert die Treffer', async () => {
|
||||
describe('queryAdvisorRAG (Authority Router)', () => {
|
||||
it('ruft den Router EINMAL auf und formatiert die Treffer', async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ results: [{ text: 'Inhalt A', regulation_short: 'DSGVO', score: 0.9 }] }),
|
||||
@@ -60,19 +60,19 @@ describe('advisor-rag', () => {
|
||||
const result = await mod.queryAdvisorRAG('Was ist eine DSFA?')
|
||||
expect(result).toContain('[Quelle 1: DSGVO]')
|
||||
expect(result).toContain('Inhalt A')
|
||||
expect(mockFetch).toHaveBeenCalledTimes(mod.COMPLIANCE_COLLECTIONS.length)
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('ruft die ai-sdk /sdk/v1/rag/search mit collection + top_k auf', async () => {
|
||||
it('ruft /sdk/v1/rag/retrieve mit query + top_k (ohne collection) auf', async () => {
|
||||
mockFetch.mockResolvedValue({ ok: true, json: async () => ({ results: [] }) })
|
||||
await mod.queryAdvisorRAG('test')
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/sdk/v1/rag/search'),
|
||||
expect.stringContaining('/sdk/v1/rag/retrieve'),
|
||||
expect.objectContaining({ method: 'POST' }),
|
||||
)
|
||||
const body = JSON.parse(mockFetch.mock.calls[0][1].body)
|
||||
expect(body).toMatchObject({ query: 'test', top_k: 3 })
|
||||
expect(mod.COMPLIANCE_COLLECTIONS).toContain(body.collection)
|
||||
expect(body).toMatchObject({ query: 'test', top_k: 8 })
|
||||
expect(body.collection).toBeUndefined()
|
||||
})
|
||||
|
||||
it('liefert leeren String wenn das RAG-Backend nicht erreichbar ist (graceful)', async () => {
|
||||
@@ -80,10 +80,5 @@ describe('advisor-rag', () => {
|
||||
const result = await mod.queryAdvisorRAG('test')
|
||||
expect(result).toBe('')
|
||||
})
|
||||
|
||||
it('umfasst genau die 6 Compliance-Collections', () => {
|
||||
expect(mod.COMPLIANCE_COLLECTIONS).toHaveLength(6)
|
||||
expect(mod.COMPLIANCE_COLLECTIONS).toContain('bp_compliance_recht')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
/**
|
||||
* Compliance-Advisor RAG-Suche.
|
||||
*
|
||||
* Fragt die ai-compliance-sdk (`/sdk/v1/rag/search`) ab statt des frueheren
|
||||
* `rag-service:8097` (auf prod nicht erreichbar). Die ai-sdk embeddet die Query
|
||||
* mit bge-m3 (prod: ollama-embed) und sucht in den Qdrant-Compliance-Collections
|
||||
* — damit profitiert der Advisor vom reicheren Embedding.
|
||||
* Fragt den Authority Router der ai-compliance-sdk (`/sdk/v1/rag/retrieve`) mit NUR der
|
||||
* Query ab — der Router waehlt selbst die Collections (Broad-Authority-Base + KB-2026.1-Slice
|
||||
* bei in-scope), embeddet mit bge-m3 (prod: ollama-embed), merged + authority-ranked. Der
|
||||
* Advisor bleibt damit collection-agnostisch (Vertrag: Compiler -> Collections -> Retriever
|
||||
* -> Advisor); die fruehere Multi-Collection-Logik liegt jetzt im Retriever.
|
||||
*
|
||||
* Fehler je Collection werden geschluckt (graceful: Antwort ohne diesen Treffer).
|
||||
* Fehler werden geschluckt (graceful: Antwort ohne RAG-Kontext).
|
||||
* Fundstellen via article_label sind live ab dem Prod-Re-Ingest 2026-06.
|
||||
*/
|
||||
|
||||
@@ -17,16 +18,6 @@ const DEFAULT_USER = '00000000-0000-0000-0000-000000000001'
|
||||
const DEFAULT_TENANT =
|
||||
process.env.DEFAULT_TENANT_ID || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||
|
||||
// Compliance-relevante Collections (ai-sdk-Whitelist `AllowedCollections`).
|
||||
export const COMPLIANCE_COLLECTIONS = [
|
||||
'bp_compliance_gesetze',
|
||||
'bp_compliance_ce',
|
||||
'bp_compliance_datenschutz',
|
||||
'bp_dsfa_corpus',
|
||||
'bp_compliance_recht',
|
||||
'bp_legal_templates',
|
||||
] as const
|
||||
|
||||
interface SdkRagResult {
|
||||
text?: string
|
||||
regulation_code?: string
|
||||
@@ -68,39 +59,36 @@ export function mapSdkResults(results: SdkRagResult[] | undefined): ScoredPassag
|
||||
.filter((p) => p.content)
|
||||
}
|
||||
|
||||
async function searchCollection(collection: string, query: string): Promise<ScoredPassage[]> {
|
||||
/**
|
||||
* Authority Router: EIN collection-agnostischer Aufruf an die ai-sdk (`/sdk/v1/rag/retrieve`).
|
||||
* Der Router waehlt die Collections (Broad-Authority-Base + KB-2026.1-Slice bei in-scope),
|
||||
* merged + authority-ranked sie und liefert die Top-Passagen. Der Advisor weiss damit nichts
|
||||
* mehr ueber einzelne Collections — die fruehere Multi-Collection-Logik liegt jetzt im Retriever.
|
||||
* Fehler werden geschluckt (graceful: Antwort ohne RAG-Kontext).
|
||||
*/
|
||||
export async function queryAdvisorRAG(query: string): Promise<string> {
|
||||
let passages: ScoredPassage[] = []
|
||||
try {
|
||||
const res = await fetch(`${SDK_URL}/sdk/v1/rag/search`, {
|
||||
const res = await fetch(`${SDK_URL}/sdk/v1/rag/retrieve`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-User-ID': DEFAULT_USER,
|
||||
'X-Tenant-ID': DEFAULT_TENANT,
|
||||
},
|
||||
body: JSON.stringify({ query, collection, top_k: 3 }),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
body: JSON.stringify({ query, top_k: 8 }),
|
||||
signal: AbortSignal.timeout(15000),
|
||||
})
|
||||
if (!res.ok) return []
|
||||
const data = await res.json()
|
||||
return mapSdkResults(data.results)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
passages = mapSdkResults(data.results)
|
||||
}
|
||||
} catch {
|
||||
return []
|
||||
// graceful: keine Verbindung -> Antwort ohne RAG-Kontext
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fragt alle Compliance-Collections parallel ab und liefert die Top-8-Passagen
|
||||
* als formatierten Kontextblock (oder '' wenn nichts erreichbar/gefunden).
|
||||
*/
|
||||
export async function queryAdvisorRAG(query: string): Promise<string> {
|
||||
const settled = await Promise.all(
|
||||
COMPLIANCE_COLLECTIONS.map((c) => searchCollection(c, query)),
|
||||
)
|
||||
const all = settled.flat()
|
||||
if (all.length === 0) return ''
|
||||
all.sort((a, b) => b.score - a.score)
|
||||
return all
|
||||
.slice(0, 8)
|
||||
// Der Router liefert bereits authority-geordnete Top-K; Reihenfolge bewahren.
|
||||
if (passages.length === 0) return ''
|
||||
return passages
|
||||
.map((r, i) => `[Quelle ${i + 1}: ${r.source}]\n${r.content}`)
|
||||
.join('\n\n---\n\n')
|
||||
}
|
||||
|
||||
@@ -82,6 +82,43 @@ func (h *RAGHandlers) Search(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// RetrieveRequest is the Authority Router request: a query only, no collection — the router decides
|
||||
// which collections to query (broad authority base + the in-scope KB-2026.1 slice).
|
||||
type RetrieveRequest struct {
|
||||
Query string `json:"query" binding:"required"`
|
||||
TopK int `json:"top_k,omitempty"`
|
||||
}
|
||||
|
||||
// Retrieve is the Authority Router endpoint. The Advisor calls this with ONLY a query and stays
|
||||
// collection-agnostic; the router fans out over the authority base + the in-scope slice, merges by
|
||||
// authority score, and returns the unified top-K. Response shape matches Search (query/results/
|
||||
// count/assessment) so existing consumers parse it unchanged.
|
||||
// POST /sdk/v1/rag/retrieve
|
||||
func (h *RAGHandlers) Retrieve(c *gin.Context) {
|
||||
var req RetrieveRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if req.TopK <= 0 || req.TopK > 20 {
|
||||
req.TopK = 8
|
||||
}
|
||||
|
||||
results, err := h.ragClient.Retrieve(c.Request.Context(), req.Query, req.TopK)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "RAG retrieve failed: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"query": req.Query,
|
||||
"results": results,
|
||||
"count": len(results),
|
||||
"assessment": ucca.Assess(results),
|
||||
})
|
||||
}
|
||||
|
||||
// ListRegulations returns the list of available regulations in the corpus.
|
||||
// GET /sdk/v1/rag/regulations
|
||||
func (h *RAGHandlers) ListRegulations(c *gin.Context) {
|
||||
|
||||
@@ -159,6 +159,7 @@ func registerRAGRoutes(v1 *gin.RouterGroup, h *handlers.RAGHandlers) {
|
||||
ragRoutes := v1.Group("/rag")
|
||||
{
|
||||
ragRoutes.POST("/search", h.Search)
|
||||
ragRoutes.POST("/retrieve", h.Retrieve)
|
||||
ragRoutes.GET("/regulations", h.ListRegulations)
|
||||
ragRoutes.GET("/corpus-status", h.CorpusStatus)
|
||||
ragRoutes.GET("/corpus-versions/:collection", h.CorpusVersionHistory)
|
||||
@@ -358,7 +359,6 @@ func registerWhistleblowerRoutes(v1 *gin.RouterGroup, h *handlers.WhistleblowerH
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func registerMaximizerRoutes(v1 *gin.RouterGroup, h *handlers.MaximizerHandlers) {
|
||||
m := v1.Group("/maximizer")
|
||||
{
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package iace
|
||||
|
||||
// P3: pin accepted proposer decisions into the GT gate.
|
||||
//
|
||||
// When a human accepts a proposal from the offline proposer (a dedup
|
||||
// supersession, a foreign-framing gate, a vocab→tag mapping, a coverage hazard),
|
||||
// they record an AcceptedPin. A pin is a tiny, machine-scoped invariant — "this
|
||||
// pattern MUST (or must NOT) fire for this machine" — that a test re-checks on
|
||||
// every run. This is what makes the library's growth COMPOUND into the gate
|
||||
// instead of silently eroding it: a future change that re-introduces a dropped
|
||||
// duplicate, un-gates a foreign pattern, or removes a coverage hazard breaks the
|
||||
// pin and fails CI.
|
||||
//
|
||||
// A single boolean covers all four proposal types:
|
||||
// - dedup supersession accepted → DropPattern MustFire=false
|
||||
// - foreign-framing gate accepted → foreign pattern MustFire=false
|
||||
// - vocab→tag / coverage hazard accepted → the enabled pattern MustFire=true
|
||||
|
||||
// AcceptedPin is one regression invariant for an accepted proposal.
|
||||
type AcceptedPin struct {
|
||||
Pattern string `json:"pattern"`
|
||||
MustFire bool `json:"must_fire"`
|
||||
Reason string `json:"reason"`
|
||||
FromProposal string `json:"from_proposal,omitempty"`
|
||||
}
|
||||
|
||||
// PinSet is the accepted-pin registry for one machine (testdata/accepted_pins_*.json).
|
||||
type PinSet struct {
|
||||
Machine string `json:"machine"`
|
||||
Pins []AcceptedPin `json:"pins"`
|
||||
}
|
||||
|
||||
// PinResult is the verdict for one pin against an engine run.
|
||||
type PinResult struct {
|
||||
Pin AcceptedPin
|
||||
OK bool
|
||||
Detail string
|
||||
}
|
||||
|
||||
// VerifyPins checks every pin against the set of pattern IDs the engine actually
|
||||
// fired for the machine. A pin holds iff the pattern's presence equals MustFire.
|
||||
func VerifyPins(pins []AcceptedPin, firedPatternIDs []string) []PinResult {
|
||||
fired := make(map[string]bool, len(firedPatternIDs))
|
||||
for _, id := range firedPatternIDs {
|
||||
fired[id] = true
|
||||
}
|
||||
out := make([]PinResult, 0, len(pins))
|
||||
for _, p := range pins {
|
||||
got := fired[p.Pattern]
|
||||
ok := got == p.MustFire
|
||||
detail := "ok"
|
||||
if !ok {
|
||||
if p.MustFire {
|
||||
detail = "expected to fire but did NOT — coverage/mapping regressed"
|
||||
} else {
|
||||
detail = "expected to be suppressed but FIRED — gate/supersession regressed"
|
||||
}
|
||||
}
|
||||
out = append(out, PinResult{Pin: p, OK: ok, Detail: detail})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// GenerateDedupPin turns an accepted (verdict=duplicate) dedup candidate into the
|
||||
// pin that protects the supersession: the dropped pattern must no longer fire.
|
||||
func GenerateDedupPin(c DedupCandidate) AcceptedPin {
|
||||
return AcceptedPin{
|
||||
Pattern: c.DropPattern,
|
||||
MustFire: false,
|
||||
Reason: "accepted duplicate of " + c.KeepPattern + " (" + c.Category + ")",
|
||||
FromProposal: "dedup " + c.DropPattern + " -> " + c.KeepPattern,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package iace
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestVerifyPins(t *testing.T) {
|
||||
pins := []AcceptedPin{
|
||||
{Pattern: "HPa", MustFire: true},
|
||||
{Pattern: "HPb", MustFire: false},
|
||||
}
|
||||
res := VerifyPins(pins, []string{"HPa", "HPb"})
|
||||
if !res[0].OK {
|
||||
t.Errorf("HPa must_fire=true and it fired -> should be OK")
|
||||
}
|
||||
if res[1].OK {
|
||||
t.Errorf("HPb must_fire=false but it fired -> should be VIOLATED")
|
||||
}
|
||||
res2 := VerifyPins(pins, []string{})
|
||||
if res2[0].OK || !res2[1].OK {
|
||||
t.Errorf("expected HPa violated + HPb ok, got %+v", res2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateDedupPin(t *testing.T) {
|
||||
pin := GenerateDedupPin(DedupCandidate{KeepPattern: "HP144", DropPattern: "HP013", Category: "electrical_hazard"})
|
||||
if pin.Pattern != "HP013" || pin.MustFire {
|
||||
t.Fatalf("want pin {HP013, must_fire=false}, got %+v", pin)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWarewashing_AcceptedPins re-checks every accepted P1 supersession against the
|
||||
// live warewashing engine output. A future change that un-suppresses HP013/016/018
|
||||
// or drops HP2201/HP144 breaks a pin here — the gate compounds, not erodes.
|
||||
func TestWarewashing_AcceptedPins(t *testing.T) {
|
||||
raw, err := os.ReadFile(filepath.Join("testdata", "accepted_pins_warewashing.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("read pins: %v", err)
|
||||
}
|
||||
var ps PinSet
|
||||
if err := json.Unmarshal(raw, &ps); err != nil {
|
||||
t.Fatalf("parse pins: %v", err)
|
||||
}
|
||||
|
||||
_, _, kept := warewashingEngineOutput()
|
||||
firedIDs := make([]string, 0, len(kept))
|
||||
for _, pm := range kept {
|
||||
firedIDs = append(firedIDs, pm.PatternID)
|
||||
}
|
||||
|
||||
ok := 0
|
||||
for _, r := range VerifyPins(ps.Pins, firedIDs) {
|
||||
if r.OK {
|
||||
ok++
|
||||
continue
|
||||
}
|
||||
t.Errorf("PIN VIOLATED: %s (must_fire=%v) — %s [%s]", r.Pin.Pattern, r.Pin.MustFire, r.Detail, r.Pin.Reason)
|
||||
}
|
||||
t.Logf("accepted pins for %q: %d/%d hold", ps.Machine, ok, len(ps.Pins))
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"machine": "Gewerbliche Untertisch-Geschirrspuelmaschine (vernetzt)",
|
||||
"pins": [
|
||||
{"pattern": "HP016", "must_fire": false, "reason": "generic hot-surface (Formwerkzeuge/Auspuffleitung framing) superseded by HP2201", "from_proposal": "P1 thermal supersession"},
|
||||
{"pattern": "HP018", "must_fire": false, "reason": "actuator-burn superseded by HP2201", "from_proposal": "P1 thermal supersession"},
|
||||
{"pattern": "HP013", "must_fire": false, "reason": "stored-energy Batterie/USV framing superseded by HP144", "from_proposal": "P1 stored-energy supersession"},
|
||||
{"pattern": "HP2201", "must_fire": true, "reason": "warewashing hot-surface (Boiler/Tank/Spuelkammer) must remain — it is the clean equivalent that replaces HP016/HP018", "from_proposal": "P1 thermal supersession"},
|
||||
{"pattern": "HP144", "must_fire": true, "reason": "residual-voltage (Frequenzumrichter/Zwischenkreis) must remain — clean equivalent that replaces HP013", "from_proposal": "P1 stored-energy supersession"}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
package ucca
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// routerBaseCollections is the broad authority base the Authority Router fans out over. It mirrors
|
||||
// the Advisor's historical multi-collection set; the KB-2026.1 slice is added separately when the
|
||||
// query is in scope. Override via RAG_ROUTER_COLLECTIONS (comma-separated) per environment.
|
||||
func (c *LegalRAGClient) routerBaseCollections() []string {
|
||||
if v := strings.TrimSpace(os.Getenv("RAG_ROUTER_COLLECTIONS")); v != "" {
|
||||
var out []string
|
||||
for _, p := range strings.Split(v, ",") {
|
||||
if s := strings.TrimSpace(p); s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
if len(out) > 0 {
|
||||
return out
|
||||
}
|
||||
}
|
||||
return []string{
|
||||
"bp_compliance_gesetze",
|
||||
"bp_compliance_ce",
|
||||
"bp_compliance_datenschutz",
|
||||
"bp_dsfa_corpus",
|
||||
"bp_compliance_recht",
|
||||
"bp_legal_templates",
|
||||
}
|
||||
}
|
||||
|
||||
const routerPerCollectionTopK = 3
|
||||
|
||||
// Retrieve is the Authority Router entry point: callers (the Advisor) pass ONLY a query and stay
|
||||
// collection-agnostic. The router fans out over the broad authority base and ADDS the KB-2026.1
|
||||
// slice when the query is in scope (inKBScope), then merges all hits, deduplicates, and returns the
|
||||
// top-K by authority score. This moves the former Advisor-side collection fan-out into the retrieval
|
||||
// layer (the "Retriever" tier of the quality pyramid), so the proven KB-2026.1 slice gain reaches
|
||||
// the product path without the Advisor knowing about individual collections.
|
||||
//
|
||||
// The merged set is ordered by the per-collection authority score that rerankByAuthority already
|
||||
// produced inside searchInternal — i.e. binding-vs-guidance ordering is preserved across the merge.
|
||||
// Per-collection failures (e.g. a collection absent on an environment) degrade gracefully.
|
||||
func (c *LegalRAGClient) Retrieve(ctx context.Context, query string, topK int) ([]LegalSearchResult, error) {
|
||||
if topK <= 0 {
|
||||
topK = 8
|
||||
}
|
||||
|
||||
collections := c.routerBaseCollections()
|
||||
if c.kbScopeRoutingEnabled && c.kbSliceCollection != "" && inKBScope(query) {
|
||||
collections = append(collections, c.kbSliceCollection)
|
||||
}
|
||||
|
||||
// Cross-regulation queries (>=2 explicitly named regulations) get a larger per-collection budget
|
||||
// so each collection's multi-regulation search isn't truncated down to the keyword-dominant
|
||||
// domain; the final per-regulation balancing then guarantees every named domain in the top-K.
|
||||
regs := detectRegulations(query)
|
||||
perColl := routerPerCollectionTopK
|
||||
if len(regs) >= 2 {
|
||||
perColl = routerPerCollectionTopK * len(regs)
|
||||
}
|
||||
|
||||
// Warm the full-text indexes sequentially first so the concurrent fan-out below only READS the
|
||||
// shared textIndexEnsured map (the writes happen here, serialized) — closes the cold-start map
|
||||
// race deterministically. Best-effort: a missing collection just stays un-indexed (hybrid then
|
||||
// falls back to dense, or the per-collection search degrades to nothing).
|
||||
if c.hybridEnabled {
|
||||
for _, coll := range collections {
|
||||
_ = c.ensureTextIndex(ctx, coll)
|
||||
}
|
||||
}
|
||||
|
||||
out := make([][]LegalSearchResult, len(collections))
|
||||
var wg sync.WaitGroup
|
||||
for i, coll := range collections {
|
||||
wg.Add(1)
|
||||
go func(i int, coll string) {
|
||||
defer wg.Done()
|
||||
if res, err := c.searchInternal(ctx, coll, query, nil, perColl); err == nil {
|
||||
out[i] = res
|
||||
}
|
||||
}(i, coll)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
merged := make([]LegalSearchResult, 0, len(collections)*perColl)
|
||||
for _, r := range out {
|
||||
merged = append(merged, r...)
|
||||
}
|
||||
merged = dedupResults(merged)
|
||||
sort.SliceStable(merged, func(a, b int) bool { return merged[a].Score > merged[b].Score })
|
||||
|
||||
// Cross-regulation: guarantee every named domain is represented (0070-class fix) instead of
|
||||
// letting a global score-sort starve the non-dominant domain.
|
||||
if len(regs) >= 2 {
|
||||
return balanceByRegulation(merged, regs, topK), nil
|
||||
}
|
||||
if len(merged) > topK {
|
||||
merged = merged[:topK]
|
||||
}
|
||||
return merged, nil
|
||||
}
|
||||
|
||||
// dedupResults removes duplicate passages that can appear when collections overlap, keeping the
|
||||
// highest-scoring occurrence. Identity = regulation_code + article_label + a text prefix.
|
||||
func dedupResults(in []LegalSearchResult) []LegalSearchResult {
|
||||
pos := make(map[string]int, len(in))
|
||||
out := make([]LegalSearchResult, 0, len(in))
|
||||
for _, r := range in {
|
||||
text := r.Text
|
||||
if len(text) > 80 {
|
||||
text = text[:80]
|
||||
}
|
||||
key := r.RegulationCode + "|" + r.ArticleLabel + "|" + text
|
||||
if idx, ok := pos[key]; ok {
|
||||
if r.Score > out[idx].Score {
|
||||
out[idx] = r
|
||||
}
|
||||
continue
|
||||
}
|
||||
pos[key] = len(out)
|
||||
out = append(out, r)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package ucca
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type benchQ struct {
|
||||
ID string `json:"id"`
|
||||
Document string `json:"document"`
|
||||
Question string `json:"question"`
|
||||
}
|
||||
|
||||
// docTokens maps a bench question's expected document to acceptable regulation_code/label substrings.
|
||||
func docTokens(document string) []string {
|
||||
d := strings.ToUpper(document)
|
||||
var t []string
|
||||
for _, wp := range []string{"WP243", "WP248", "WP260"} {
|
||||
if strings.Contains(d, wp) {
|
||||
t = append(t, wp)
|
||||
}
|
||||
}
|
||||
dns := strings.ReplaceAll(d, " ", "")
|
||||
for _, gl := range []struct{ key, tok string }{{"07/2020", "GL07"}, {"05/2020", "GL05"}, {"09/2022", "GL09"}} {
|
||||
if strings.Contains(dns, gl.key) {
|
||||
t = append(t, gl.tok)
|
||||
}
|
||||
}
|
||||
if strings.Contains(d, "TDDDG") {
|
||||
t = append(t, "TDDDG")
|
||||
}
|
||||
if strings.Contains(d, "DSGVO") || strings.Contains(d, "ART. 13") || strings.Contains(d, "ART. 14") {
|
||||
t = append(t, "DSGVO")
|
||||
}
|
||||
if strings.Contains(d, "BDSG") {
|
||||
t = append(t, "BDSG")
|
||||
}
|
||||
if strings.Contains(d, "CRA") {
|
||||
t = append(t, "CRA")
|
||||
}
|
||||
if strings.Contains(d, "MASCH") {
|
||||
t = append(t, "MASCH", "MACHINERY", "MVO")
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func hitDoc(results []LegalSearchResult, toks []string) bool {
|
||||
for _, r := range results {
|
||||
s := strings.ReplaceAll(strings.ToUpper(r.RegulationCode+" "+r.ArticleLabel), " ", "")
|
||||
for _, tk := range toks {
|
||||
if strings.Contains(s, strings.ReplaceAll(tk, " ", "")) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// TestMultiReg0070E2E (RUN_E2E=1) is the 0070 regression: a cross-regulation query (CRA + MaschVO)
|
||||
// must return BOTH domains through the real Retrieve(), not just the keyword-dominant CRA.
|
||||
func TestMultiReg0070E2E(t *testing.T) {
|
||||
if os.Getenv("RUN_E2E") != "1" {
|
||||
t.Skip("set RUN_E2E=1 + QDRANT_URL/OLLAMA_URL/QDRANT_API_KEY")
|
||||
}
|
||||
c := NewLegalRAGClient()
|
||||
q := "Wie greifen CRA und Maschinenverordnung bei einer vernetzten Maschine ineinander?"
|
||||
res, err := c.Retrieve(context.Background(), q, 8)
|
||||
if err != nil {
|
||||
t.Fatalf("retrieve: %v", err)
|
||||
}
|
||||
var hasCRA, hasMasch bool
|
||||
var codes []string
|
||||
for _, r := range res {
|
||||
u := strings.ToUpper(r.RegulationCode)
|
||||
codes = append(codes, u)
|
||||
if strings.Contains(u, "CRA") {
|
||||
hasCRA = true
|
||||
}
|
||||
if strings.Contains(u, "MASCH") || strings.Contains(u, "MACHIN") || u == "MVO" {
|
||||
hasMasch = true
|
||||
}
|
||||
}
|
||||
t.Logf("0070 top-8 codes: %v", codes)
|
||||
if !hasCRA || !hasMasch {
|
||||
t.Errorf("0070 must return BOTH domains via Retrieve(): CRA=%v MaschVO=%v", hasCRA, hasMasch)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthorityRouterCB100 (RUN_E2E=1) drives the REAL Retrieve() over the ComplianceBench-100 against
|
||||
// the live collections: NEW (scope routing on → slice added for in-scope queries) vs OLD (routing off
|
||||
// → broad base only). It is the regression gate that the router actually delivers the proven slice
|
||||
// gain (+28/0-regr in the offline simulation) through the production Go code path.
|
||||
func TestAuthorityRouterCB100(t *testing.T) {
|
||||
if os.Getenv("RUN_E2E") != "1" {
|
||||
t.Skip("set RUN_E2E=1 + QDRANT_URL/OLLAMA_URL/QDRANT_API_KEY + BENCH_PATH")
|
||||
}
|
||||
path := os.Getenv("BENCH_PATH")
|
||||
if path == "" {
|
||||
path = "/tmp/compliance_bench.json"
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("bench read: %v", err)
|
||||
}
|
||||
var doc struct {
|
||||
Questions []benchQ `json:"questions"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &doc); err != nil {
|
||||
t.Fatalf("bench parse: %v", err)
|
||||
}
|
||||
|
||||
// BENCH_STRIDE samples every Kth question (stratified across DS/CRA/MaschVO) so the gate stays
|
||||
// tractable against the remote dev Qdrant; default 1 = full CB-100.
|
||||
stride := 1
|
||||
if s := os.Getenv("BENCH_STRIDE"); s != "" {
|
||||
if n, err := strconv.Atoi(s); err == nil && n > 0 {
|
||||
stride = n
|
||||
}
|
||||
}
|
||||
|
||||
c := NewLegalRAGClient()
|
||||
ctx := context.Background()
|
||||
var n, oldHit, newHit, gain, regr int
|
||||
for i, q := range doc.Questions {
|
||||
if i%stride != 0 {
|
||||
continue
|
||||
}
|
||||
n++
|
||||
toks := docTokens(q.Document)
|
||||
c.kbScopeRoutingEnabled = false
|
||||
oldRes, _ := c.Retrieve(ctx, q.Question, 8)
|
||||
c.kbScopeRoutingEnabled = true
|
||||
newRes, _ := c.Retrieve(ctx, q.Question, 8)
|
||||
oh, nh := hitDoc(oldRes, toks), hitDoc(newRes, toks)
|
||||
if oh {
|
||||
oldHit++
|
||||
}
|
||||
if nh {
|
||||
newHit++
|
||||
}
|
||||
flip := "="
|
||||
switch {
|
||||
case !oh && nh:
|
||||
gain++
|
||||
flip = "GAIN"
|
||||
case oh && !nh:
|
||||
regr++
|
||||
flip = "REGR"
|
||||
}
|
||||
t.Logf("%-9s [%-14s] OLD=%-5v NEW=%-5v %s", q.ID, q.Document, oh, nh, flip)
|
||||
}
|
||||
t.Logf("CB-100 sample (stride=%d) via Retrieve(): N=%d | OLD-hit %d | NEW-hit %d | GAIN %d | REGR %d",
|
||||
stride, n, oldHit, newHit, gain, regr)
|
||||
if newHit <= oldHit || gain < 3 {
|
||||
t.Errorf("router must add slice gains: NEW(%d) must exceed OLD(%d), gain=%d", newHit, oldHit, gain)
|
||||
}
|
||||
if regr > 2 {
|
||||
t.Errorf("too many regressions through the router: %d", regr)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package ucca
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRouterBaseCollections(t *testing.T) {
|
||||
c := &LegalRAGClient{}
|
||||
os.Unsetenv("RAG_ROUTER_COLLECTIONS")
|
||||
def := c.routerBaseCollections()
|
||||
if len(def) != 6 || def[1] != "bp_compliance_ce" {
|
||||
t.Fatalf("default base collections unexpected: %v", def)
|
||||
}
|
||||
|
||||
os.Setenv("RAG_ROUTER_COLLECTIONS", " bp_compliance_ce , kb_2026_1_build ,, ")
|
||||
defer os.Unsetenv("RAG_ROUTER_COLLECTIONS")
|
||||
got := c.routerBaseCollections()
|
||||
if len(got) != 2 || got[0] != "bp_compliance_ce" || got[1] != "kb_2026_1_build" {
|
||||
t.Fatalf("env override parse failed (trim/empty): %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouterSliceSelection(t *testing.T) {
|
||||
// The router appends the slice exactly when the query is in scope (inKBScope) and routing is on.
|
||||
// Mirror the selection logic so a regression in either is caught without a live Qdrant.
|
||||
c := &LegalRAGClient{kbSliceCollection: "kb_2026_1_build", kbScopeRoutingEnabled: true}
|
||||
sel := func(q string) bool {
|
||||
colls := c.routerBaseCollections()
|
||||
if c.kbScopeRoutingEnabled && c.kbSliceCollection != "" && inKBScope(q) {
|
||||
colls = append(colls, c.kbSliceCollection)
|
||||
}
|
||||
for _, x := range colls {
|
||||
if x == c.kbSliceCollection {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
if !sel("Welche neun Kriterien nennt WP248 fuer ein hohes Risiko?") {
|
||||
t.Error("in-scope guidance query must include the slice")
|
||||
}
|
||||
if sel("Was sagt NIST SP 800-53 zu Access Control?") {
|
||||
t.Error("out-of-scope query must NOT include the slice")
|
||||
}
|
||||
c.kbScopeRoutingEnabled = false
|
||||
if sel("Welche Kriterien nennt WP248?") {
|
||||
t.Error("routing disabled => slice never included")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBalanceByRegulation(t *testing.T) {
|
||||
regs := []detectedRegulation{
|
||||
{Canonical: "CRA", CodeValues: []string{"CRA"}},
|
||||
{Canonical: "MaschVO", CodeValues: []string{"MASCHVO", "MVO", "MACHINERY"}},
|
||||
}
|
||||
// CRA dominates by score; without balancing the top-4 would be all CRA + NIST.
|
||||
pool := []LegalSearchResult{
|
||||
{RegulationCode: "CRA", Score: 0.99},
|
||||
{RegulationCode: "CRA", Score: 0.98},
|
||||
{RegulationCode: "CRA", Score: 0.97},
|
||||
{RegulationCode: "NIST", Score: 0.96},
|
||||
{RegulationCode: "MACHINERY", Score: 0.70},
|
||||
{RegulationCode: "MVO", Score: 0.65},
|
||||
}
|
||||
out := balanceByRegulation(pool, regs, 4)
|
||||
var hasCRA, hasMasch bool
|
||||
for _, r := range out {
|
||||
switch r.RegulationCode {
|
||||
case "CRA":
|
||||
hasCRA = true
|
||||
case "MACHINERY", "MVO":
|
||||
hasMasch = true
|
||||
}
|
||||
}
|
||||
if !hasCRA || !hasMasch {
|
||||
t.Errorf("both named domains must be represented: CRA=%v MaschVO=%v out=%v", hasCRA, hasMasch, out)
|
||||
}
|
||||
if out[0].RegulationCode != "CRA" || !(out[1].RegulationCode == "MACHINERY" || out[1].RegulationCode == "MVO") {
|
||||
t.Errorf("round-robin should alternate domains, got %s then %s", out[0].RegulationCode, out[1].RegulationCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDedupResults(t *testing.T) {
|
||||
in := []LegalSearchResult{
|
||||
{RegulationCode: "EDPB WP248", ArticleLabel: "III.B", Text: "lorem", Score: 0.7},
|
||||
{RegulationCode: "EDPB WP248", ArticleLabel: "III.B", Text: "lorem", Score: 0.9}, // dup, higher score
|
||||
{RegulationCode: "DSGVO", ArticleLabel: "Art. 35", Text: "ipsum", Score: 0.8},
|
||||
}
|
||||
out := dedupResults(in)
|
||||
if len(out) != 2 {
|
||||
t.Fatalf("expected 2 deduped, got %d", len(out))
|
||||
}
|
||||
for _, r := range out {
|
||||
if r.RegulationCode == "EDPB WP248" && r.Score != 0.9 {
|
||||
t.Errorf("dedup must keep highest score, got %v", r.Score)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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, ";"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package ucca
|
||||
|
||||
import "strings"
|
||||
|
||||
// kbScopeTopics are high-precision data-protection / compliance topic markers that place a query in
|
||||
// the KB-2026.1 authoritative slice even when it does NOT name a regulation. Conservative by design:
|
||||
// an unmatched query falls back to the broad CE default (no regression) — the slice is only used when
|
||||
// the query is confidently in-scope.
|
||||
var kbScopeTopics = []string{
|
||||
// DP-Guidance-Marker, die IN der Slice liegen (EDPB/DSK/WP/GL) — bewusst NICHT die generischen
|
||||
// Verben aus guidanceIntentSignals (sagt/laut/empfiehlt/auslegung) und NICHT enisa/bsi/nist/owasp
|
||||
// (die liegen im breiten CE-Pool, nicht in der Slice).
|
||||
"edpb", "dsk", "datenschutzausschuss", "orientierungshilfe",
|
||||
"wp2", "wp 2", "wp29", "working paper", "gl 0",
|
||||
"datenschutz", "dsgvo", "gdpr", "dsfa", "folgenabschätzung", "folgenabschaetzung",
|
||||
"einwilligung", "auftragsverarbeit", "betroffenenrecht", "auskunftsrecht",
|
||||
"verarbeitungsverzeichnis", "datenschutzbeauftragt", "verzeichnis von verarbeitung",
|
||||
"cookie", "tracking", "transparenzpflicht", "datenpanne", "meldepflicht",
|
||||
"technische und organisatorische maßnahmen",
|
||||
"cyber resilience", "schwachstelle", "vulnerability", "sicherheitsupdate",
|
||||
"maschinensicherheit", "wesentliche veränderung", "wesentliche veraenderung",
|
||||
"konformitätsbewertung", "konformitaetsbewertung", "ce-kennzeichnung",
|
||||
}
|
||||
|
||||
// inKBScope reports whether the query belongs to the KB-2026.1 authoritative slice. True when it
|
||||
// names an in-slice regulation (detectRegulations), asks for guidance (EDPB/DSK/WP/GL), or hits a
|
||||
// data-protection / compliance topic marker.
|
||||
func inKBScope(query string) bool {
|
||||
if len(detectRegulations(query)) > 0 {
|
||||
return true
|
||||
}
|
||||
q := strings.ToLower(query)
|
||||
for _, t := range kbScopeTopics {
|
||||
if strings.Contains(q, t) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// resolveCollection applies the Blue-Green „authoritative slice promotion" routing. An explicitly
|
||||
// requested collection is honoured unchanged; the DEFAULT (empty) request is routed to the KB-2026.1
|
||||
// slice when the query is in-scope, else to the broad CE default. Disable via RAG_KB_SCOPE_ROUTING=false.
|
||||
func (c *LegalRAGClient) resolveCollection(query, requested string) string {
|
||||
if requested != "" {
|
||||
return requested
|
||||
}
|
||||
if c.kbScopeRoutingEnabled && c.kbSliceCollection != "" && inKBScope(query) {
|
||||
return c.kbSliceCollection
|
||||
}
|
||||
return c.collection
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package ucca
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestInKBScope(t *testing.T) {
|
||||
inScope := []string{
|
||||
"Welche neun Kriterien nennt WP248 fuer ein hohes Risiko?",
|
||||
"Wie greifen CRA und Maschinenverordnung bei einer vernetzten Maschine ineinander?",
|
||||
"Wann ist eine Datenschutz-Folgenabschaetzung erforderlich?",
|
||||
"Welche Anforderungen stellt die DSGVO an die Einwilligung?",
|
||||
"Brauche ich einen Datenschutzbeauftragten?",
|
||||
"Wann muss eine aktiv ausgenutzte Schwachstelle gemeldet werden?",
|
||||
}
|
||||
outScope := []string{
|
||||
"Welche OWASP-Kontrollen gibt es fuer Authentifizierung?",
|
||||
"Was sagt NIST SP 800-53 zu Access Control?",
|
||||
"Wie funktioniert ISO 27001 Zertifizierung?",
|
||||
"Welche IFRS-Standards gelten fuer Leasing?",
|
||||
}
|
||||
for _, q := range inScope {
|
||||
if !inKBScope(q) {
|
||||
t.Errorf("inKBScope(%q) = false, want true", q)
|
||||
}
|
||||
}
|
||||
for _, q := range outScope {
|
||||
if inKBScope(q) {
|
||||
t.Errorf("inKBScope(%q) = true, want false", q)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCollection(t *testing.T) {
|
||||
c := &LegalRAGClient{collection: "bp_compliance_ce", kbSliceCollection: "kb_2026_1_build", kbScopeRoutingEnabled: true}
|
||||
if got := c.resolveCollection("Welche Kriterien nennt WP248?", ""); got != "kb_2026_1_build" {
|
||||
t.Errorf("in-scope default -> %s, want kb_2026_1_build", got)
|
||||
}
|
||||
if got := c.resolveCollection("Was sagt NIST SP 800-53?", ""); got != "bp_compliance_ce" {
|
||||
t.Errorf("out-of-scope default -> %s, want bp_compliance_ce", got)
|
||||
}
|
||||
if got := c.resolveCollection("Welche Kriterien nennt WP248?", "explicit_coll"); got != "explicit_coll" {
|
||||
t.Errorf("explicit request must be honoured -> %s", got)
|
||||
}
|
||||
c.kbScopeRoutingEnabled = false
|
||||
if got := c.resolveCollection("Welche Kriterien nennt WP248?", ""); got != "bp_compliance_ce" {
|
||||
t.Errorf("disabled routing -> %s, want bp_compliance_ce", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestKBScopeRoutingE2E (RUN_E2E=1) verifies the routing against the REAL collections: a default
|
||||
// Search() of an in-scope query must hit the KB-2026.1 slice (WP248/MaschVO live there but NOT in
|
||||
// the broad CE pool = clean discriminator); an out-of-scope query stays on CE.
|
||||
func TestKBScopeRoutingE2E(t *testing.T) {
|
||||
if os.Getenv("RUN_E2E") != "1" {
|
||||
t.Skip("set RUN_E2E=1 + QDRANT_URL/OLLAMA_URL/QDRANT_API_KEY")
|
||||
}
|
||||
c := NewLegalRAGClient()
|
||||
cases := []struct {
|
||||
q string
|
||||
wantToken string // expected in top-8 when routed to the slice
|
||||
wantInKB bool
|
||||
}{
|
||||
{"Welche neun Kriterien nennt WP248 fuer ein voraussichtlich hohes Risiko?", "WP248", true},
|
||||
{"Welche grundlegenden Sicherheits- und Gesundheitsschutzanforderungen enthaelt Anhang III der Maschinenverordnung?", "MASCH", true},
|
||||
{"Wie greifen CRA und Maschinenverordnung bei einer vernetzten Maschine ineinander?", "MASCH", true},
|
||||
{"Was sagt NIST SP 800-53 zu Access Control?", "", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
routed := c.resolveCollection(tc.q, "")
|
||||
res, err := c.Search(context.Background(), tc.q, nil, 8)
|
||||
if err != nil {
|
||||
t.Fatalf("%q: %v", tc.q, err)
|
||||
}
|
||||
codes := map[string]bool{}
|
||||
for _, r := range res {
|
||||
codes[strings.ToUpper(r.RegulationCode)] = true
|
||||
}
|
||||
hit := false
|
||||
if tc.wantToken != "" {
|
||||
for cd := range codes {
|
||||
if strings.Contains(cd, tc.wantToken) {
|
||||
hit = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
col := make([]string, 0, len(codes))
|
||||
for cd := range codes {
|
||||
col = append(col, cd)
|
||||
}
|
||||
fmt.Printf("inKB=%-5v routed=%-16s wantTok=%-6s found=%-5v | %v\n", tc.wantInKB, routed, tc.wantToken, hit, col)
|
||||
if tc.wantInKB && tc.wantToken != "" && !hit {
|
||||
t.Errorf("%q routed to %s but %s not in top-8 (slice not active?)", tc.q, routed, tc.wantToken)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,12 @@ type LegalRAGClient struct {
|
||||
textIndexEnsured map[string]bool
|
||||
hybridEnabled bool
|
||||
graphEnabled bool
|
||||
|
||||
// Blue-Green „authoritative slice promotion" (additiv, KEIN CE-Ersatz): faellt eine Query
|
||||
// in den KB-2026.1-Scope (DP/CRA/MaschVO/NIS2/DataAct/DORA/AIAct + EDPB/DSK-Guidance), wird
|
||||
// die hochwertige Slice-Collection abgefragt; sonst bleibt der breite Default (bp_compliance_ce).
|
||||
kbSliceCollection string
|
||||
kbScopeRoutingEnabled bool
|
||||
}
|
||||
|
||||
// NewLegalRAGClient creates a new Legal RAG client using Ollama bge-m3 embeddings.
|
||||
@@ -45,15 +51,25 @@ func NewLegalRAGClient() *LegalRAGClient {
|
||||
// zur Begruendung/Vollstaendigkeit genutzt, nicht zur Pool-Expansion (Default).
|
||||
graphEnabled := os.Getenv("RAG_GRAPH_EXPANSION") == "true"
|
||||
|
||||
// KB-2026.1 authoritative slice (Blue-Green, additiv). Routing default AN; Rollback ohne
|
||||
// Redeploy ueber RAG_KB_SCOPE_ROUTING=false (dann faellt alles auf den CE-Default zurueck).
|
||||
kbSlice := os.Getenv("RAG_KB_SLICE_COLLECTION")
|
||||
if kbSlice == "" {
|
||||
kbSlice = "kb_2026_1_build"
|
||||
}
|
||||
kbScopeRouting := os.Getenv("RAG_KB_SCOPE_ROUTING") != "false"
|
||||
|
||||
return &LegalRAGClient{
|
||||
qdrantURL: qdrantURL,
|
||||
qdrantAPIKey: qdrantAPIKey,
|
||||
ollamaURL: ollamaURL,
|
||||
embeddingModel: "bge-m3",
|
||||
collection: "bp_compliance_ce",
|
||||
textIndexEnsured: make(map[string]bool),
|
||||
hybridEnabled: hybridEnabled,
|
||||
graphEnabled: graphEnabled,
|
||||
qdrantURL: qdrantURL,
|
||||
qdrantAPIKey: qdrantAPIKey,
|
||||
ollamaURL: ollamaURL,
|
||||
embeddingModel: "bge-m3",
|
||||
collection: "bp_compliance_ce",
|
||||
textIndexEnsured: make(map[string]bool),
|
||||
hybridEnabled: hybridEnabled,
|
||||
graphEnabled: graphEnabled,
|
||||
kbSliceCollection: kbSlice,
|
||||
kbScopeRoutingEnabled: kbScopeRouting,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 60 * time.Second,
|
||||
},
|
||||
@@ -63,21 +79,32 @@ func NewLegalRAGClient() *LegalRAGClient {
|
||||
// SearchCollection queries a specific Qdrant collection for relevant passages.
|
||||
// If collection is empty, it falls back to the default collection (bp_compliance_ce).
|
||||
func (c *LegalRAGClient) SearchCollection(ctx context.Context, collection string, query string, regulationIDs []string, topK int) ([]LegalSearchResult, error) {
|
||||
if collection == "" {
|
||||
collection = c.collection
|
||||
}
|
||||
return c.searchInternal(ctx, collection, query, regulationIDs, topK)
|
||||
return c.searchInternal(ctx, c.resolveCollection(query, collection), query, regulationIDs, topK)
|
||||
}
|
||||
|
||||
// Search queries the compliance CE corpus for relevant passages.
|
||||
// Search queries the compliance corpus for relevant passages. The target collection is resolved by
|
||||
// the Blue-Green slice routing: the KB-2026.1 slice for in-scope queries, else the broad CE default.
|
||||
func (c *LegalRAGClient) Search(ctx context.Context, query string, regulationIDs []string, topK int) ([]LegalSearchResult, error) {
|
||||
return c.searchInternal(ctx, c.collection, query, regulationIDs, topK)
|
||||
return c.searchInternal(ctx, c.resolveCollection(query, ""), query, regulationIDs, topK)
|
||||
}
|
||||
|
||||
// searchInternal performs the actual search against a given collection.
|
||||
// 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 +150,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,
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
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 heisst je Collection anders: Slice MASCHVO · gesetze MVO · ce MACHINERY/MASCHINENVO.
|
||||
// Alle Varianten als CodeValues, sonst findet der per-Reg-Filter MaschVO nur in der Slice (0070).
|
||||
{"MaschVO", []string{"maschinenverordnung", "maschvo", "machinery regulation"}, []string{"MASCHVO", "MaschVO", "MVO", "MASCHINENVO", "MACHINERY"}},
|
||||
{"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) }
|
||||
|
||||
// balanceByRegulation builds the final top-K so EVERY explicitly-named regulation with hits is
|
||||
// represented, instead of letting the keyword-dominant domain (e.g. CRA) crowd out the other
|
||||
// (e.g. MaschVO) in a cross-regulation query. The input pool must already be score-ordered;
|
||||
// results are grouped by exact regulation_code match against each regulation's CodeValues, then
|
||||
// taken round-robin across the named domains (highest-scored first within each), with any
|
||||
// remaining slots filled by the leftover pool in score order. Generic; no doc-specific logic.
|
||||
func balanceByRegulation(pool []LegalSearchResult, regs []detectedRegulation, topK int) []LegalSearchResult {
|
||||
if topK <= 0 {
|
||||
topK = 8
|
||||
}
|
||||
byReg := make([][]LegalSearchResult, len(regs))
|
||||
matched := make([]bool, len(pool))
|
||||
for ri, r := range regs {
|
||||
for pi := range pool {
|
||||
if matched[pi] {
|
||||
continue
|
||||
}
|
||||
code := strings.TrimSpace(pool[pi].RegulationCode)
|
||||
for _, cv := range r.CodeValues {
|
||||
if strings.EqualFold(code, cv) {
|
||||
byReg[ri] = append(byReg[ri], pool[pi])
|
||||
matched[pi] = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out := make([]LegalSearchResult, 0, topK)
|
||||
idx := make([]int, len(regs))
|
||||
for len(out) < topK {
|
||||
progressed := false
|
||||
for ri := range regs {
|
||||
if idx[ri] < len(byReg[ri]) {
|
||||
out = append(out, byReg[ri][idx[ri]])
|
||||
idx[ri]++
|
||||
progressed = true
|
||||
if len(out) >= topK {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !progressed {
|
||||
break
|
||||
}
|
||||
}
|
||||
for pi := range pool {
|
||||
if len(out) >= topK {
|
||||
break
|
||||
}
|
||||
if !matched[pi] {
|
||||
out = append(out, pool[pi])
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -162,7 +162,7 @@ async def update_ai_system(
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Update an AI system."""
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
|
||||
system = db.query(AISystemDB).filter(AISystemDB.id == system_id).first()
|
||||
if not system:
|
||||
@@ -226,7 +226,7 @@ async def assess_ai_system(
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Run AI Act risk assessment for an AI system."""
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
|
||||
system = db.query(AISystemDB).filter(AISystemDB.id == system_id).first()
|
||||
if not system:
|
||||
|
||||
@@ -47,6 +47,8 @@ from compliance.services.canonical_control_service import (
|
||||
_control_row, # re-exported for legacy test imports
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/v1/canonical", tags=["canonical-controls"])
|
||||
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ Endpoints:
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, date, timedelta
|
||||
from datetime import datetime, date, timedelta, timezone
|
||||
from calendar import month_abbr
|
||||
from typing import Optional, Dict, Any, List
|
||||
from decimal import Decimal
|
||||
|
||||
@@ -26,10 +26,11 @@ versions). Module-level helpers re-exported for legacy tests.
|
||||
import logging
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from fastapi.responses import Response
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import text
|
||||
|
||||
from classroom_engine.database import get_db
|
||||
from compliance.api._http_errors import translate_domain_errors
|
||||
@@ -484,6 +485,7 @@ async def list_dsfas(
|
||||
async def create_dsfa(
|
||||
request: DSFACreate,
|
||||
tenant_id: Optional[str] = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
service: DSFAService = Depends(get_dsfa_service),
|
||||
) -> dict[str, Any]:
|
||||
"""Neue DSFA erstellen."""
|
||||
|
||||
@@ -16,6 +16,11 @@ from the legacy path.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import json
|
||||
import hashlib
|
||||
import uuid as uuid_module
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
|
||||
@@ -30,14 +35,15 @@ from ..db import (
|
||||
EvidenceConfidenceEnum,
|
||||
EvidenceTruthStatusEnum,
|
||||
)
|
||||
from ..db.models import EvidenceDB, ControlDB, AuditTrailDB
|
||||
from ..db.models import EvidenceDB, AuditTrailDB
|
||||
from ..services.auto_risk_updater import AutoRiskUpdater
|
||||
from ..services.evidence_service import EvidenceService
|
||||
from ..services.evidence_service import EvidenceService, _update_risks as _update_risks_impl
|
||||
from .schemas import (
|
||||
EvidenceCreate, EvidenceResponse, EvidenceListResponse,
|
||||
EvidenceRejectRequest,
|
||||
)
|
||||
from .audit_trail_utils import log_audit_trail
|
||||
from ._http_errors import translate_domain_errors
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["compliance-evidence"])
|
||||
@@ -146,6 +152,7 @@ async def list_evidence(
|
||||
status: Optional[str] = None,
|
||||
page: Optional[int] = Query(None, ge=1, description="Page number (1-based)"),
|
||||
limit: Optional[int] = Query(None, ge=1, le=500, description="Items per page"),
|
||||
db: Session = Depends(get_db),
|
||||
service: EvidenceService = Depends(get_evidence_service),
|
||||
) -> EvidenceListResponse:
|
||||
"""List evidence with optional filters and pagination."""
|
||||
@@ -186,9 +193,11 @@ async def list_evidence(
|
||||
@router.post("/evidence", response_model=EvidenceResponse)
|
||||
async def create_evidence(
|
||||
evidence_data: EvidenceCreate,
|
||||
db: Session = Depends(get_db),
|
||||
service: EvidenceService = Depends(get_evidence_service),
|
||||
) -> EvidenceResponse:
|
||||
"""Create new evidence record."""
|
||||
dsms_cid = None
|
||||
repo = EvidenceRepository(db)
|
||||
|
||||
# Get control UUID
|
||||
@@ -257,6 +266,7 @@ async def create_evidence(
|
||||
@router.delete("/evidence/{evidence_id}")
|
||||
async def delete_evidence(
|
||||
evidence_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
service: EvidenceService = Depends(get_evidence_service),
|
||||
) -> dict[str, Any]:
|
||||
"""Delete an evidence record."""
|
||||
@@ -275,6 +285,7 @@ async def upload_evidence(
|
||||
title: str = Query(...),
|
||||
file: UploadFile = File(...),
|
||||
description: Optional[str] = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
service: EvidenceService = Depends(get_evidence_service),
|
||||
) -> EvidenceResponse:
|
||||
"""Upload evidence file."""
|
||||
@@ -674,6 +685,7 @@ async def collect_ci_evidence(
|
||||
async def get_ci_evidence_status(
|
||||
control_id: Optional[str] = Query(None, description="Filter by control ID"),
|
||||
days: int = Query(30, description="Look back N days"),
|
||||
db: Session = Depends(get_db),
|
||||
service: EvidenceService = Depends(get_evidence_service),
|
||||
) -> dict[str, Any]:
|
||||
"""Get CI/CD evidence collection status overview."""
|
||||
@@ -681,70 +693,8 @@ async def get_ci_evidence_status(
|
||||
return service.ci_status(control_id, days)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Legacy re-exports for tests that import helpers directly.
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
if control_id:
|
||||
ctrl_repo = ControlRepository(db)
|
||||
control = ctrl_repo.get_by_control_id(control_id)
|
||||
if control:
|
||||
query = query.filter(EvidenceDB.control_id == control.id)
|
||||
|
||||
evidence_list = query.order_by(EvidenceDB.collected_at.desc()).limit(100).all()
|
||||
|
||||
# Group by control and calculate stats
|
||||
control_stats = defaultdict(lambda: {
|
||||
"total": 0,
|
||||
"valid": 0,
|
||||
"failed": 0,
|
||||
"last_collected": None,
|
||||
"evidence": [],
|
||||
})
|
||||
|
||||
for e in evidence_list:
|
||||
# Get control_id string
|
||||
control = db.query(ControlDB).filter(ControlDB.id == e.control_id).first()
|
||||
ctrl_id = control.control_id if control else "unknown"
|
||||
|
||||
stats = control_stats[ctrl_id]
|
||||
stats["total"] += 1
|
||||
if e.status:
|
||||
if e.status.value == "valid":
|
||||
stats["valid"] += 1
|
||||
elif e.status.value == "failed":
|
||||
stats["failed"] += 1
|
||||
if not stats["last_collected"] or e.collected_at > stats["last_collected"]:
|
||||
stats["last_collected"] = e.collected_at
|
||||
|
||||
# Add evidence summary
|
||||
stats["evidence"].append({
|
||||
"id": e.id,
|
||||
"type": e.evidence_type,
|
||||
"status": e.status.value if e.status else None,
|
||||
"collected_at": e.collected_at.isoformat() if e.collected_at else None,
|
||||
"ci_job_id": e.ci_job_id,
|
||||
})
|
||||
|
||||
# Convert to list and sort
|
||||
result = []
|
||||
for ctrl_id, stats in control_stats.items():
|
||||
result.append({
|
||||
"control_id": ctrl_id,
|
||||
"total_evidence": stats["total"],
|
||||
"valid_count": stats["valid"],
|
||||
"failed_count": stats["failed"],
|
||||
"last_collected": stats["last_collected"].isoformat() if stats["last_collected"] else None,
|
||||
"recent_evidence": stats["evidence"][:5],
|
||||
})
|
||||
|
||||
result.sort(key=lambda x: x["last_collected"] or "", reverse=True)
|
||||
|
||||
return {
|
||||
"period_days": days,
|
||||
"total_evidence": len(evidence_list),
|
||||
"controls": result,
|
||||
}
|
||||
# (Alte CI-Status-Implementierung entfernt — unerreichbarer Code nach `return
|
||||
# service.ci_status(...)`; durch den Service ersetzt, `query` war nie initialisiert.)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
@@ -772,6 +722,7 @@ async def review_evidence(
|
||||
approval_status='first_approved'. A second (different) reviewer then
|
||||
sets second_reviewer and approval_status='approved'.
|
||||
"""
|
||||
dsms_cid = None
|
||||
evidence = db.query(EvidenceDB).filter(EvidenceDB.id == evidence_id).first()
|
||||
if not evidence:
|
||||
raise HTTPException(status_code=404, detail=f"Evidence {evidence_id} not found")
|
||||
@@ -851,6 +802,7 @@ async def reject_evidence(
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Reject evidence (sets approval_status='rejected')."""
|
||||
dsms_cid = None
|
||||
evidence = db.query(EvidenceDB).filter(EvidenceDB.id == evidence_id).first()
|
||||
if not evidence:
|
||||
raise HTTPException(status_code=404, detail=f"Evidence {evidence_id} not found")
|
||||
|
||||
@@ -24,6 +24,7 @@ from fastapi.responses import FileResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from classroom_engine.database import get_db
|
||||
from ..db.models import EvidenceDB
|
||||
|
||||
from .audit_trail_utils import log_audit_trail
|
||||
from ..db import (
|
||||
@@ -310,6 +311,7 @@ async def list_controls_paginated(
|
||||
)
|
||||
async def get_control(
|
||||
control_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
svc: ControlExportService = Depends(get_ctrl_export_service),
|
||||
) -> ControlResponse:
|
||||
"""Get a specific control by control_id."""
|
||||
@@ -354,6 +356,7 @@ async def get_control(
|
||||
async def update_control(
|
||||
control_id: str,
|
||||
update: ControlUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
svc: ControlExportService = Depends(get_ctrl_export_service),
|
||||
) -> ControlResponse:
|
||||
"""Update a control."""
|
||||
@@ -443,6 +446,7 @@ async def update_control(
|
||||
async def review_control(
|
||||
control_id: str,
|
||||
review: ControlReviewRequest,
|
||||
db: Session = Depends(get_db),
|
||||
svc: ControlExportService = Depends(get_ctrl_export_service),
|
||||
) -> ControlResponse:
|
||||
"""Mark a control as reviewed with new status."""
|
||||
|
||||
@@ -21,7 +21,7 @@ Phase 1 Step 4 refactor: handlers delegate to VVTService.
|
||||
import logging
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Request
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
||||
Reference in New Issue
Block a user