feat(rag): optimize RAG pipeline — JSON-Mode, CoT, Hybrid Search, Re-Ranking, Cross-Reg Dedup, chunk 1024
Some checks failed
CI/CD / go-lint (push) Has been skipped
CI/CD / python-lint (push) Has been skipped
CI/CD / nodejs-lint (push) Has been skipped
CI/CD / test-go-ai-compliance (push) Failing after 42s
CI/CD / test-python-backend-compliance (push) Successful in 1m38s
CI/CD / test-python-document-crawler (push) Successful in 20s
CI/CD / test-python-dsms-gateway (push) Successful in 17s
CI/CD / validate-canonical-controls (push) Successful in 10s
CI/CD / Deploy (push) Has been skipped

Phase 1 (LLM Quality):
- Add format=json to all Ollama payloads (obligation_extractor, control_generator, citation_backfill)
- Add Chain-of-Thought analysis steps to Pass 0a/0b system prompts

Phase 2 (Retrieval Quality):
- Hybrid search via Qdrant Query API with RRF fusion + automatic text index (legal_rag.go)
- Fallback to dense-only search if Query API unavailable
- Cross-encoder re-ranking with BGE Reranker v2 (RERANK_ENABLED=false by default)
- CPU-only PyTorch dependency to keep Docker image small

Phase 3 (Data Layer):
- Cross-regulation dedup pass (threshold 0.95) links controls across regulations
- DedupResult.link_type field distinguishes dedup_merge vs cross_regulation
- Chunk size defaults updated 512/50 → 1024/128 for new ingestions only
- Existing collections and controls are NOT affected

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-03-21 11:49:43 +01:00
parent c3a53fe5d2
commit c52dbdb8f1
24 changed files with 2620 additions and 139 deletions

View File

@@ -48,12 +48,12 @@ describe('Ingestion Script: ingest-industry-compliance.sh', () => {
expect(scriptContent).toContain('chunk_strategy=recursive')
})
it('should use chunk_size=512', () => {
expect(scriptContent).toContain('chunk_size=512')
it('should use chunk_size=1024', () => {
expect(scriptContent).toContain('chunk_size=1024')
})
it('should use chunk_overlap=50', () => {
expect(scriptContent).toContain('chunk_overlap=50')
it('should use chunk_overlap=128', () => {
expect(scriptContent).toContain('chunk_overlap=128')
})
it('should validate minimum file size', () => {

View File

@@ -14,12 +14,14 @@ import (
// LegalRAGClient provides access to the compliance CE vector search via Qdrant + Ollama bge-m3.
type LegalRAGClient struct {
qdrantURL string
qdrantAPIKey string
ollamaURL string
embeddingModel string
collection string
httpClient *http.Client
qdrantURL string
qdrantAPIKey string
ollamaURL string
embeddingModel string
collection string
httpClient *http.Client
textIndexEnsured map[string]bool // tracks which collections have text index
hybridEnabled bool // use Query API with RRF fusion
}
// LegalSearchResult represents a single search result from the compliance corpus.
@@ -70,12 +72,16 @@ func NewLegalRAGClient() *LegalRAGClient {
ollamaURL = "http://localhost:11434"
}
hybridEnabled := os.Getenv("RAG_HYBRID_SEARCH") != "false" // enabled by default
return &LegalRAGClient{
qdrantURL: qdrantURL,
qdrantAPIKey: qdrantAPIKey,
ollamaURL: ollamaURL,
embeddingModel: "bge-m3",
collection: "bp_compliance_ce",
qdrantURL: qdrantURL,
qdrantAPIKey: qdrantAPIKey,
ollamaURL: ollamaURL,
embeddingModel: "bge-m3",
collection: "bp_compliance_ce",
textIndexEnsured: make(map[string]bool),
hybridEnabled: hybridEnabled,
httpClient: &http.Client{
Timeout: 60 * time.Second,
},
@@ -126,6 +132,161 @@ type qdrantSearchHit struct {
Payload map[string]interface{} `json:"payload"`
}
// --- Hybrid Search (Query API with RRF fusion) ---
// qdrantQueryRequest for Qdrant Query API with prefetch + fusion.
type qdrantQueryRequest struct {
Prefetch []qdrantPrefetch `json:"prefetch"`
Query *qdrantFusion `json:"query"`
Limit int `json:"limit"`
WithPayload bool `json:"with_payload"`
Filter *qdrantFilter `json:"filter,omitempty"`
}
type qdrantPrefetch struct {
Query []float64 `json:"query"`
Limit int `json:"limit"`
Filter *qdrantFilter `json:"filter,omitempty"`
}
type qdrantFusion struct {
Fusion string `json:"fusion"`
}
// qdrantQueryResponse from Qdrant Query API (same shape as search).
type qdrantQueryResponse struct {
Result []qdrantSearchHit `json:"result"`
}
// qdrantTextIndexRequest for creating a full-text index on a payload field.
type qdrantTextIndexRequest struct {
FieldName string `json:"field_name"`
FieldSchema qdrantTextFieldSchema `json:"field_schema"`
}
type qdrantTextFieldSchema struct {
Type string `json:"type"`
Tokenizer string `json:"tokenizer"`
MinLen int `json:"min_token_len,omitempty"`
MaxLen int `json:"max_token_len,omitempty"`
}
// ensureTextIndex creates a full-text index on chunk_text if not already done for this collection.
func (c *LegalRAGClient) ensureTextIndex(ctx context.Context, collection string) error {
if c.textIndexEnsured[collection] {
return nil
}
indexReq := qdrantTextIndexRequest{
FieldName: "chunk_text",
FieldSchema: qdrantTextFieldSchema{
Type: "text",
Tokenizer: "word",
MinLen: 2,
MaxLen: 40,
},
}
jsonBody, err := json.Marshal(indexReq)
if err != nil {
return fmt.Errorf("failed to marshal text index request: %w", err)
}
url := fmt.Sprintf("%s/collections/%s/index", c.qdrantURL, collection)
req, err := http.NewRequestWithContext(ctx, "PUT", url, bytes.NewReader(jsonBody))
if err != nil {
return fmt.Errorf("failed to create text index request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
if c.qdrantAPIKey != "" {
req.Header.Set("api-key", c.qdrantAPIKey)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("text index request failed: %w", err)
}
defer resp.Body.Close()
// 200 = created, 409 = already exists — both are fine
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusConflict {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("text index creation failed %d: %s", resp.StatusCode, string(body))
}
c.textIndexEnsured[collection] = true
return nil
}
// searchHybrid performs RRF-fused hybrid search (dense + full-text) via Qdrant Query API.
func (c *LegalRAGClient) searchHybrid(ctx context.Context, collection string, embedding []float64, regulationIDs []string, topK int) ([]qdrantSearchHit, error) {
// Ensure text index exists
if err := c.ensureTextIndex(ctx, collection); err != nil {
// Non-fatal: log and fall back to dense-only
return nil, err
}
// Build prefetch with dense vector (retrieve top-20 for re-ranking)
prefetchLimit := 20
if topK > 20 {
prefetchLimit = topK * 4
}
queryReq := qdrantQueryRequest{
Prefetch: []qdrantPrefetch{
{Query: embedding, Limit: prefetchLimit},
},
Query: &qdrantFusion{Fusion: "rrf"},
Limit: topK,
WithPayload: true,
}
// Add regulation filter
if len(regulationIDs) > 0 {
conditions := make([]qdrantCondition, len(regulationIDs))
for i, regID := range regulationIDs {
conditions[i] = qdrantCondition{
Key: "regulation_id",
Match: qdrantMatch{Value: regID},
}
}
queryReq.Filter = &qdrantFilter{Should: conditions}
}
jsonBody, err := json.Marshal(queryReq)
if err != nil {
return nil, fmt.Errorf("failed to marshal query request: %w", err)
}
url := fmt.Sprintf("%s/collections/%s/points/query", c.qdrantURL, collection)
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(jsonBody))
if err != nil {
return nil, fmt.Errorf("failed to create query request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
if c.qdrantAPIKey != "" {
req.Header.Set("api-key", c.qdrantAPIKey)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("query request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("qdrant query returned %d: %s", resp.StatusCode, string(body))
}
var queryResp qdrantQueryResponse
if err := json.NewDecoder(resp.Body).Decode(&queryResp); err != nil {
return nil, fmt.Errorf("failed to decode query response: %w", err)
}
return queryResp.Result, nil
}
// generateEmbedding calls Ollama bge-m3 to get a 1024-dim vector for the query.
func (c *LegalRAGClient) generateEmbedding(ctx context.Context, text string) ([]float64, error) {
// Truncate to 2000 chars for bge-m3
@@ -187,6 +348,8 @@ func (c *LegalRAGClient) Search(ctx context.Context, query string, regulationIDs
}
// 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) {
// Generate query embedding via Ollama bge-m3
embedding, err := c.generateEmbedding(ctx, query)
@@ -194,14 +357,51 @@ func (c *LegalRAGClient) searchInternal(ctx context.Context, collection string,
return nil, fmt.Errorf("failed to generate embedding: %w", err)
}
// Build Qdrant search request
// Try hybrid search first (Query API + RRF), fall back to dense-only
var hits []qdrantSearchHit
if c.hybridEnabled {
hybridHits, err := c.searchHybrid(ctx, collection, embedding, regulationIDs, topK)
if err == nil {
hits = hybridHits
}
// On error, fall through to dense-only search below
}
if hits == nil {
denseHits, err := c.searchDense(ctx, collection, embedding, regulationIDs, topK)
if err != nil {
return nil, err
}
hits = denseHits
}
// Convert to results using bp_compliance_ce payload schema
results := make([]LegalSearchResult, len(hits))
for i, hit := range hits {
results[i] = LegalSearchResult{
Text: getString(hit.Payload, "chunk_text"),
RegulationCode: getString(hit.Payload, "regulation_id"),
RegulationName: getString(hit.Payload, "regulation_name_de"),
RegulationShort: getString(hit.Payload, "regulation_short"),
Category: getString(hit.Payload, "category"),
Pages: getIntSlice(hit.Payload, "pages"),
SourceURL: getString(hit.Payload, "source"),
Score: hit.Score,
}
}
return results, nil
}
// searchDense performs a dense-only vector search via Qdrant /points/search.
func (c *LegalRAGClient) searchDense(ctx context.Context, collection string, embedding []float64, regulationIDs []string, topK int) ([]qdrantSearchHit, error) {
searchReq := qdrantSearchRequest{
Vector: embedding,
Limit: topK,
WithPayload: true,
}
// Add filter for specific regulations if provided
if len(regulationIDs) > 0 {
conditions := make([]qdrantCondition, len(regulationIDs))
for i, regID := range regulationIDs {
@@ -218,7 +418,6 @@ func (c *LegalRAGClient) searchInternal(ctx context.Context, collection string,
return nil, fmt.Errorf("failed to marshal search request: %w", err)
}
// Call Qdrant
url := fmt.Sprintf("%s/collections/%s/points/search", c.qdrantURL, collection)
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(jsonBody))
if err != nil {
@@ -245,22 +444,7 @@ func (c *LegalRAGClient) searchInternal(ctx context.Context, collection string,
return nil, fmt.Errorf("failed to decode search response: %w", err)
}
// Convert to results using bp_compliance_ce payload schema
results := make([]LegalSearchResult, len(searchResp.Result))
for i, hit := range searchResp.Result {
results[i] = LegalSearchResult{
Text: getString(hit.Payload, "chunk_text"),
RegulationCode: getString(hit.Payload, "regulation_id"),
RegulationName: getString(hit.Payload, "regulation_name_de"),
RegulationShort: getString(hit.Payload, "regulation_short"),
Category: getString(hit.Payload, "category"),
Pages: getIntSlice(hit.Payload, "pages"),
SourceURL: getString(hit.Payload, "source"),
Score: hit.Score,
}
}
return results, nil
return searchResp.Result, nil
}
// GetLegalContextForAssessment retrieves relevant legal context for an assessment.

View File

@@ -32,11 +32,13 @@ func TestSearchCollection_UsesCorrectCollection(t *testing.T) {
// Parse qdrant mock host/port
client := &LegalRAGClient{
qdrantURL: qdrantMock.URL,
ollamaURL: ollamaMock.URL,
embeddingModel: "bge-m3",
collection: "bp_compliance_ce",
httpClient: http.DefaultClient,
qdrantURL: qdrantMock.URL,
ollamaURL: ollamaMock.URL,
embeddingModel: "bge-m3",
collection: "bp_compliance_ce",
textIndexEnsured: make(map[string]bool),
hybridEnabled: false, // dense-only for this test
httpClient: http.DefaultClient,
}
// Test with explicit collection
@@ -69,11 +71,13 @@ func TestSearchCollection_FallbackDefault(t *testing.T) {
defer qdrantMock.Close()
client := &LegalRAGClient{
qdrantURL: qdrantMock.URL,
ollamaURL: ollamaMock.URL,
embeddingModel: "bge-m3",
collection: "bp_compliance_ce",
httpClient: http.DefaultClient,
qdrantURL: qdrantMock.URL,
ollamaURL: ollamaMock.URL,
embeddingModel: "bge-m3",
collection: "bp_compliance_ce",
textIndexEnsured: make(map[string]bool),
hybridEnabled: false,
httpClient: http.DefaultClient,
}
// Test with empty collection (should fall back to default)
@@ -140,8 +144,9 @@ func TestScrollChunks_ReturnsChunksAndNextOffset(t *testing.T) {
defer qdrantMock.Close()
client := &LegalRAGClient{
qdrantURL: qdrantMock.URL,
httpClient: http.DefaultClient,
qdrantURL: qdrantMock.URL,
textIndexEnsured: make(map[string]bool),
httpClient: http.DefaultClient,
}
chunks, nextOffset, err := client.ScrollChunks(context.Background(), "bp_compliance_ce", "", 100)
@@ -196,8 +201,9 @@ func TestScrollChunks_EmptyCollection_ReturnsEmpty(t *testing.T) {
defer qdrantMock.Close()
client := &LegalRAGClient{
qdrantURL: qdrantMock.URL,
httpClient: http.DefaultClient,
qdrantURL: qdrantMock.URL,
textIndexEnsured: make(map[string]bool),
httpClient: http.DefaultClient,
}
chunks, nextOffset, err := client.ScrollChunks(context.Background(), "bp_compliance_ce", "", 100)
@@ -230,8 +236,9 @@ func TestScrollChunks_WithOffset_SendsOffset(t *testing.T) {
defer qdrantMock.Close()
client := &LegalRAGClient{
qdrantURL: qdrantMock.URL,
httpClient: http.DefaultClient,
qdrantURL: qdrantMock.URL,
textIndexEnsured: make(map[string]bool),
httpClient: http.DefaultClient,
}
_, _, err := client.ScrollChunks(context.Background(), "bp_compliance_ce", "some-offset-id", 50)
@@ -263,9 +270,10 @@ func TestScrollChunks_SendsAPIKey(t *testing.T) {
defer qdrantMock.Close()
client := &LegalRAGClient{
qdrantURL: qdrantMock.URL,
qdrantAPIKey: "test-api-key-123",
httpClient: http.DefaultClient,
qdrantURL: qdrantMock.URL,
qdrantAPIKey: "test-api-key-123",
textIndexEnsured: make(map[string]bool),
httpClient: http.DefaultClient,
}
_, _, err := client.ScrollChunks(context.Background(), "bp_compliance_ce", "", 10)
@@ -310,11 +318,13 @@ func TestSearch_StillWorks(t *testing.T) {
defer qdrantMock.Close()
client := &LegalRAGClient{
qdrantURL: qdrantMock.URL,
ollamaURL: ollamaMock.URL,
embeddingModel: "bge-m3",
collection: "bp_compliance_ce",
httpClient: http.DefaultClient,
qdrantURL: qdrantMock.URL,
ollamaURL: ollamaMock.URL,
embeddingModel: "bge-m3",
collection: "bp_compliance_ce",
textIndexEnsured: make(map[string]bool),
hybridEnabled: false,
httpClient: http.DefaultClient,
}
results, err := client.Search(context.Background(), "DSGVO Art. 35", nil, 5)
@@ -334,3 +344,257 @@ func TestSearch_StillWorks(t *testing.T) {
t.Errorf("Expected default collection in URL, got: %s", requestedURL)
}
}
// --- Hybrid Search Tests ---
func TestHybridSearch_UsesQueryAPI(t *testing.T) {
var requestedPaths []string
ollamaMock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(ollamaEmbeddingResponse{
Embedding: make([]float64, 1024),
})
}))
defer ollamaMock.Close()
qdrantMock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestedPaths = append(requestedPaths, r.URL.Path)
if strings.Contains(r.URL.Path, "/index") {
// Text index creation — return OK
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"result":{"operation_id":1,"status":"completed"}}`))
return
}
if strings.Contains(r.URL.Path, "/points/query") {
// Verify the query request body has prefetch + fusion
var reqBody map[string]interface{}
json.NewDecoder(r.Body).Decode(&reqBody)
if _, ok := reqBody["prefetch"]; !ok {
t.Error("Query request missing 'prefetch' field")
}
queryField, ok := reqBody["query"].(map[string]interface{})
if !ok || queryField["fusion"] != "rrf" {
t.Error("Query request missing 'query.fusion=rrf'")
}
json.NewEncoder(w).Encode(qdrantQueryResponse{
Result: []qdrantSearchHit{
{
ID: "1",
Score: 0.88,
Payload: map[string]interface{}{
"chunk_text": "Hybrid result",
"regulation_id": "eu_2016_679",
"regulation_name_de": "DSGVO",
"regulation_short": "DSGVO",
"category": "regulation",
"source": "https://example.com",
},
},
},
})
return
}
// Fallback: should not reach dense search
t.Error("Unexpected dense search call when hybrid succeeded")
json.NewEncoder(w).Encode(qdrantSearchResponse{Result: []qdrantSearchHit{}})
}))
defer qdrantMock.Close()
client := &LegalRAGClient{
qdrantURL: qdrantMock.URL,
ollamaURL: ollamaMock.URL,
embeddingModel: "bge-m3",
collection: "bp_compliance_ce",
textIndexEnsured: make(map[string]bool),
hybridEnabled: true,
httpClient: http.DefaultClient,
}
results, err := client.Search(context.Background(), "DSGVO Art. 35", nil, 5)
if err != nil {
t.Fatalf("Hybrid search failed: %v", err)
}
if len(results) != 1 {
t.Fatalf("Expected 1 result, got %d", len(results))
}
if results[0].Text != "Hybrid result" {
t.Errorf("Expected 'Hybrid result', got '%s'", results[0].Text)
}
// Verify text index was created
hasIndex := false
hasQuery := false
for _, p := range requestedPaths {
if strings.Contains(p, "/index") {
hasIndex = true
}
if strings.Contains(p, "/points/query") {
hasQuery = true
}
}
if !hasIndex {
t.Error("Expected text index creation call")
}
if !hasQuery {
t.Error("Expected Query API call")
}
}
func TestHybridSearch_FallbackToDense(t *testing.T) {
var requestedPaths []string
ollamaMock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(ollamaEmbeddingResponse{
Embedding: make([]float64, 1024),
})
}))
defer ollamaMock.Close()
qdrantMock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestedPaths = append(requestedPaths, r.URL.Path)
if strings.Contains(r.URL.Path, "/index") {
// Simulate text index failure (old Qdrant version)
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(`{"status":{"error":"not supported"}}`))
return
}
if strings.Contains(r.URL.Path, "/points/search") {
// Dense fallback
json.NewEncoder(w).Encode(qdrantSearchResponse{
Result: []qdrantSearchHit{
{
ID: "2",
Score: 0.90,
Payload: map[string]interface{}{
"chunk_text": "Dense fallback result",
"regulation_id": "eu_2016_679",
"regulation_name_de": "DSGVO",
"regulation_short": "DSGVO",
"category": "regulation",
"source": "https://example.com",
},
},
},
})
return
}
w.WriteHeader(http.StatusInternalServerError)
}))
defer qdrantMock.Close()
client := &LegalRAGClient{
qdrantURL: qdrantMock.URL,
ollamaURL: ollamaMock.URL,
embeddingModel: "bge-m3",
collection: "bp_compliance_ce",
textIndexEnsured: make(map[string]bool),
hybridEnabled: true,
httpClient: http.DefaultClient,
}
results, err := client.Search(context.Background(), "test query", nil, 5)
if err != nil {
t.Fatalf("Fallback search failed: %v", err)
}
if len(results) != 1 {
t.Fatalf("Expected 1 result, got %d", len(results))
}
if results[0].Text != "Dense fallback result" {
t.Errorf("Expected 'Dense fallback result', got '%s'", results[0].Text)
}
// Verify it fell back to dense search
hasDense := false
for _, p := range requestedPaths {
if strings.Contains(p, "/points/search") {
hasDense = true
}
}
if !hasDense {
t.Error("Expected fallback to dense /points/search")
}
}
func TestEnsureTextIndex_OnlyCalledOnce(t *testing.T) {
callCount := 0
qdrantMock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/index") {
callCount++
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"result":{"operation_id":1,"status":"completed"}}`))
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"result":[]}`))
}))
defer qdrantMock.Close()
client := &LegalRAGClient{
qdrantURL: qdrantMock.URL,
textIndexEnsured: make(map[string]bool),
httpClient: http.DefaultClient,
}
ctx := context.Background()
_ = client.ensureTextIndex(ctx, "test_collection")
_ = client.ensureTextIndex(ctx, "test_collection")
_ = client.ensureTextIndex(ctx, "test_collection")
if callCount != 1 {
t.Errorf("Expected ensureTextIndex to call Qdrant exactly once, called %d times", callCount)
}
}
func TestHybridDisabled_UsesDenseOnly(t *testing.T) {
var requestedPaths []string
ollamaMock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(ollamaEmbeddingResponse{
Embedding: make([]float64, 1024),
})
}))
defer ollamaMock.Close()
qdrantMock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestedPaths = append(requestedPaths, r.URL.Path)
json.NewEncoder(w).Encode(qdrantSearchResponse{
Result: []qdrantSearchHit{},
})
}))
defer qdrantMock.Close()
client := &LegalRAGClient{
qdrantURL: qdrantMock.URL,
ollamaURL: ollamaMock.URL,
embeddingModel: "bge-m3",
collection: "bp_compliance_ce",
textIndexEnsured: make(map[string]bool),
hybridEnabled: false,
httpClient: http.DefaultClient,
}
_, err := client.Search(context.Background(), "test", nil, 5)
if err != nil {
t.Fatalf("Search failed: %v", err)
}
for _, p := range requestedPaths {
if strings.Contains(p, "/points/query") {
t.Error("Query API should not be called when hybrid is disabled")
}
if strings.Contains(p, "/index") {
t.Error("Text index should not be created when hybrid is disabled")
}
}
}

View File

@@ -69,7 +69,7 @@ class AnchorFinder:
tags_str = " ".join(control.tags[:3]) if control.tags else ""
query = f"{control.title} {tags_str}".strip()
results = await self.rag.search(
results = await self.rag.search_with_rerank(
query=query,
collection="bp_compliance_ce",
top_k=15,

View File

@@ -391,6 +391,7 @@ async def _llm_ollama(prompt: str, system_prompt: Optional[str] = None) -> str:
"model": OLLAMA_MODEL,
"messages": messages,
"stream": False,
"format": "json",
"options": {"num_predict": 256},
"think": False,
}

View File

@@ -0,0 +1,733 @@
"""Control Deduplication Engine — 4-Stage Matching Pipeline.
Prevents duplicate atomic controls during Pass 0b by checking candidates
against existing controls before insertion.
Stages:
1. Pattern-Gate: pattern_id must match (hard gate)
2. Action-Check: normalized action verb must match (hard gate)
3. Object-Norm: normalized object must match (soft gate with high threshold)
4. Embedding: cosine similarity with tiered thresholds (Qdrant)
Verdicts:
- NEW: create a new atomic control
- LINK: add parent link to existing control (similarity > LINK_THRESHOLD)
- REVIEW: queue for human review (REVIEW_THRESHOLD < sim < LINK_THRESHOLD)
"""
import logging
import os
import re
from dataclasses import dataclass, field
from typing import Optional, Callable, Awaitable
import httpx
logger = logging.getLogger(__name__)
# ── Configuration ────────────────────────────────────────────────────
DEDUP_ENABLED = os.getenv("DEDUP_ENABLED", "true").lower() == "true"
LINK_THRESHOLD = float(os.getenv("DEDUP_LINK_THRESHOLD", "0.92"))
REVIEW_THRESHOLD = float(os.getenv("DEDUP_REVIEW_THRESHOLD", "0.85"))
LINK_THRESHOLD_DIFF_OBJECT = float(os.getenv("DEDUP_LINK_THRESHOLD_DIFF_OBJ", "0.95"))
CROSS_REG_LINK_THRESHOLD = float(os.getenv("DEDUP_CROSS_REG_THRESHOLD", "0.95"))
QDRANT_COLLECTION = os.getenv("DEDUP_QDRANT_COLLECTION", "atomic_controls")
QDRANT_URL = os.getenv("QDRANT_URL", "http://host.docker.internal:6333")
EMBEDDING_URL = os.getenv("EMBEDDING_URL", "http://embedding-service:8087")
# ── Result Dataclass ─────────────────────────────────────────────────
@dataclass
class DedupResult:
"""Outcome of the dedup check."""
verdict: str # "new" | "link" | "review"
matched_control_uuid: Optional[str] = None
matched_control_id: Optional[str] = None
matched_title: Optional[str] = None
stage: str = "" # which stage decided
similarity_score: float = 0.0
link_type: str = "dedup_merge" # "dedup_merge" | "cross_regulation"
details: dict = field(default_factory=dict)
# ── Action Normalization ─────────────────────────────────────────────
_ACTION_SYNONYMS: dict[str, str] = {
# German → canonical English
"implementieren": "implement",
"umsetzen": "implement",
"einrichten": "implement",
"einführen": "implement",
"aufbauen": "implement",
"bereitstellen": "implement",
"aktivieren": "implement",
"konfigurieren": "configure",
"einstellen": "configure",
"parametrieren": "configure",
"testen": "test",
"prüfen": "test",
"überprüfen": "test",
"verifizieren": "test",
"validieren": "test",
"kontrollieren": "test",
"auditieren": "audit",
"dokumentieren": "document",
"protokollieren": "log",
"aufzeichnen": "log",
"loggen": "log",
"überwachen": "monitor",
"monitoring": "monitor",
"beobachten": "monitor",
"schulen": "train",
"trainieren": "train",
"sensibilisieren": "train",
"löschen": "delete",
"entfernen": "delete",
"verschlüsseln": "encrypt",
"sperren": "block",
"beschränken": "restrict",
"einschränken": "restrict",
"begrenzen": "restrict",
"autorisieren": "authorize",
"genehmigen": "authorize",
"freigeben": "authorize",
"authentifizieren": "authenticate",
"identifizieren": "identify",
"melden": "report",
"benachrichtigen": "notify",
"informieren": "notify",
"aktualisieren": "update",
"erneuern": "update",
"sichern": "backup",
"wiederherstellen": "restore",
# English passthrough
"implement": "implement",
"configure": "configure",
"test": "test",
"verify": "test",
"validate": "test",
"audit": "audit",
"document": "document",
"log": "log",
"monitor": "monitor",
"train": "train",
"delete": "delete",
"encrypt": "encrypt",
"restrict": "restrict",
"authorize": "authorize",
"authenticate": "authenticate",
"report": "report",
"update": "update",
"backup": "backup",
"restore": "restore",
}
def normalize_action(action: str) -> str:
"""Normalize an action verb to a canonical English form."""
if not action:
return ""
action = action.strip().lower()
# Strip German infinitive/conjugation suffixes for lookup
action_base = re.sub(r"(en|t|st|e|te|tet|end)$", "", action)
# Try exact match first, then base form
if action in _ACTION_SYNONYMS:
return _ACTION_SYNONYMS[action]
if action_base in _ACTION_SYNONYMS:
return _ACTION_SYNONYMS[action_base]
# Fuzzy: check if action starts with any known verb
for verb, canonical in _ACTION_SYNONYMS.items():
if action.startswith(verb) or verb.startswith(action):
return canonical
return action # fallback: return as-is
# ── Object Normalization ─────────────────────────────────────────────
_OBJECT_SYNONYMS: dict[str, str] = {
# Authentication / Access
"mfa": "multi_factor_auth",
"multi-faktor-authentifizierung": "multi_factor_auth",
"mehrfaktorauthentifizierung": "multi_factor_auth",
"multi-factor authentication": "multi_factor_auth",
"two-factor": "multi_factor_auth",
"2fa": "multi_factor_auth",
"passwort": "password_policy",
"kennwort": "password_policy",
"password": "password_policy",
"zugangsdaten": "credentials",
"credentials": "credentials",
"admin-konten": "privileged_access",
"admin accounts": "privileged_access",
"administratorkonten": "privileged_access",
"privilegierte zugriffe": "privileged_access",
"privileged accounts": "privileged_access",
"remote-zugriff": "remote_access",
"fernzugriff": "remote_access",
"remote access": "remote_access",
"session": "session_management",
"sitzung": "session_management",
"sitzungsverwaltung": "session_management",
# Encryption
"verschlüsselung": "encryption",
"encryption": "encryption",
"kryptografie": "encryption",
"kryptografische verfahren": "encryption",
"schlüssel": "key_management",
"key management": "key_management",
"schlüsselverwaltung": "key_management",
"zertifikat": "certificate_management",
"certificate": "certificate_management",
"tls": "transport_encryption",
"ssl": "transport_encryption",
"https": "transport_encryption",
# Network
"firewall": "firewall",
"netzwerk": "network_security",
"network": "network_security",
"vpn": "vpn",
"segmentierung": "network_segmentation",
"segmentation": "network_segmentation",
# Logging / Monitoring
"audit-log": "audit_logging",
"audit log": "audit_logging",
"protokoll": "audit_logging",
"logging": "audit_logging",
"monitoring": "monitoring",
"überwachung": "monitoring",
"alerting": "alerting",
"alarmierung": "alerting",
"siem": "siem",
# Data
"personenbezogene daten": "personal_data",
"personal data": "personal_data",
"sensible daten": "sensitive_data",
"sensitive data": "sensitive_data",
"datensicherung": "backup",
"backup": "backup",
"wiederherstellung": "disaster_recovery",
"disaster recovery": "disaster_recovery",
# Policy / Process
"richtlinie": "policy",
"policy": "policy",
"verfahrensanweisung": "procedure",
"procedure": "procedure",
"prozess": "process",
"schulung": "training",
"training": "training",
"awareness": "awareness",
"sensibilisierung": "awareness",
# Incident
"vorfall": "incident",
"incident": "incident",
"sicherheitsvorfall": "security_incident",
"security incident": "security_incident",
# Vulnerability
"schwachstelle": "vulnerability",
"vulnerability": "vulnerability",
"patch": "patch_management",
"update": "patch_management",
"patching": "patch_management",
}
# Precompile for substring matching (longest first)
_OBJECT_KEYS_SORTED = sorted(_OBJECT_SYNONYMS.keys(), key=len, reverse=True)
def normalize_object(obj: str) -> str:
"""Normalize a compliance object to a canonical token."""
if not obj:
return ""
obj_lower = obj.strip().lower()
# Exact match
if obj_lower in _OBJECT_SYNONYMS:
return _OBJECT_SYNONYMS[obj_lower]
# Substring match (longest first)
for phrase in _OBJECT_KEYS_SORTED:
if phrase in obj_lower:
return _OBJECT_SYNONYMS[phrase]
# Fallback: strip articles/prepositions, join with underscore
cleaned = re.sub(r"\b(der|die|das|den|dem|des|ein|eine|eines|einem|einen"
r"|für|von|zu|auf|in|an|bei|mit|nach|über|unter|the|a|an"
r"|for|of|to|on|in|at|by|with)\b", "", obj_lower)
tokens = [t for t in cleaned.split() if len(t) > 2]
return "_".join(tokens[:4]) if tokens else obj_lower.replace(" ", "_")
# ── Canonicalization ─────────────────────────────────────────────────
def canonicalize_text(action: str, obj: str, title: str = "") -> str:
"""Build a canonical English text for embedding.
Transforms German compliance text into normalized English tokens
for more stable embedding comparisons.
"""
norm_action = normalize_action(action)
norm_object = normalize_object(obj)
# Build canonical sentence
parts = [norm_action, norm_object]
if title:
# Add title keywords (stripped of common filler)
title_clean = re.sub(
r"\b(und|oder|für|von|zu|der|die|das|den|dem|des|ein|eine"
r"|bei|mit|nach|gemäß|gem\.|laut|entsprechend)\b",
"", title.lower()
)
title_tokens = [t for t in title_clean.split() if len(t) > 3][:5]
if title_tokens:
parts.append("for")
parts.extend(title_tokens)
return " ".join(parts)
# ── Embedding Helper ─────────────────────────────────────────────────
async def get_embedding(text: str) -> list[float]:
"""Get embedding vector for a single text via embedding service."""
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.post(
f"{EMBEDDING_URL}/embed",
json={"texts": [text]},
)
embeddings = resp.json().get("embeddings", [])
return embeddings[0] if embeddings else []
except Exception as e:
logger.warning("Embedding failed: %s", e)
return []
def cosine_similarity(a: list[float], b: list[float]) -> float:
"""Compute cosine similarity between two vectors."""
if not a or not b or len(a) != len(b):
return 0.0
dot = sum(x * y for x, y in zip(a, b))
norm_a = sum(x * x for x in a) ** 0.5
norm_b = sum(x * x for x in b) ** 0.5
if norm_a == 0 or norm_b == 0:
return 0.0
return dot / (norm_a * norm_b)
# ── Qdrant Helpers ───────────────────────────────────────────────────
async def qdrant_search(
embedding: list[float],
pattern_id: str,
top_k: int = 10,
) -> list[dict]:
"""Search Qdrant for similar atomic controls, filtered by pattern_id."""
if not embedding:
return []
body: dict = {
"vector": embedding,
"limit": top_k,
"with_payload": True,
"filter": {
"must": [
{"key": "pattern_id", "match": {"value": pattern_id}}
]
},
}
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.post(
f"{QDRANT_URL}/collections/{QDRANT_COLLECTION}/points/search",
json=body,
)
if resp.status_code != 200:
logger.warning("Qdrant search failed: %d", resp.status_code)
return []
return resp.json().get("result", [])
except Exception as e:
logger.warning("Qdrant search error: %s", e)
return []
async def qdrant_search_cross_regulation(
embedding: list[float],
top_k: int = 5,
) -> list[dict]:
"""Search Qdrant for similar controls across ALL regulations (no pattern_id filter).
Used for cross-regulation linking (e.g. DSGVO Art. 25 ↔ NIS2 Art. 21).
"""
if not embedding:
return []
body: dict = {
"vector": embedding,
"limit": top_k,
"with_payload": True,
}
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.post(
f"{QDRANT_URL}/collections/{QDRANT_COLLECTION}/points/search",
json=body,
)
if resp.status_code != 200:
logger.warning("Qdrant cross-reg search failed: %d", resp.status_code)
return []
return resp.json().get("result", [])
except Exception as e:
logger.warning("Qdrant cross-reg search error: %s", e)
return []
async def qdrant_upsert(
point_id: str,
embedding: list[float],
payload: dict,
) -> bool:
"""Upsert a single point into the atomic_controls Qdrant collection."""
if not embedding:
return False
body = {
"points": [{
"id": point_id,
"vector": embedding,
"payload": payload,
}]
}
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.put(
f"{QDRANT_URL}/collections/{QDRANT_COLLECTION}/points",
json=body,
)
return resp.status_code == 200
except Exception as e:
logger.warning("Qdrant upsert error: %s", e)
return False
async def ensure_qdrant_collection(vector_size: int = 1024) -> bool:
"""Create the Qdrant collection if it doesn't exist (idempotent)."""
try:
async with httpx.AsyncClient(timeout=10.0) as client:
# Check if exists
resp = await client.get(f"{QDRANT_URL}/collections/{QDRANT_COLLECTION}")
if resp.status_code == 200:
return True
# Create
resp = await client.put(
f"{QDRANT_URL}/collections/{QDRANT_COLLECTION}",
json={
"vectors": {"size": vector_size, "distance": "Cosine"},
},
)
if resp.status_code == 200:
logger.info("Created Qdrant collection: %s", QDRANT_COLLECTION)
# Create payload indexes
for field_name in ["pattern_id", "action_normalized", "object_normalized", "control_id"]:
await client.put(
f"{QDRANT_URL}/collections/{QDRANT_COLLECTION}/index",
json={"field_name": field_name, "field_schema": "keyword"},
)
return True
logger.error("Failed to create Qdrant collection: %d", resp.status_code)
return False
except Exception as e:
logger.warning("Qdrant collection check error: %s", e)
return False
# ── Main Dedup Checker ───────────────────────────────────────────────
class ControlDedupChecker:
"""4-stage dedup checker for atomic controls.
Usage:
checker = ControlDedupChecker(db_session)
result = await checker.check_duplicate(candidate_action, candidate_object, candidate_title, pattern_id)
if result.verdict == "link":
checker.add_parent_link(result.matched_control_uuid, parent_uuid)
elif result.verdict == "review":
checker.write_review(candidate, result)
else:
# Insert new control
"""
def __init__(
self,
db,
embed_fn: Optional[Callable[[str], Awaitable[list[float]]]] = None,
search_fn: Optional[Callable] = None,
):
self.db = db
self._embed = embed_fn or get_embedding
self._search = search_fn or qdrant_search
self._cache: dict[str, list[dict]] = {} # pattern_id → existing controls
def _load_existing(self, pattern_id: str) -> list[dict]:
"""Load existing atomic controls with same pattern_id from DB."""
if pattern_id in self._cache:
return self._cache[pattern_id]
from sqlalchemy import text
rows = self.db.execute(text("""
SELECT id::text, control_id, title, objective,
pattern_id,
generation_metadata->>'obligation_type' as obligation_type
FROM canonical_controls
WHERE parent_control_uuid IS NOT NULL
AND release_state != 'deprecated'
AND pattern_id = :pid
"""), {"pid": pattern_id}).fetchall()
result = [
{
"uuid": r[0], "control_id": r[1], "title": r[2],
"objective": r[3], "pattern_id": r[4],
"obligation_type": r[5],
}
for r in rows
]
self._cache[pattern_id] = result
return result
async def check_duplicate(
self,
action: str,
obj: str,
title: str,
pattern_id: Optional[str],
) -> DedupResult:
"""Run the 4-stage dedup pipeline + cross-regulation linking.
Returns DedupResult with verdict: new/link/review.
"""
# No pattern_id → can't dedup meaningfully
if not pattern_id:
return DedupResult(verdict="new", stage="no_pattern")
# Stage 1: Pattern-Gate
existing = self._load_existing(pattern_id)
if not existing:
return DedupResult(
verdict="new", stage="pattern_gate",
details={"reason": "no existing controls with this pattern_id"},
)
# Stage 2: Action-Check
norm_action = normalize_action(action)
# We don't have action stored on existing controls from DB directly,
# so we use embedding for controls that passed pattern gate.
# But we CAN check via generation_metadata if available.
# Stage 3: Object-Normalization
norm_object = normalize_object(obj)
# Stage 4: Embedding Similarity
canonical = canonicalize_text(action, obj, title)
embedding = await self._embed(canonical)
if not embedding:
# Can't compute embedding → default to new
return DedupResult(
verdict="new", stage="embedding_unavailable",
details={"canonical_text": canonical},
)
# Search Qdrant
results = await self._search(embedding, pattern_id, top_k=5)
if not results:
# No intra-pattern matches → try cross-regulation
return await self._check_cross_regulation(embedding, DedupResult(
verdict="new", stage="no_qdrant_matches",
details={"canonical_text": canonical, "action": norm_action, "object": norm_object},
))
# Evaluate best match
best = results[0]
best_score = best.get("score", 0.0)
best_payload = best.get("payload", {})
best_action = best_payload.get("action_normalized", "")
best_object = best_payload.get("object_normalized", "")
# Action differs → NEW (even if embedding is high)
if best_action and norm_action and best_action != norm_action:
return await self._check_cross_regulation(embedding, DedupResult(
verdict="new", stage="action_mismatch",
similarity_score=best_score,
matched_control_id=best_payload.get("control_id"),
details={
"candidate_action": norm_action,
"existing_action": best_action,
"similarity": best_score,
},
))
# Object differs → use higher threshold
if best_object and norm_object and best_object != norm_object:
if best_score > LINK_THRESHOLD_DIFF_OBJECT:
return DedupResult(
verdict="link", stage="embedding_diff_object",
matched_control_uuid=best_payload.get("control_uuid"),
matched_control_id=best_payload.get("control_id"),
matched_title=best_payload.get("title"),
similarity_score=best_score,
details={"candidate_object": norm_object, "existing_object": best_object},
)
return await self._check_cross_regulation(embedding, DedupResult(
verdict="new", stage="object_mismatch_below_threshold",
similarity_score=best_score,
matched_control_id=best_payload.get("control_id"),
details={
"candidate_object": norm_object,
"existing_object": best_object,
"threshold": LINK_THRESHOLD_DIFF_OBJECT,
},
))
# Same action + same object → tiered thresholds
if best_score > LINK_THRESHOLD:
return DedupResult(
verdict="link", stage="embedding_match",
matched_control_uuid=best_payload.get("control_uuid"),
matched_control_id=best_payload.get("control_id"),
matched_title=best_payload.get("title"),
similarity_score=best_score,
)
if best_score > REVIEW_THRESHOLD:
return DedupResult(
verdict="review", stage="embedding_review",
matched_control_uuid=best_payload.get("control_uuid"),
matched_control_id=best_payload.get("control_id"),
matched_title=best_payload.get("title"),
similarity_score=best_score,
)
return await self._check_cross_regulation(embedding, DedupResult(
verdict="new", stage="embedding_below_threshold",
similarity_score=best_score,
details={"threshold": REVIEW_THRESHOLD},
))
async def _check_cross_regulation(
self,
embedding: list[float],
intra_result: DedupResult,
) -> DedupResult:
"""Second pass: cross-regulation linking for controls deemed 'new'.
Searches Qdrant WITHOUT pattern_id filter. Uses a higher threshold
(0.95) to avoid false positives across regulation boundaries.
"""
if intra_result.verdict != "new" or not embedding:
return intra_result
cross_results = await qdrant_search_cross_regulation(embedding, top_k=5)
if not cross_results:
return intra_result
best = cross_results[0]
best_score = best.get("score", 0.0)
if best_score > CROSS_REG_LINK_THRESHOLD:
best_payload = best.get("payload", {})
return DedupResult(
verdict="link",
stage="cross_regulation",
matched_control_uuid=best_payload.get("control_uuid"),
matched_control_id=best_payload.get("control_id"),
matched_title=best_payload.get("title"),
similarity_score=best_score,
link_type="cross_regulation",
details={
"cross_reg_score": best_score,
"cross_reg_threshold": CROSS_REG_LINK_THRESHOLD,
},
)
return intra_result
def add_parent_link(
self,
control_uuid: str,
parent_control_uuid: str,
link_type: str = "dedup_merge",
confidence: float = 0.0,
source_regulation: Optional[str] = None,
source_article: Optional[str] = None,
obligation_candidate_id: Optional[str] = None,
) -> None:
"""Add a parent link to an existing atomic control."""
from sqlalchemy import text
self.db.execute(text("""
INSERT INTO control_parent_links
(control_uuid, parent_control_uuid, link_type, confidence,
source_regulation, source_article, obligation_candidate_id)
VALUES (:cu, :pu, :lt, :conf, :sr, :sa, :oci::uuid)
ON CONFLICT (control_uuid, parent_control_uuid) DO NOTHING
"""), {
"cu": control_uuid,
"pu": parent_control_uuid,
"lt": link_type,
"conf": confidence,
"sr": source_regulation,
"sa": source_article,
"oci": obligation_candidate_id,
})
self.db.commit()
def write_review(
self,
candidate_control_id: str,
candidate_title: str,
candidate_objective: str,
result: DedupResult,
parent_control_uuid: Optional[str] = None,
obligation_candidate_id: Optional[str] = None,
) -> None:
"""Write a dedup review queue entry."""
from sqlalchemy import text
self.db.execute(text("""
INSERT INTO control_dedup_reviews
(candidate_control_id, candidate_title, candidate_objective,
matched_control_uuid, matched_control_id,
similarity_score, dedup_stage, dedup_details,
parent_control_uuid, obligation_candidate_id)
VALUES (:ccid, :ct, :co, :mcu::uuid, :mci, :ss, :ds,
:dd::jsonb, :pcu::uuid, :oci)
"""), {
"ccid": candidate_control_id,
"ct": candidate_title,
"co": candidate_objective,
"mcu": result.matched_control_uuid,
"mci": result.matched_control_id,
"ss": result.similarity_score,
"ds": result.stage,
"dd": __import__("json").dumps(result.details),
"pcu": parent_control_uuid,
"oci": obligation_candidate_id,
})
self.db.commit()
async def index_control(
self,
control_uuid: str,
control_id: str,
title: str,
action: str,
obj: str,
pattern_id: str,
) -> bool:
"""Index a new atomic control in Qdrant for future dedup checks."""
norm_action = normalize_action(action)
norm_object = normalize_object(obj)
canonical = canonicalize_text(action, obj, title)
embedding = await self._embed(canonical)
if not embedding:
return False
return await qdrant_upsert(
point_id=control_uuid,
embedding=embedding,
payload={
"control_uuid": control_uuid,
"control_id": control_id,
"title": title,
"pattern_id": pattern_id,
"action_normalized": norm_action,
"object_normalized": norm_object,
"canonical_text": canonical,
},
)

View File

@@ -75,12 +75,12 @@ REGULATION_LICENSE_MAP: dict[str, dict] = {
# RULE 1: FREE USE — Laws, Public Domain
# source_type: "law" = binding legislation, "guideline" = authority guidance (soft law),
# "standard" = voluntary framework/best practice, "restricted" = protected norm
# EU Regulations
"eu_2016_679": {"license": "EU_LAW", "rule": 1, "source_type": "law", "name": "DSGVO"},
"eu_2024_1689": {"license": "EU_LAW", "rule": 1, "source_type": "law", "name": "AI Act (KI-Verordnung)"},
"eu_2022_2555": {"license": "EU_LAW", "rule": 1, "source_type": "law", "name": "NIS2"},
# EU Regulations — names MUST match canonical DB source names
"eu_2016_679": {"license": "EU_LAW", "rule": 1, "source_type": "law", "name": "DSGVO (EU) 2016/679"},
"eu_2024_1689": {"license": "EU_LAW", "rule": 1, "source_type": "law", "name": "KI-Verordnung (EU) 2024/1689"},
"eu_2022_2555": {"license": "EU_LAW", "rule": 1, "source_type": "law", "name": "NIS2-Richtlinie (EU) 2022/2555"},
"eu_2024_2847": {"license": "EU_LAW", "rule": 1, "source_type": "law", "name": "Cyber Resilience Act (CRA)"},
"eu_2023_1230": {"license": "EU_LAW", "rule": 1, "source_type": "law", "name": "Maschinenverordnung"},
"eu_2023_1230": {"license": "EU_LAW", "rule": 1, "source_type": "law", "name": "Maschinenverordnung (EU) 2023/1230"},
"eu_2022_2065": {"license": "EU_LAW", "rule": 1, "source_type": "law", "name": "Digital Services Act (DSA)"},
"eu_2022_1925": {"license": "EU_LAW", "rule": 1, "source_type": "law", "name": "Digital Markets Act (DMA)"},
"eu_2022_868": {"license": "EU_LAW", "rule": 1, "source_type": "law", "name": "Data Governance Act (DGA)"},
@@ -88,52 +88,52 @@ REGULATION_LICENSE_MAP: dict[str, dict] = {
"eu_2021_914": {"license": "EU_LAW", "rule": 1, "source_type": "law", "name": "Standardvertragsklauseln (SCC)"},
"eu_2002_58": {"license": "EU_LAW", "rule": 1, "source_type": "law", "name": "ePrivacy-Richtlinie"},
"eu_2000_31": {"license": "EU_LAW", "rule": 1, "source_type": "law", "name": "E-Commerce-Richtlinie"},
"eu_2023_1803": {"license": "EU_LAW", "rule": 1, "source_type": "law", "name": "IFRS-Uebernahmeverordnung"},
"eu_2023_1803": {"license": "EU_LAW", "rule": 1, "source_type": "law", "name": "IFRS-Übernahmeverordnung"},
"eucsa": {"license": "EU_LAW", "rule": 1, "source_type": "law", "name": "EU Cybersecurity Act"},
"dataact": {"license": "EU_LAW", "rule": 1, "source_type": "law", "name": "Data Act"},
"dora": {"license": "EU_LAW", "rule": 1, "source_type": "law", "name": "Digital Operational Resilience Act"},
"ehds": {"license": "EU_LAW", "rule": 1, "source_type": "law", "name": "European Health Data Space"},
"gpsr": {"license": "EU_LAW", "rule": 1, "source_type": "law", "name": "Allgemeine Produktsicherheitsverordnung"},
"eu_2023_988": {"license": "EU_LAW", "rule": 1, "source_type": "law", "name": "Allgemeine Produktsicherheitsverordnung (GPSR)"},
"eu_2023_1542": {"license": "EU_LAW", "rule": 1, "source_type": "law", "name": "Batterieverordnung"},
"mica": {"license": "EU_LAW", "rule": 1, "source_type": "law", "name": "Markets in Crypto-Assets"},
"eu_2023_1542": {"license": "EU_LAW", "rule": 1, "source_type": "law", "name": "Batterieverordnung (EU) 2023/1542"},
"mica": {"license": "EU_LAW", "rule": 1, "source_type": "law", "name": "Markets in Crypto-Assets (MiCA)"},
"psd2": {"license": "EU_LAW", "rule": 1, "source_type": "law", "name": "Zahlungsdiensterichtlinie 2"},
"dpf": {"license": "EU_LAW", "rule": 1, "source_type": "law", "name": "EU-US Data Privacy Framework"},
"dsm": {"license": "EU_LAW", "rule": 1, "source_type": "law", "name": "DSM-Urheberrechtsrichtlinie"},
"amlr": {"license": "EU_LAW", "rule": 1, "source_type": "law", "name": "AML-Verordnung"},
"eu_blue_guide_2022": {"license": "EU_PUBLIC", "rule": 1, "source_type": "guideline", "name": "Blue Guide 2022"},
"eu_blue_guide_2022": {"license": "EU_PUBLIC", "rule": 1, "source_type": "guideline", "name": "EU Blue Guide 2022"},
# NIST (Public Domain — NOT laws, voluntary standards)
"nist_sp_800_53": {"license": "NIST_PUBLIC_DOMAIN", "rule": 1, "source_type": "standard", "name": "NIST SP 800-53"},
"nist_sp800_53r5": {"license": "NIST_PUBLIC_DOMAIN", "rule": 1, "source_type": "standard", "name": "NIST SP 800-53 Rev.5"},
"nist_sp_800_63b": {"license": "NIST_PUBLIC_DOMAIN", "rule": 1, "source_type": "standard", "name": "NIST SP 800-63B"},
"nist_sp_800_53": {"license": "NIST_PUBLIC_DOMAIN", "rule": 1, "source_type": "standard", "name": "NIST SP 800-53 Rev. 5"},
"nist_sp800_53r5": {"license": "NIST_PUBLIC_DOMAIN", "rule": 1, "source_type": "standard", "name": "NIST SP 800-53 Rev. 5"},
"nist_sp_800_63b": {"license": "NIST_PUBLIC_DOMAIN", "rule": 1, "source_type": "standard", "name": "NIST SP 800-63-3"},
"nist_sp800_63_3": {"license": "NIST_PUBLIC_DOMAIN", "rule": 1, "source_type": "standard", "name": "NIST SP 800-63-3"},
"nist_csf_2_0": {"license": "NIST_PUBLIC_DOMAIN", "rule": 1, "source_type": "standard", "name": "NIST CSF 2.0"},
"nist_sp_800_218": {"license": "NIST_PUBLIC_DOMAIN", "rule": 1, "source_type": "standard", "name": "NIST SSDF"},
"nist_sp800_218": {"license": "NIST_PUBLIC_DOMAIN", "rule": 1, "source_type": "standard", "name": "NIST SSDF"},
"nist_sp800_207": {"license": "NIST_PUBLIC_DOMAIN", "rule": 1, "source_type": "standard", "name": "NIST SP 800-207 Zero Trust"},
"nist_csf_2_0": {"license": "NIST_PUBLIC_DOMAIN", "rule": 1, "source_type": "standard", "name": "NIST Cybersecurity Framework 2.0"},
"nist_sp_800_218": {"license": "NIST_PUBLIC_DOMAIN", "rule": 1, "source_type": "standard", "name": "NIST SP 800-218 (SSDF)"},
"nist_sp800_218": {"license": "NIST_PUBLIC_DOMAIN", "rule": 1, "source_type": "standard", "name": "NIST SP 800-218 (SSDF)"},
"nist_sp800_207": {"license": "NIST_PUBLIC_DOMAIN", "rule": 1, "source_type": "standard", "name": "NIST SP 800-207 (Zero Trust)"},
"nist_ai_rmf": {"license": "NIST_PUBLIC_DOMAIN", "rule": 1, "source_type": "standard", "name": "NIST AI Risk Management Framework"},
"nist_privacy_1_0": {"license": "NIST_PUBLIC_DOMAIN", "rule": 1, "source_type": "standard", "name": "NIST Privacy Framework 1.0"},
"nistir_8259a": {"license": "NIST_PUBLIC_DOMAIN", "rule": 1, "source_type": "standard", "name": "NISTIR 8259A IoT Security"},
"cisa_secure_by_design": {"license": "US_GOV_PUBLIC", "rule": 1, "source_type": "standard", "name": "CISA Secure by Design"},
# German Laws
"bdsg": {"license": "DE_LAW", "rule": 1, "source_type": "law", "name": "BDSG"},
"bdsg_2018_komplett": {"license": "DE_LAW", "rule": 1, "source_type": "law", "name": "BDSG 2018"},
"bdsg": {"license": "DE_LAW", "rule": 1, "source_type": "law", "name": "Bundesdatenschutzgesetz (BDSG)"},
"bdsg_2018_komplett": {"license": "DE_LAW", "rule": 1, "source_type": "law", "name": "Bundesdatenschutzgesetz (BDSG)"},
"ttdsg": {"license": "DE_LAW", "rule": 1, "source_type": "law", "name": "TTDSG"},
"tdddg_25": {"license": "DE_LAW", "rule": 1, "source_type": "law", "name": "TDDDG"},
"tkg": {"license": "DE_LAW", "rule": 1, "source_type": "law", "name": "TKG"},
"de_tkg": {"license": "DE_LAW", "rule": 1, "source_type": "law", "name": "TKG"},
"bgb_komplett": {"license": "DE_LAW", "rule": 1, "source_type": "law", "name": "BGB"},
"hgb": {"license": "DE_LAW", "rule": 1, "source_type": "law", "name": "HGB"},
"hgb_komplett": {"license": "DE_LAW", "rule": 1, "source_type": "law", "name": "HGB"},
"hgb": {"license": "DE_LAW", "rule": 1, "source_type": "law", "name": "Handelsgesetzbuch (HGB)"},
"hgb_komplett": {"license": "DE_LAW", "rule": 1, "source_type": "law", "name": "Handelsgesetzbuch (HGB)"},
"urhg_komplett": {"license": "DE_LAW", "rule": 1, "source_type": "law", "name": "UrhG"},
"uwg": {"license": "DE_LAW", "rule": 1, "source_type": "law", "name": "UWG"},
"tmg_komplett": {"license": "DE_LAW", "rule": 1, "source_type": "law", "name": "TMG"},
"gewo": {"license": "DE_LAW", "rule": 1, "source_type": "law", "name": "GewO"},
"ao": {"license": "DE_LAW", "rule": 1, "source_type": "law", "name": "Abgabenordnung"},
"ao_komplett": {"license": "DE_LAW", "rule": 1, "source_type": "law", "name": "Abgabenordnung"},
"gewo": {"license": "DE_LAW", "rule": 1, "source_type": "law", "name": "Gewerbeordnung (GewO)"},
"ao": {"license": "DE_LAW", "rule": 1, "source_type": "law", "name": "Abgabenordnung (AO)"},
"ao_komplett": {"license": "DE_LAW", "rule": 1, "source_type": "law", "name": "Abgabenordnung (AO)"},
"battdg": {"license": "DE_LAW", "rule": 1, "source_type": "law", "name": "Batteriegesetz"},
# Austrian Laws
"at_dsg": {"license": "AT_LAW", "rule": 1, "source_type": "law", "name": "AT DSG"},
"at_dsg": {"license": "AT_LAW", "rule": 1, "source_type": "law", "name": "Österreichisches Datenschutzgesetz (DSG)"},
"at_abgb": {"license": "AT_LAW", "rule": 1, "source_type": "law", "name": "AT ABGB"},
"at_abgb_agb": {"license": "AT_LAW", "rule": 1, "source_type": "law", "name": "AT ABGB AGB-Recht"},
"at_bao": {"license": "AT_LAW", "rule": 1, "source_type": "law", "name": "AT BAO"},
@@ -141,7 +141,7 @@ REGULATION_LICENSE_MAP: dict[str, dict] = {
"at_ecg": {"license": "AT_LAW", "rule": 1, "source_type": "law", "name": "AT E-Commerce-Gesetz"},
"at_kschg": {"license": "AT_LAW", "rule": 1, "source_type": "law", "name": "AT Konsumentenschutzgesetz"},
"at_medieng": {"license": "AT_LAW", "rule": 1, "source_type": "law", "name": "AT Mediengesetz"},
"at_tkg": {"license": "AT_LAW", "rule": 1, "source_type": "law", "name": "AT TKG"},
"at_tkg": {"license": "AT_LAW", "rule": 1, "source_type": "law", "name": "Telekommunikationsgesetz Oesterreich"},
"at_ugb": {"license": "AT_LAW", "rule": 1, "source_type": "law", "name": "AT UGB"},
"at_ugb_ret": {"license": "AT_LAW", "rule": 1, "source_type": "law", "name": "AT UGB Retention"},
"at_uwg": {"license": "AT_LAW", "rule": 1, "source_type": "law", "name": "AT UWG"},
@@ -179,21 +179,21 @@ REGULATION_LICENSE_MAP: dict[str, dict] = {
"wp260_transparency": {"license": "EU_PUBLIC", "rule": 1, "source_type": "guideline", "name": "WP29 Transparency"},
# RULE 2: CITATION REQUIRED — CC-BY, CC-BY-SA (voluntary standards)
"owasp_asvs": {"license": "CC-BY-SA-4.0", "rule": 2, "source_type": "standard", "name": "OWASP ASVS",
"owasp_asvs": {"license": "CC-BY-SA-4.0", "rule": 2, "source_type": "standard", "name": "OWASP ASVS 4.0",
"attribution": "OWASP Foundation, CC BY-SA 4.0"},
"owasp_masvs": {"license": "CC-BY-SA-4.0", "rule": 2, "source_type": "standard", "name": "OWASP MASVS",
"owasp_masvs": {"license": "CC-BY-SA-4.0", "rule": 2, "source_type": "standard", "name": "OWASP MASVS 2.0",
"attribution": "OWASP Foundation, CC BY-SA 4.0"},
"owasp_top10": {"license": "CC-BY-SA-4.0", "rule": 2, "source_type": "standard", "name": "OWASP Top 10",
"owasp_top10": {"license": "CC-BY-SA-4.0", "rule": 2, "source_type": "standard", "name": "OWASP Top 10 (2021)",
"attribution": "OWASP Foundation, CC BY-SA 4.0"},
"owasp_top10_2021": {"license": "CC-BY-SA-4.0", "rule": 2, "source_type": "standard", "name": "OWASP Top 10 2021",
"owasp_top10_2021": {"license": "CC-BY-SA-4.0", "rule": 2, "source_type": "standard", "name": "OWASP Top 10 (2021)",
"attribution": "OWASP Foundation, CC BY-SA 4.0"},
"owasp_api_top10_2023": {"license": "CC-BY-SA-4.0", "rule": 2, "source_type": "standard", "name": "OWASP API Top 10 2023",
"owasp_api_top10_2023": {"license": "CC-BY-SA-4.0", "rule": 2, "source_type": "standard", "name": "OWASP API Security Top 10 (2023)",
"attribution": "OWASP Foundation, CC BY-SA 4.0"},
"owasp_samm": {"license": "CC-BY-SA-4.0", "rule": 2, "source_type": "standard", "name": "OWASP SAMM",
"owasp_samm": {"license": "CC-BY-SA-4.0", "rule": 2, "source_type": "standard", "name": "OWASP SAMM 2.0",
"attribution": "OWASP Foundation, CC BY-SA 4.0"},
"owasp_mobile_top10": {"license": "CC-BY-SA-4.0", "rule": 2, "source_type": "standard", "name": "OWASP Mobile Top 10",
"attribution": "OWASP Foundation, CC BY-SA 4.0"},
"oecd_ai_principles": {"license": "OECD_PUBLIC", "rule": 2, "source_type": "standard", "name": "OECD AI Principles",
"oecd_ai_principles": {"license": "OECD_PUBLIC", "rule": 2, "source_type": "standard", "name": "OECD KI-Empfehlung",
"attribution": "OECD"},
# RULE 3: RESTRICTED — Full reformulation required
@@ -626,6 +626,7 @@ async def _llm_ollama(prompt: str, system_prompt: Optional[str] = None) -> str:
"model": OLLAMA_MODEL,
"messages": messages,
"stream": False,
"format": "json",
"options": {"num_predict": 512}, # Limit response length for speed
"think": False, # Disable thinking for faster responses
}
@@ -1040,8 +1041,10 @@ Quelle: {chunk.regulation_name} ({chunk.regulation_code}), {chunk.article}"""
effective_paragraph = llm_paragraph or chunk.paragraph or ""
control.license_rule = 1
control.source_original_text = chunk.text
# Use canonical name from REGULATION_LICENSE_MAP, not Qdrant's regulation_name
canonical_source = license_info.get("name", chunk.regulation_name)
control.source_citation = {
"source": chunk.regulation_name,
"source": canonical_source,
"article": effective_article,
"paragraph": effective_paragraph,
"license": license_info.get("license", ""),
@@ -1105,8 +1108,10 @@ Quelle: {chunk.regulation_name}, {chunk.article}"""
effective_paragraph = llm_paragraph or chunk.paragraph or ""
control.license_rule = 2
control.source_original_text = chunk.text
# Use canonical name from REGULATION_LICENSE_MAP, not Qdrant's regulation_name
canonical_source = license_info.get("name", chunk.regulation_name)
control.source_citation = {
"source": chunk.regulation_name,
"source": canonical_source,
"article": effective_article,
"paragraph": effective_paragraph,
"license": license_info.get("license", ""),
@@ -1277,8 +1282,10 @@ Gib ein JSON-Array zurueck mit GENAU {len(chunks)} Elementen. Fuer Chunks ohne A
effective_paragraph = llm_paragraph or chunk.paragraph or ""
if lic["rule"] in (1, 2):
control.source_original_text = chunk.text
# Use canonical name from REGULATION_LICENSE_MAP, not Qdrant's regulation_name
canonical_source = lic.get("name", chunk.regulation_name)
control.source_citation = {
"source": chunk.regulation_name,
"source": canonical_source,
"article": effective_article,
"paragraph": effective_paragraph,
"license": lic.get("license", ""),

View File

@@ -46,20 +46,62 @@ ANTHROPIC_API_URL = "https://api.anthropic.com/v1"
# ---------------------------------------------------------------------------
# Normative signal detection (Rule 1)
# Normative signal detection — 3-Tier Classification
# ---------------------------------------------------------------------------
# Tier 1: Pflicht (mandatory) — strong normative signals
# Tier 2: Empfehlung (recommendation) — weaker normative signals
# Tier 3: Kann (optional/permissive) — permissive signals
# Nothing is rejected — everything is classified.
_NORMATIVE_SIGNALS = [
_PFLICHT_SIGNALS = [
# Deutsche modale Pflichtformulierungen
r"\bmüssen\b", r"\bmuss\b", r"\bhat\s+sicherzustellen\b",
r"\bhaben\s+sicherzustellen\b", r"\bsind\s+verpflichtet\b",
r"\bist\s+verpflichtet\b", r"\bist\s+zu\s+\w+en\b",
r"\bsind\s+zu\s+\w+en\b", r"\bhat\s+zu\s+\w+en\b",
r"\bhaben\s+zu\s+\w+en\b", r"\bsoll\b", r"\bsollen\b",
r"\bgewährleisten\b", r"\bsicherstellen\b",
r"\bist\s+verpflichtet\b",
# "ist zu prüfen", "sind zu dokumentieren" (direkt)
r"\bist\s+zu\s+\w+en\b", r"\bsind\s+zu\s+\w+en\b",
r"\bhat\s+zu\s+\w+en\b", r"\bhaben\s+zu\s+\w+en\b",
# "ist festzustellen", "sind vorzunehmen" (Compound-Verben, eingebettetes zu)
r"\bist\s+\w+zu\w+en\b", r"\bsind\s+\w+zu\w+en\b",
# "ist zusätzlich zu prüfen", "sind regelmäßig zu überwachen" (Adverb dazwischen)
r"\bist\s+\w+\s+zu\s+\w+en\b", r"\bsind\s+\w+\s+zu\s+\w+en\b",
r"\bhat\s+\w+\s+zu\s+\w+en\b", r"\bhaben\s+\w+\s+zu\s+\w+en\b",
# Englische Pflicht-Signale
r"\bshall\b", r"\bmust\b", r"\brequired\b",
r"\bshould\b", r"\bensure\b",
# Compound-Infinitive (Gerundivum): mitzuteilen, anzuwenden, bereitzustellen
r"\b\w+zuteilen\b", r"\b\w+zuwenden\b", r"\b\w+zustellen\b", r"\b\w+zulegen\b",
r"\b\w+zunehmen\b", r"\b\w+zuführen\b", r"\b\w+zuhalten\b", r"\b\w+zusetzen\b",
r"\b\w+zuweisen\b", r"\b\w+zuordnen\b", r"\b\w+zufügen\b", r"\b\w+zugeben\b",
# Breites Pattern: "ist ... [bis 80 Zeichen] ... zu + Infinitiv"
r"\bist\b.{1,80}\bzu\s+\w+en\b", r"\bsind\b.{1,80}\bzu\s+\w+en\b",
]
_NORMATIVE_RE = re.compile("|".join(_NORMATIVE_SIGNALS), re.IGNORECASE)
_PFLICHT_RE = re.compile("|".join(_PFLICHT_SIGNALS), re.IGNORECASE)
_EMPFEHLUNG_SIGNALS = [
# Modale Verben (schwaecher als "muss")
r"\bsoll\b", r"\bsollen\b", r"\bsollte\b", r"\bsollten\b",
r"\bgewährleisten\b", r"\bsicherstellen\b",
# Englische Empfehlungs-Signale
r"\bshould\b", r"\bensure\b", r"\brecommend\w*\b",
# Haeufige normative Infinitive (ohne Hilfsverb, als Empfehlung)
r"\bnachweisen\b", r"\beinhalten\b", r"\bunterlassen\b", r"\bwahren\b",
r"\bdokumentieren\b", r"\bimplementieren\b", r"\büberprüfen\b", r"\büberwachen\b",
# Pruefanweisungen als normative Aussage
r"\bprüfen,\s+ob\b", r"\bkontrollieren,\s+ob\b",
]
_EMPFEHLUNG_RE = re.compile("|".join(_EMPFEHLUNG_SIGNALS), re.IGNORECASE)
_KANN_SIGNALS = [
r"\bkann\b", r"\bkönnen\b", r"\bdarf\b", r"\bdürfen\b",
r"\bmay\b", r"\boptional\b",
]
_KANN_RE = re.compile("|".join(_KANN_SIGNALS), re.IGNORECASE)
# Union of all normative signals (for backward-compatible has_normative_signal flag)
_NORMATIVE_RE = re.compile(
"|".join(_PFLICHT_SIGNALS + _EMPFEHLUNG_SIGNALS + _KANN_SIGNALS),
re.IGNORECASE,
)
_RATIONALE_SIGNALS = [
r"\bda\s+", r"\bweil\b", r"\bgrund\b", r"\berwägung",
@@ -100,6 +142,7 @@ class ObligationCandidate:
object_: str = ""
condition: Optional[str] = None
normative_strength: str = "must"
obligation_type: str = "pflicht" # pflicht | empfehlung | kann
is_test_obligation: bool = False
is_reporting_obligation: bool = False
extraction_confidence: float = 0.0
@@ -115,6 +158,7 @@ class ObligationCandidate:
"object": self.object_,
"condition": self.condition,
"normative_strength": self.normative_strength,
"obligation_type": self.obligation_type,
"is_test_obligation": self.is_test_obligation,
"is_reporting_obligation": self.is_reporting_obligation,
"extraction_confidence": self.extraction_confidence,
@@ -162,11 +206,30 @@ class AtomicControlCandidate:
# ---------------------------------------------------------------------------
def classify_obligation_type(txt: str) -> str:
"""Classify obligation text into pflicht/empfehlung/kann.
Priority: pflicht > empfehlung > kann > empfehlung (default).
Nothing is rejected — obligations without normative signal default
to 'empfehlung' (recommendation).
"""
if _PFLICHT_RE.search(txt):
return "pflicht"
if _EMPFEHLUNG_RE.search(txt):
return "empfehlung"
if _KANN_RE.search(txt):
return "kann"
# No signal at all — LLM thought it was an obligation, classify
# as recommendation (the user can still use it).
return "empfehlung"
def quality_gate(candidate: ObligationCandidate) -> dict:
"""Validate an obligation candidate. Returns quality flags dict.
Checks:
has_normative_signal: text contains normative language
has_normative_signal: text contains normative language (informational)
obligation_type: pflicht | empfehlung | kann (classified, never rejected)
single_action: only one main action (heuristic)
not_rationale: not just a justification/reasoning
not_evidence_only: not just an evidence requirement
@@ -176,9 +239,12 @@ def quality_gate(candidate: ObligationCandidate) -> dict:
txt = candidate.obligation_text
flags = {}
# 1. Normative signal
# 1. Normative signal (informational — no longer used for rejection)
flags["has_normative_signal"] = bool(_NORMATIVE_RE.search(txt))
# 1b. Obligation type classification
flags["obligation_type"] = classify_obligation_type(txt)
# 2. Single action heuristic — count "und" / "and" / "sowie" splits
# that connect different verbs (imperfect but useful)
multi_verb_re = re.compile(
@@ -210,8 +276,12 @@ def quality_gate(candidate: ObligationCandidate) -> dict:
def passes_quality_gate(flags: dict) -> bool:
"""Check if all critical quality flags pass."""
critical = ["has_normative_signal", "not_evidence_only", "min_length", "has_parent_link"]
"""Check if critical quality flags pass.
Note: has_normative_signal is NO LONGER critical — obligations without
normative signal are classified as 'empfehlung' instead of being rejected.
"""
critical = ["not_evidence_only", "min_length", "has_parent_link"]
return all(flags.get(k, False) for k in critical)
@@ -224,6 +294,13 @@ _PASS0A_SYSTEM_PROMPT = """\
Du bist ein Rechts-Compliance-Experte. Du zerlegst Compliance-Controls \
in einzelne atomare Pflichten.
ANALYSE-SCHRITTE (intern durchfuehren, NICHT im Output!):
1. Identifiziere den Adressaten (Wer muss handeln?)
2. Identifiziere die Handlung (Was muss getan werden?)
3. Bestimme die normative Staerke (muss/soll/kann)
4. Pruefe ob Test- oder Meldepflicht vorliegt (separat erfassen!)
5. Formuliere jede Pflicht als eigenstaendiges JSON-Objekt
REGELN (STRIKT EINHALTEN):
1. Nur normative Aussagen extrahieren — erkennbar an: müssen, haben \
sicherzustellen, sind verpflichtet, ist zu dokumentieren, ist zu melden, \
@@ -272,6 +349,12 @@ _PASS0B_SYSTEM_PROMPT = """\
Du bist ein Security-Compliance-Experte. Du erstellst aus einer einzelnen \
normativen Pflicht ein praxisorientiertes, atomares Security Control.
ANALYSE-SCHRITTE (intern durchfuehren, NICHT im Output!):
1. Identifiziere die konkrete Anforderung aus der Pflicht
2. Leite eine umsetzbare technische/organisatorische Massnahme ab
3. Definiere ein Pruefverfahren (wie wird Umsetzung verifiziert?)
4. Bestimme den Nachweis (welches Dokument/Artefakt belegt Compliance?)
Das Control muss UMSETZBAR sein — keine Gesetzesparaphrase.
Antworte NUR als JSON. Keine Erklärungen."""
@@ -603,8 +686,15 @@ class DecompositionPass:
stats_0b = await decomp.run_pass0b(limit=100)
"""
def __init__(self, db: Session):
def __init__(self, db: Session, dedup_enabled: bool = False):
self.db = db
self._dedup = None
if dedup_enabled:
from compliance.services.control_dedup import (
ControlDedupChecker, DEDUP_ENABLED,
)
if DEDUP_ENABLED:
self._dedup = ControlDedupChecker(db)
# -------------------------------------------------------------------
# Pass 0a: Obligation Extraction
@@ -810,10 +900,11 @@ class DecompositionPass:
if not cand.is_reporting_obligation and _REPORTING_RE.search(cand.obligation_text):
cand.is_reporting_obligation = True
# Quality gate
# Quality gate + obligation type classification
flags = quality_gate(cand)
cand.quality_flags = flags
cand.extraction_confidence = _compute_extraction_confidence(flags)
cand.obligation_type = flags.get("obligation_type", "empfehlung")
if passes_quality_gate(flags):
cand.release_state = "validated"
@@ -877,6 +968,9 @@ class DecompositionPass:
"errors": 0,
"provider": "anthropic" if use_anthropic else "ollama",
"batch_size": batch_size,
"dedup_enabled": self._dedup is not None,
"dedup_linked": 0,
"dedup_review": 0,
}
# Prepare obligation data
@@ -915,7 +1009,7 @@ class DecompositionPass:
results_by_id = _parse_json_object(llm_response)
for obl in batch:
parsed = results_by_id.get(obl["candidate_id"], {})
self._process_pass0b_control(obl, parsed, stats)
await self._process_pass0b_control(obl, parsed, stats)
elif use_anthropic:
obl = batch[0]
prompt = _build_pass0b_prompt(
@@ -931,7 +1025,7 @@ class DecompositionPass:
)
stats["llm_calls"] += 1
parsed = _parse_json_object(llm_response)
self._process_pass0b_control(obl, parsed, stats)
await self._process_pass0b_control(obl, parsed, stats)
else:
from compliance.services.obligation_extractor import _llm_ollama
obl = batch[0]
@@ -948,7 +1042,7 @@ class DecompositionPass:
)
stats["llm_calls"] += 1
parsed = _parse_json_object(llm_response)
self._process_pass0b_control(obl, parsed, stats)
await self._process_pass0b_control(obl, parsed, stats)
except Exception as e:
ids = ", ".join(o["candidate_id"] for o in batch)
@@ -959,10 +1053,16 @@ class DecompositionPass:
logger.info("Pass 0b: %s", stats)
return stats
def _process_pass0b_control(
async def _process_pass0b_control(
self, obl: dict, parsed: dict, stats: dict,
) -> None:
"""Create atomic control from parsed LLM output or template fallback."""
"""Create atomic control from parsed LLM output or template fallback.
If dedup is enabled, checks for duplicates before insertion:
- LINK: adds parent link to existing control instead of creating new
- REVIEW: queues for human review, does not create control
- NEW: creates new control and indexes in Qdrant
"""
if not parsed or not parsed.get("title"):
atomic = _template_fallback(
obligation_text=obl["obligation_text"],
@@ -990,6 +1090,56 @@ class DecompositionPass:
atomic.parent_control_uuid = obl["parent_uuid"]
atomic.obligation_candidate_id = obl["candidate_id"]
# ── Dedup check (if enabled) ────────────────────────────
if self._dedup:
pattern_id = None
# Try to get pattern_id from parent control
pid_row = self.db.execute(text(
"SELECT pattern_id FROM canonical_controls WHERE id = CAST(:uid AS uuid)"
), {"uid": obl["parent_uuid"]}).fetchone()
if pid_row:
pattern_id = pid_row[0]
result = await self._dedup.check_duplicate(
action=obl.get("action", ""),
obj=obl.get("object", ""),
title=atomic.title,
pattern_id=pattern_id,
)
if result.verdict == "link":
self._dedup.add_parent_link(
control_uuid=result.matched_control_uuid,
parent_control_uuid=obl["parent_uuid"],
link_type="dedup_merge",
confidence=result.similarity_score,
)
stats.setdefault("dedup_linked", 0)
stats["dedup_linked"] += 1
stats["candidates_processed"] += 1
logger.info("Dedup LINK: %s%s (%.3f, %s)",
atomic.title[:60], result.matched_control_id,
result.similarity_score, result.stage)
return
if result.verdict == "review":
self._dedup.write_review(
candidate_control_id=atomic.candidate_id or "",
candidate_title=atomic.title,
candidate_objective=atomic.objective,
result=result,
parent_control_uuid=obl["parent_uuid"],
obligation_candidate_id=obl.get("oc_id"),
)
stats.setdefault("dedup_review", 0)
stats["dedup_review"] += 1
stats["candidates_processed"] += 1
logger.info("Dedup REVIEW: %s%s (%.3f, %s)",
atomic.title[:60], result.matched_control_id,
result.similarity_score, result.stage)
return
# ── Create new atomic control ───────────────────────────
seq = self._next_atomic_seq(obl["parent_control_id"])
atomic.candidate_id = f"{obl['parent_control_id']}-A{seq:02d}"
@@ -1006,6 +1156,29 @@ class DecompositionPass:
{"oc_id": obl["oc_id"]},
)
# Index in Qdrant for future dedup checks
if self._dedup:
pattern_id_val = None
pid_row2 = self.db.execute(text(
"SELECT pattern_id FROM canonical_controls WHERE id = CAST(:uid AS uuid)"
), {"uid": obl["parent_uuid"]}).fetchone()
if pid_row2:
pattern_id_val = pid_row2[0]
# Get the UUID of the newly inserted control
new_row = self.db.execute(text(
"SELECT id::text FROM canonical_controls WHERE control_id = :cid ORDER BY created_at DESC LIMIT 1"
), {"cid": atomic.candidate_id}).fetchone()
if new_row and pattern_id_val:
await self._dedup.index_control(
control_uuid=new_row[0],
control_id=atomic.candidate_id,
title=atomic.title,
action=obl.get("action", ""),
obj=obl.get("object", ""),
pattern_id=pattern_id_val,
)
stats["controls_created"] += 1
stats["candidates_processed"] += 1
@@ -1415,7 +1588,7 @@ class DecompositionPass:
if pass_type == "0a":
self._handle_batch_result_0a(custom_id, text_content, stats)
else:
self._handle_batch_result_0b(custom_id, text_content, stats)
await self._handle_batch_result_0b(custom_id, text_content, stats)
except Exception as e:
logger.error("Processing batch result %s: %s", custom_id, e)
stats["errors"] += 1
@@ -1466,7 +1639,7 @@ class DecompositionPass:
self._process_pass0a_obligations(raw_obls, control_id, control_uuid, stats)
stats["controls_processed"] += 1
def _handle_batch_result_0b(
async def _handle_batch_result_0b(
self, custom_id: str, text_content: str, stats: dict,
) -> None:
"""Process a single Pass 0b batch result."""
@@ -1477,14 +1650,14 @@ class DecompositionPass:
parsed = _parse_json_object(text_content)
obl = self._load_obligation_for_0b(candidate_ids[0])
if obl:
self._process_pass0b_control(obl, parsed, stats)
await self._process_pass0b_control(obl, parsed, stats)
else:
results_by_id = _parse_json_object(text_content)
for cand_id in candidate_ids:
parsed = results_by_id.get(cand_id, {})
obl = self._load_obligation_for_0b(cand_id)
if obl:
self._process_pass0b_control(obl, parsed, stats)
await self._process_pass0b_control(obl, parsed, stats)
def _load_obligation_for_0b(self, candidate_id: str) -> Optional[dict]:
"""Load obligation data needed for Pass 0b processing."""

View File

@@ -524,6 +524,7 @@ async def _llm_ollama(prompt: str, system_prompt: Optional[str] = None) -> str:
"model": OLLAMA_MODEL,
"messages": messages,
"stream": False,
"format": "json",
"options": {"num_predict": 512},
"think": False,
}

View File

@@ -100,6 +100,40 @@ class ComplianceRAGClient:
logger.warning("RAG search failed: %s", e)
return []
async def search_with_rerank(
self,
query: str,
collection: str = "bp_compliance_ce",
regulations: Optional[List[str]] = None,
top_k: int = 5,
) -> List[RAGSearchResult]:
"""
Search with optional cross-encoder re-ranking.
Fetches top_k*4 results from RAG, then re-ranks with cross-encoder
and returns top_k. Falls back to regular search if reranker is disabled.
"""
from .reranker import get_reranker
reranker = get_reranker()
if reranker is None:
return await self.search(query, collection, regulations, top_k)
# Fetch more candidates for re-ranking
candidates = await self.search(
query, collection, regulations, top_k=max(top_k * 4, 20)
)
if not candidates:
return []
texts = [c.text for c in candidates]
try:
ranked_indices = reranker.rerank(query, texts, top_k=top_k)
return [candidates[i] for i in ranked_indices]
except Exception as e:
logger.warning("Reranking failed, returning unranked: %s", e)
return candidates[:top_k]
async def scroll(
self,
collection: str,

View File

@@ -0,0 +1,85 @@
"""
Cross-Encoder Re-Ranking for RAG Search Results.
Uses BGE Reranker v2 (BAAI/bge-reranker-v2-m3, MIT license) to re-rank
search results from Qdrant for improved retrieval quality.
Lazy-loads the model on first use. Disabled by default (RERANK_ENABLED=false).
"""
import logging
import os
from typing import Optional
logger = logging.getLogger(__name__)
RERANK_ENABLED = os.getenv("RERANK_ENABLED", "false").lower() == "true"
RERANK_MODEL = os.getenv("RERANK_MODEL", "BAAI/bge-reranker-v2-m3")
class Reranker:
"""Cross-encoder reranker using sentence-transformers."""
def __init__(self, model_name: str = RERANK_MODEL):
self._model = None # Lazy init
self._model_name = model_name
def _ensure_model(self) -> None:
"""Load model on first use."""
if self._model is not None:
return
try:
from sentence_transformers import CrossEncoder
logger.info("Loading reranker model: %s", self._model_name)
self._model = CrossEncoder(self._model_name)
logger.info("Reranker model loaded successfully")
except ImportError:
logger.error(
"sentence-transformers not installed. "
"Install with: pip install sentence-transformers"
)
raise
except Exception as e:
logger.error("Failed to load reranker model: %s", e)
raise
def rerank(
self, query: str, texts: list[str], top_k: int = 5
) -> list[int]:
"""
Return indices of top_k texts sorted by relevance (highest first).
Args:
query: The search query.
texts: List of candidate texts to re-rank.
top_k: Number of top results to return.
Returns:
List of indices into the original texts list, sorted by relevance.
"""
if not texts:
return []
self._ensure_model()
pairs = [[query, text] for text in texts]
scores = self._model.predict(pairs)
# Sort by score descending, return indices
ranked = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True)
return ranked[:top_k]
# Module-level singleton
_reranker: Optional[Reranker] = None
def get_reranker() -> Optional[Reranker]:
"""Get the shared reranker instance. Returns None if disabled."""
global _reranker
if not RERANK_ENABLED:
return None
if _reranker is None:
_reranker = Reranker()
return _reranker

View File

@@ -22,6 +22,11 @@ python-multipart>=0.0.22
# AI / Anthropic (compliance AI assistant)
anthropic==0.75.0
# Re-Ranking (cross-encoder, CPU-only PyTorch to keep image small)
--extra-index-url https://download.pytorch.org/whl/cpu
torch
sentence-transformers>=3.0.0
# PDF Generation (GDPR export, audit reports)
weasyprint>=68.0
reportlab==4.2.5

View File

@@ -219,3 +219,36 @@ class TestCitationBackfillMatching:
sql_text = str(self.db.execute.call_args[0][0].text)
assert "license_rule IN (1, 2)" in sql_text
assert "source_citation IS NOT NULL" in sql_text
# =============================================================================
# Tests: Ollama JSON-Mode
# =============================================================================
class TestOllamaJsonMode:
"""Verify that citation_backfill Ollama payloads include format=json."""
@pytest.mark.asyncio
async def test_ollama_payload_contains_format_json(self):
"""_llm_ollama must send format='json' in the request payload."""
from compliance.services.citation_backfill import _llm_ollama
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"message": {"content": '{"article": "Art. 1"}'}
}
with patch("compliance.services.citation_backfill.httpx.AsyncClient") as mock_cls:
mock_client = AsyncMock()
mock_client.post.return_value = mock_response
mock_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
mock_cls.return_value.__aexit__ = AsyncMock(return_value=False)
await _llm_ollama("test prompt", "system prompt")
mock_client.post.assert_called_once()
call_kwargs = mock_client.post.call_args
payload = call_kwargs.kwargs.get("json") or call_kwargs[1].get("json")
assert payload["format"] == "json"

View File

@@ -0,0 +1,625 @@
"""Tests for Control Deduplication Engine (4-Stage Matching Pipeline).
Covers:
- normalize_action(): German → canonical English verb mapping
- normalize_object(): Compliance object normalization
- canonicalize_text(): Canonicalization layer for embedding
- cosine_similarity(): Vector math
- DedupResult dataclass
- ControlDedupChecker.check_duplicate() — all 4 stages and verdicts
"""
import pytest
from unittest.mock import MagicMock, AsyncMock, patch
from compliance.services.control_dedup import (
normalize_action,
normalize_object,
canonicalize_text,
cosine_similarity,
DedupResult,
ControlDedupChecker,
LINK_THRESHOLD,
REVIEW_THRESHOLD,
LINK_THRESHOLD_DIFF_OBJECT,
CROSS_REG_LINK_THRESHOLD,
)
# ---------------------------------------------------------------------------
# normalize_action TESTS
# ---------------------------------------------------------------------------
class TestNormalizeAction:
"""Stage 2: Action normalization (German → canonical English)."""
def test_german_implement_synonyms(self):
for verb in ["implementieren", "umsetzen", "einrichten", "einführen", "aktivieren"]:
assert normalize_action(verb) == "implement", f"{verb} should map to implement"
def test_german_test_synonyms(self):
for verb in ["testen", "prüfen", "überprüfen", "verifizieren", "validieren"]:
assert normalize_action(verb) == "test", f"{verb} should map to test"
def test_german_monitor_synonyms(self):
for verb in ["überwachen", "monitoring", "beobachten"]:
assert normalize_action(verb) == "monitor", f"{verb} should map to monitor"
def test_german_encrypt(self):
assert normalize_action("verschlüsseln") == "encrypt"
def test_german_log_synonyms(self):
for verb in ["protokollieren", "aufzeichnen", "loggen"]:
assert normalize_action(verb) == "log", f"{verb} should map to log"
def test_german_restrict_synonyms(self):
for verb in ["beschränken", "einschränken", "begrenzen"]:
assert normalize_action(verb) == "restrict", f"{verb} should map to restrict"
def test_english_passthrough(self):
assert normalize_action("implement") == "implement"
assert normalize_action("test") == "test"
assert normalize_action("monitor") == "monitor"
assert normalize_action("encrypt") == "encrypt"
def test_case_insensitive(self):
assert normalize_action("IMPLEMENTIEREN") == "implement"
assert normalize_action("Testen") == "test"
def test_whitespace_handling(self):
assert normalize_action(" implementieren ") == "implement"
def test_empty_string(self):
assert normalize_action("") == ""
def test_unknown_verb_passthrough(self):
assert normalize_action("fluxkapazitieren") == "fluxkapazitieren"
def test_german_authorize_synonyms(self):
for verb in ["autorisieren", "genehmigen", "freigeben"]:
assert normalize_action(verb) == "authorize", f"{verb} should map to authorize"
def test_german_notify_synonyms(self):
for verb in ["benachrichtigen", "informieren"]:
assert normalize_action(verb) == "notify", f"{verb} should map to notify"
# ---------------------------------------------------------------------------
# normalize_object TESTS
# ---------------------------------------------------------------------------
class TestNormalizeObject:
"""Stage 3: Object normalization (compliance objects → canonical tokens)."""
def test_mfa_synonyms(self):
for obj in ["MFA", "2FA", "multi-faktor-authentifizierung", "two-factor"]:
assert normalize_object(obj) == "multi_factor_auth", f"{obj} should → multi_factor_auth"
def test_password_synonyms(self):
for obj in ["Passwort", "Kennwort", "password"]:
assert normalize_object(obj) == "password_policy", f"{obj} should → password_policy"
def test_privileged_access(self):
for obj in ["Admin-Konten", "admin accounts", "privilegierte Zugriffe"]:
assert normalize_object(obj) == "privileged_access", f"{obj} should → privileged_access"
def test_remote_access(self):
for obj in ["Remote-Zugriff", "Fernzugriff", "remote access"]:
assert normalize_object(obj) == "remote_access", f"{obj} should → remote_access"
def test_encryption_synonyms(self):
for obj in ["Verschlüsselung", "encryption", "Kryptografie"]:
assert normalize_object(obj) == "encryption", f"{obj} should → encryption"
def test_key_management(self):
for obj in ["Schlüssel", "key management", "Schlüsselverwaltung"]:
assert normalize_object(obj) == "key_management", f"{obj} should → key_management"
def test_transport_encryption(self):
for obj in ["TLS", "SSL", "HTTPS"]:
assert normalize_object(obj) == "transport_encryption", f"{obj} should → transport_encryption"
def test_audit_logging(self):
for obj in ["Audit-Log", "audit log", "Protokoll", "logging"]:
assert normalize_object(obj) == "audit_logging", f"{obj} should → audit_logging"
def test_vulnerability(self):
assert normalize_object("Schwachstelle") == "vulnerability"
assert normalize_object("vulnerability") == "vulnerability"
def test_patch_management(self):
for obj in ["Patch", "patching"]:
assert normalize_object(obj) == "patch_management", f"{obj} should → patch_management"
def test_case_insensitive(self):
assert normalize_object("FIREWALL") == "firewall"
assert normalize_object("VPN") == "vpn"
def test_empty_string(self):
assert normalize_object("") == ""
def test_substring_match(self):
"""Longer phrases containing known keywords should match."""
assert normalize_object("die Admin-Konten des Unternehmens") == "privileged_access"
assert normalize_object("zentrale Schlüsselverwaltung") == "key_management"
def test_unknown_object_fallback(self):
"""Unknown objects get cleaned and underscore-joined."""
result = normalize_object("Quantencomputer Resistenz")
assert "_" in result or result == "quantencomputer_resistenz"
def test_articles_stripped_in_fallback(self):
"""German/English articles should be stripped in fallback."""
result = normalize_object("der grosse Quantencomputer")
# "der" and "grosse" (>2 chars) → tokens without articles
assert "der" not in result
# ---------------------------------------------------------------------------
# canonicalize_text TESTS
# ---------------------------------------------------------------------------
class TestCanonicalizeText:
"""Canonicalization layer: German compliance text → normalized English for embedding."""
def test_basic_canonicalization(self):
result = canonicalize_text("implementieren", "MFA")
assert "implement" in result
assert "multi_factor_auth" in result
def test_with_title(self):
result = canonicalize_text("testen", "Firewall", "Netzwerk-Firewall regelmässig prüfen")
assert "test" in result
assert "firewall" in result
def test_title_filler_stripped(self):
result = canonicalize_text("implementieren", "VPN", "für den Zugriff gemäß Richtlinie")
# "für", "den", "gemäß" should be stripped
assert "für" not in result
assert "gemäß" not in result
def test_empty_action_and_object(self):
result = canonicalize_text("", "")
assert result.strip() == ""
def test_example_from_spec(self):
"""The canonical form of the spec example."""
result = canonicalize_text("implementieren", "MFA", "Administratoren müssen MFA verwenden")
assert "implement" in result
assert "multi_factor_auth" in result
# ---------------------------------------------------------------------------
# cosine_similarity TESTS
# ---------------------------------------------------------------------------
class TestCosineSimilarity:
def test_identical_vectors(self):
v = [1.0, 0.0, 0.0]
assert cosine_similarity(v, v) == pytest.approx(1.0)
def test_orthogonal_vectors(self):
a = [1.0, 0.0]
b = [0.0, 1.0]
assert cosine_similarity(a, b) == pytest.approx(0.0)
def test_opposite_vectors(self):
a = [1.0, 0.0]
b = [-1.0, 0.0]
assert cosine_similarity(a, b) == pytest.approx(-1.0)
def test_empty_vectors(self):
assert cosine_similarity([], []) == 0.0
def test_mismatched_lengths(self):
assert cosine_similarity([1.0], [1.0, 2.0]) == 0.0
def test_zero_vector(self):
assert cosine_similarity([0.0, 0.0], [1.0, 1.0]) == 0.0
# ---------------------------------------------------------------------------
# DedupResult TESTS
# ---------------------------------------------------------------------------
class TestDedupResult:
def test_defaults(self):
r = DedupResult(verdict="new")
assert r.verdict == "new"
assert r.matched_control_uuid is None
assert r.stage == ""
assert r.similarity_score == 0.0
assert r.details == {}
def test_link_result(self):
r = DedupResult(
verdict="link",
matched_control_uuid="abc-123",
matched_control_id="AUTH-2001",
stage="embedding_match",
similarity_score=0.95,
)
assert r.verdict == "link"
assert r.matched_control_id == "AUTH-2001"
# ---------------------------------------------------------------------------
# ControlDedupChecker TESTS (mocked DB + embedding)
# ---------------------------------------------------------------------------
class TestControlDedupChecker:
"""Integration tests for the 4-stage dedup pipeline with mocks."""
def _make_checker(self, existing_controls=None, search_results=None):
"""Build a ControlDedupChecker with mocked dependencies."""
db = MagicMock()
# Mock DB query for existing controls
if existing_controls is not None:
mock_rows = []
for c in existing_controls:
row = (c["uuid"], c["control_id"], c["title"], c["objective"],
c.get("pattern_id", "CP-AUTH-001"), c.get("obligation_type"))
mock_rows.append(row)
db.execute.return_value.fetchall.return_value = mock_rows
# Mock embedding function
async def fake_embed(text):
return [0.1] * 1024
# Mock Qdrant search
async def fake_search(embedding, pattern_id, top_k=10):
return search_results or []
return ControlDedupChecker(db, embed_fn=fake_embed, search_fn=fake_search)
@pytest.mark.asyncio
async def test_no_pattern_id_returns_new(self):
checker = self._make_checker()
result = await checker.check_duplicate("implement", "MFA", "Test", pattern_id=None)
assert result.verdict == "new"
assert result.stage == "no_pattern"
@pytest.mark.asyncio
async def test_no_existing_controls_returns_new(self):
checker = self._make_checker(existing_controls=[])
result = await checker.check_duplicate("implement", "MFA", "Test", pattern_id="CP-AUTH-001")
assert result.verdict == "new"
assert result.stage == "pattern_gate"
@pytest.mark.asyncio
async def test_no_qdrant_matches_returns_new(self):
checker = self._make_checker(
existing_controls=[{"uuid": "a1", "control_id": "AUTH-2001", "title": "t", "objective": "o"}],
search_results=[],
)
result = await checker.check_duplicate("implement", "MFA", "Test", pattern_id="CP-AUTH-001")
assert result.verdict == "new"
assert result.stage == "no_qdrant_matches"
@pytest.mark.asyncio
async def test_action_mismatch_returns_new(self):
"""Stage 2: Different action verbs → always NEW, even if embedding is high."""
checker = self._make_checker(
existing_controls=[{"uuid": "a1", "control_id": "AUTH-2001", "title": "t", "objective": "o"}],
search_results=[{
"score": 0.96,
"payload": {
"control_uuid": "a1", "control_id": "AUTH-2001",
"action_normalized": "test",
"object_normalized": "multi_factor_auth",
"title": "MFA testen",
},
}],
)
result = await checker.check_duplicate("implementieren", "MFA", "MFA implementieren", pattern_id="CP-AUTH-001")
assert result.verdict == "new"
assert result.stage == "action_mismatch"
assert result.details["candidate_action"] == "implement"
assert result.details["existing_action"] == "test"
@pytest.mark.asyncio
async def test_object_mismatch_high_score_links(self):
"""Stage 3: Different objects but similarity > 0.95 → LINK."""
checker = self._make_checker(
existing_controls=[{"uuid": "a1", "control_id": "AUTH-2001", "title": "t", "objective": "o"}],
search_results=[{
"score": 0.96,
"payload": {
"control_uuid": "a1", "control_id": "AUTH-2001",
"action_normalized": "implement",
"object_normalized": "remote_access",
"title": "Remote-Zugriff MFA",
},
}],
)
result = await checker.check_duplicate("implementieren", "Admin-Konten", "Admin MFA", pattern_id="CP-AUTH-001")
assert result.verdict == "link"
assert result.stage == "embedding_diff_object"
@pytest.mark.asyncio
async def test_object_mismatch_low_score_returns_new(self):
"""Stage 3: Different objects and similarity < 0.95 → NEW."""
checker = self._make_checker(
existing_controls=[{"uuid": "a1", "control_id": "AUTH-2001", "title": "t", "objective": "o"}],
search_results=[{
"score": 0.88,
"payload": {
"control_uuid": "a1", "control_id": "AUTH-2001",
"action_normalized": "implement",
"object_normalized": "remote_access",
"title": "Remote-Zugriff MFA",
},
}],
)
result = await checker.check_duplicate("implementieren", "Admin-Konten", "Admin MFA", pattern_id="CP-AUTH-001")
assert result.verdict == "new"
assert result.stage == "object_mismatch_below_threshold"
@pytest.mark.asyncio
async def test_same_action_object_high_score_links(self):
"""Stage 4: Same action + object + similarity > 0.92 → LINK."""
checker = self._make_checker(
existing_controls=[{"uuid": "a1", "control_id": "AUTH-2001", "title": "t", "objective": "o"}],
search_results=[{
"score": 0.94,
"payload": {
"control_uuid": "a1", "control_id": "AUTH-2001",
"action_normalized": "implement",
"object_normalized": "multi_factor_auth",
"title": "MFA implementieren",
},
}],
)
result = await checker.check_duplicate("implementieren", "MFA", "MFA umsetzen", pattern_id="CP-AUTH-001")
assert result.verdict == "link"
assert result.stage == "embedding_match"
assert result.similarity_score == 0.94
@pytest.mark.asyncio
async def test_same_action_object_review_range(self):
"""Stage 4: Same action + object + 0.85 < similarity < 0.92 → REVIEW."""
checker = self._make_checker(
existing_controls=[{"uuid": "a1", "control_id": "AUTH-2001", "title": "t", "objective": "o"}],
search_results=[{
"score": 0.88,
"payload": {
"control_uuid": "a1", "control_id": "AUTH-2001",
"action_normalized": "implement",
"object_normalized": "multi_factor_auth",
"title": "MFA implementieren",
},
}],
)
result = await checker.check_duplicate("implementieren", "MFA", "MFA für Admins", pattern_id="CP-AUTH-001")
assert result.verdict == "review"
assert result.stage == "embedding_review"
@pytest.mark.asyncio
async def test_same_action_object_low_score_new(self):
"""Stage 4: Same action + object but similarity < 0.85 → NEW."""
checker = self._make_checker(
existing_controls=[{"uuid": "a1", "control_id": "AUTH-2001", "title": "t", "objective": "o"}],
search_results=[{
"score": 0.72,
"payload": {
"control_uuid": "a1", "control_id": "AUTH-2001",
"action_normalized": "implement",
"object_normalized": "multi_factor_auth",
"title": "MFA implementieren",
},
}],
)
result = await checker.check_duplicate("implementieren", "MFA", "Ganz anderer MFA Kontext", pattern_id="CP-AUTH-001")
assert result.verdict == "new"
assert result.stage == "embedding_below_threshold"
@pytest.mark.asyncio
async def test_embedding_failure_returns_new(self):
"""If embedding service is down, default to NEW."""
db = MagicMock()
db.execute.return_value.fetchall.return_value = [
("a1", "AUTH-2001", "t", "o", "CP-AUTH-001", None)
]
async def failing_embed(text):
return []
checker = ControlDedupChecker(db, embed_fn=failing_embed)
result = await checker.check_duplicate("implement", "MFA", "Test", pattern_id="CP-AUTH-001")
assert result.verdict == "new"
assert result.stage == "embedding_unavailable"
@pytest.mark.asyncio
async def test_spec_false_positive_example(self):
"""The spec example: Admin-MFA vs Remote-MFA should NOT dedup.
Even if embedding says >0.9, different objects (privileged_access vs remote_access)
and score < 0.95 means NEW.
"""
checker = self._make_checker(
existing_controls=[{"uuid": "a1", "control_id": "AUTH-2001", "title": "t", "objective": "o"}],
search_results=[{
"score": 0.91,
"payload": {
"control_uuid": "a1", "control_id": "AUTH-2001",
"action_normalized": "implement",
"object_normalized": "remote_access",
"title": "Remote-Zugriffe müssen MFA nutzen",
},
}],
)
result = await checker.check_duplicate(
"implementieren", "Admin-Konten",
"Admin-Zugriffe müssen MFA nutzen",
pattern_id="CP-AUTH-001",
)
assert result.verdict == "new"
assert result.stage == "object_mismatch_below_threshold"
# ---------------------------------------------------------------------------
# THRESHOLD CONFIGURATION TESTS
# ---------------------------------------------------------------------------
class TestThresholds:
"""Verify the configured threshold values match the spec."""
def test_link_threshold(self):
assert LINK_THRESHOLD == 0.92
def test_review_threshold(self):
assert REVIEW_THRESHOLD == 0.85
def test_diff_object_threshold(self):
assert LINK_THRESHOLD_DIFF_OBJECT == 0.95
def test_threshold_ordering(self):
assert LINK_THRESHOLD_DIFF_OBJECT > LINK_THRESHOLD > REVIEW_THRESHOLD
def test_cross_reg_threshold(self):
assert CROSS_REG_LINK_THRESHOLD == 0.95
def test_cross_reg_threshold_higher_than_intra(self):
assert CROSS_REG_LINK_THRESHOLD >= LINK_THRESHOLD
# ---------------------------------------------------------------------------
# CROSS-REGULATION DEDUP TESTS
# ---------------------------------------------------------------------------
class TestCrossRegulationDedup:
"""Tests for cross-regulation linking (second dedup pass)."""
def _make_checker(self):
"""Create a checker with mocked DB, embedding, and search."""
mock_db = MagicMock()
mock_db.execute.return_value.fetchall.return_value = [
("uuid-1", "CTRL-001", "MFA", "Enable MFA", "SEC-AUTH", "pflicht"),
]
embed_fn = AsyncMock(return_value=[0.1] * 1024)
search_fn = AsyncMock(return_value=[]) # no intra-pattern matches
return ControlDedupChecker(db=mock_db, embed_fn=embed_fn, search_fn=search_fn)
@pytest.mark.asyncio
async def test_cross_reg_triggered_when_intra_is_new(self):
"""Cross-reg runs when intra-pattern returns 'new'."""
checker = self._make_checker()
cross_results = [{
"score": 0.96,
"payload": {
"control_uuid": "cross-uuid-1",
"control_id": "NIS2-CTRL-001",
"title": "MFA (NIS2)",
},
}]
with patch(
"compliance.services.control_dedup.qdrant_search_cross_regulation",
new_callable=AsyncMock,
return_value=cross_results,
):
result = await checker.check_duplicate(
action="implement", obj="MFA", title="MFA", pattern_id="SEC-AUTH"
)
assert result.verdict == "link"
assert result.stage == "cross_regulation"
assert result.link_type == "cross_regulation"
assert result.matched_control_id == "NIS2-CTRL-001"
assert result.similarity_score == 0.96
@pytest.mark.asyncio
async def test_cross_reg_not_triggered_when_intra_is_link(self):
"""Cross-reg should NOT run when intra-pattern already found a link."""
mock_db = MagicMock()
mock_db.execute.return_value.fetchall.return_value = [
("uuid-1", "CTRL-001", "MFA", "Enable MFA", "SEC-AUTH", "pflicht"),
]
embed_fn = AsyncMock(return_value=[0.1] * 1024)
# Intra-pattern search returns a high match
search_fn = AsyncMock(return_value=[{
"score": 0.95,
"payload": {
"control_uuid": "intra-uuid",
"control_id": "CTRL-001",
"title": "MFA",
"action_normalized": "implement",
"object_normalized": "multi_factor_auth",
},
}])
checker = ControlDedupChecker(db=mock_db, embed_fn=embed_fn, search_fn=search_fn)
with patch(
"compliance.services.control_dedup.qdrant_search_cross_regulation",
new_callable=AsyncMock,
) as mock_cross:
result = await checker.check_duplicate(
action="implement", obj="MFA", title="MFA", pattern_id="SEC-AUTH"
)
assert result.verdict == "link"
assert result.stage == "embedding_match"
assert result.link_type == "dedup_merge" # not cross_regulation
mock_cross.assert_not_called()
@pytest.mark.asyncio
async def test_cross_reg_below_threshold_keeps_new(self):
"""Cross-reg score below 0.95 keeps the verdict as 'new'."""
checker = self._make_checker()
cross_results = [{
"score": 0.93, # below CROSS_REG_LINK_THRESHOLD
"payload": {
"control_uuid": "cross-uuid-2",
"control_id": "NIS2-CTRL-002",
"title": "Similar control",
},
}]
with patch(
"compliance.services.control_dedup.qdrant_search_cross_regulation",
new_callable=AsyncMock,
return_value=cross_results,
):
result = await checker.check_duplicate(
action="implement", obj="MFA", title="MFA", pattern_id="SEC-AUTH"
)
assert result.verdict == "new"
@pytest.mark.asyncio
async def test_cross_reg_no_results_keeps_new(self):
"""No cross-reg results keeps the verdict as 'new'."""
checker = self._make_checker()
with patch(
"compliance.services.control_dedup.qdrant_search_cross_regulation",
new_callable=AsyncMock,
return_value=[],
):
result = await checker.check_duplicate(
action="implement", obj="MFA", title="MFA", pattern_id="SEC-AUTH"
)
assert result.verdict == "new"
class TestDedupResultLinkType:
"""Tests for the link_type field on DedupResult."""
def test_default_link_type(self):
r = DedupResult(verdict="new")
assert r.link_type == "dedup_merge"
def test_cross_regulation_link_type(self):
r = DedupResult(verdict="link", link_type="cross_regulation")
assert r.link_type == "cross_regulation"

View File

@@ -30,7 +30,7 @@ class TestLicenseMapping:
def test_rule1_eu_law(self):
info = _classify_regulation("eu_2016_679")
assert info["rule"] == 1
assert info["name"] == "DSGVO"
assert "DSGVO" in info["name"]
assert info["source_type"] == "law"
def test_rule1_nist(self):
@@ -42,7 +42,7 @@ class TestLicenseMapping:
def test_rule1_german_law(self):
info = _classify_regulation("bdsg")
assert info["rule"] == 1
assert info["name"] == "BDSG"
assert "BDSG" in info["name"]
assert info["source_type"] == "law"
def test_rule2_owasp(self):
@@ -199,7 +199,7 @@ class TestAnchorFinder:
async def test_rag_anchor_search_filters_restricted(self):
"""Only Rule 1+2 sources are returned as anchors."""
mock_rag = AsyncMock()
mock_rag.search.return_value = [
mock_rag.search_with_rerank.return_value = [
RAGSearchResult(
text="OWASP requirement",
regulation_code="owasp_asvs",
@@ -231,7 +231,7 @@ class TestAnchorFinder:
# Only OWASP should be returned (Rule 2), BSI should be filtered out (Rule 3)
assert len(anchors) == 1
assert anchors[0].framework == "OWASP ASVS"
assert "OWASP ASVS" in anchors[0].framework
@pytest.mark.asyncio
async def test_web_search_identifies_frameworks(self):
@@ -1668,3 +1668,36 @@ class TestApplicabilityFields:
control = pipeline._build_control_from_json(data, "SEC")
assert "applicable_industries" not in control.generation_metadata
assert "applicable_company_size" not in control.generation_metadata
# =============================================================================
# Tests: Ollama JSON-Mode
# =============================================================================
class TestOllamaJsonMode:
"""Verify that control_generator Ollama payloads include format=json."""
@pytest.mark.asyncio
async def test_ollama_payload_contains_format_json(self):
"""_llm_ollama must send format='json' in the request payload."""
from compliance.services.control_generator import _llm_ollama
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"message": {"content": '{"test": true}'}
}
with patch("compliance.services.control_generator.httpx.AsyncClient") as mock_cls:
mock_client = AsyncMock()
mock_client.post.return_value = mock_response
mock_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
mock_cls.return_value.__aexit__ = AsyncMock(return_value=False)
await _llm_ollama("test prompt", "system prompt")
mock_client.post.assert_called_once()
call_kwargs = mock_client.post.call_args
payload = call_kwargs.kwargs.get("json") or call_kwargs[1].get("json")
assert payload["format"] == "json"

View File

@@ -25,7 +25,11 @@ from compliance.services.decomposition_pass import (
AtomicControlCandidate,
quality_gate,
passes_quality_gate,
classify_obligation_type,
_NORMATIVE_RE,
_PFLICHT_RE,
_EMPFEHLUNG_RE,
_KANN_RE,
_RATIONALE_RE,
_TEST_RE,
_REPORTING_RE,
@@ -176,7 +180,7 @@ class TestQualityGate:
def test_rationale_detected(self):
oc = ObligationCandidate(
parent_control_uuid="uuid-1",
obligation_text="Schwache Passwörter können zu Risiken führen, weil sie leicht zu erraten sind",
obligation_text="Dies liegt daran, weil schwache Konfigurationen ein Risiko darstellen",
)
flags = quality_gate(oc)
assert flags["not_rationale"] is False
@@ -228,14 +232,28 @@ class TestQualityGate:
)
flags = quality_gate(oc)
assert flags["has_normative_signal"] is False
assert flags["obligation_type"] == "empfehlung"
def test_obligation_type_in_flags(self):
oc = ObligationCandidate(
parent_control_uuid="uuid-1",
obligation_text="Der Betreiber muss alle Daten verschlüsseln.",
)
flags = quality_gate(oc)
assert flags["obligation_type"] == "pflicht"
class TestPassesQualityGate:
"""Tests for passes_quality_gate function."""
"""Tests for passes_quality_gate function.
Note: has_normative_signal is NO LONGER critical — obligations without
normative signal are classified as 'empfehlung' instead of being rejected.
"""
def test_all_critical_pass(self):
flags = {
"has_normative_signal": True,
"obligation_type": "pflicht",
"single_action": True,
"not_rationale": True,
"not_evidence_only": True,
@@ -244,20 +262,23 @@ class TestPassesQualityGate:
}
assert passes_quality_gate(flags) is True
def test_no_normative_signal_fails(self):
def test_no_normative_signal_still_passes(self):
"""No normative signal no longer causes rejection — classified as empfehlung."""
flags = {
"has_normative_signal": False,
"obligation_type": "empfehlung",
"single_action": True,
"not_rationale": True,
"not_evidence_only": True,
"min_length": True,
"has_parent_link": True,
}
assert passes_quality_gate(flags) is False
assert passes_quality_gate(flags) is True
def test_evidence_only_fails(self):
flags = {
"has_normative_signal": True,
"obligation_type": "pflicht",
"single_action": True,
"not_rationale": True,
"not_evidence_only": False,
@@ -267,9 +288,10 @@ class TestPassesQualityGate:
assert passes_quality_gate(flags) is False
def test_non_critical_dont_block(self):
"""single_action and not_rationale are NOT critical — should still pass."""
"""single_action, not_rationale, has_normative_signal are NOT critical."""
flags = {
"has_normative_signal": True,
"has_normative_signal": False, # Not critical
"obligation_type": "empfehlung",
"single_action": False, # Not critical
"not_rationale": False, # Not critical
"not_evidence_only": True,
@@ -279,6 +301,42 @@ class TestPassesQualityGate:
assert passes_quality_gate(flags) is True
class TestClassifyObligationType:
"""Tests for the 3-tier obligation type classification."""
def test_pflicht_muss(self):
assert classify_obligation_type("Der Betreiber muss alle Daten verschlüsseln") == "pflicht"
def test_pflicht_ist_zu(self):
assert classify_obligation_type("Die Meldung ist innerhalb von 72 Stunden zu erstatten") == "pflicht"
def test_pflicht_shall(self):
assert classify_obligation_type("The controller shall implement appropriate measures") == "pflicht"
def test_empfehlung_soll(self):
assert classify_obligation_type("Der Betreiber soll regelmäßige Audits durchführen") == "empfehlung"
def test_empfehlung_should(self):
assert classify_obligation_type("Organizations should implement security controls") == "empfehlung"
def test_empfehlung_sicherstellen(self):
assert classify_obligation_type("Die Verfügbarkeit der Systeme sicherstellen") == "empfehlung"
def test_kann(self):
assert classify_obligation_type("Der Betreiber kann zusätzliche Maßnahmen ergreifen") == "kann"
def test_kann_may(self):
assert classify_obligation_type("The organization may implement optional safeguards") == "kann"
def test_no_signal_defaults_to_empfehlung(self):
assert classify_obligation_type("Regelmäßige Überprüfung der Zugriffsrechte") == "empfehlung"
def test_pflicht_overrides_empfehlung(self):
"""If both pflicht and empfehlung signals present, pflicht wins."""
txt = "Der Betreiber muss sicherstellen, dass alle Daten verschlüsselt werden"
assert classify_obligation_type(txt) == "pflicht"
# ---------------------------------------------------------------------------
# HELPER TESTS
# ---------------------------------------------------------------------------
@@ -520,6 +578,24 @@ class TestPromptBuilders:
assert "REGELN" in _PASS0A_SYSTEM_PROMPT
assert "atomares" in _PASS0B_SYSTEM_PROMPT
def test_pass0a_prompt_contains_cot_steps(self):
"""Pass 0a system prompt must include Chain-of-Thought analysis steps."""
assert "ANALYSE-SCHRITTE" in _PASS0A_SYSTEM_PROMPT
assert "Adressaten" in _PASS0A_SYSTEM_PROMPT
assert "Handlung" in _PASS0A_SYSTEM_PROMPT
assert "normative Staerke" in _PASS0A_SYSTEM_PROMPT
assert "Meldepflicht" in _PASS0A_SYSTEM_PROMPT
assert "NICHT im Output" in _PASS0A_SYSTEM_PROMPT
def test_pass0b_prompt_contains_cot_steps(self):
"""Pass 0b system prompt must include Chain-of-Thought analysis steps."""
assert "ANALYSE-SCHRITTE" in _PASS0B_SYSTEM_PROMPT
assert "Anforderung" in _PASS0B_SYSTEM_PROMPT
assert "Massnahme" in _PASS0B_SYSTEM_PROMPT
assert "Pruefverfahren" in _PASS0B_SYSTEM_PROMPT
assert "Nachweis" in _PASS0B_SYSTEM_PROMPT
assert "NICHT im Output" in _PASS0B_SYSTEM_PROMPT
# ---------------------------------------------------------------------------
# DECOMPOSITION PASS INTEGRATION TESTS

View File

@@ -937,3 +937,36 @@ class TestConstants:
def test_candidate_threshold_is_60(self):
assert EMBEDDING_CANDIDATE_THRESHOLD == 0.60
# =============================================================================
# Tests: Ollama JSON-Mode
# =============================================================================
class TestOllamaJsonMode:
"""Verify that Ollama payloads include format=json."""
@pytest.mark.asyncio
async def test_ollama_payload_contains_format_json(self):
"""_llm_ollama must send format='json' in the request payload."""
from compliance.services.obligation_extractor import _llm_ollama
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"message": {"content": '{"test": true}'}
}
with patch("compliance.services.obligation_extractor.httpx.AsyncClient") as mock_cls:
mock_client = AsyncMock()
mock_client.post.return_value = mock_response
mock_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
mock_cls.return_value.__aexit__ = AsyncMock(return_value=False)
await _llm_ollama("test prompt", "system prompt")
mock_client.post.assert_called_once()
call_kwargs = mock_client.post.call_args
payload = call_kwargs.kwargs.get("json") or call_kwargs[1].get("json")
assert payload["format"] == "json"

View File

@@ -0,0 +1,191 @@
"""Tests for Cross-Encoder Re-Ranking module."""
import pytest
from unittest.mock import MagicMock, patch, AsyncMock
from compliance.services.reranker import Reranker, get_reranker, RERANK_ENABLED
from compliance.services.rag_client import ComplianceRAGClient, RAGSearchResult
# =============================================================================
# Reranker Unit Tests
# =============================================================================
class TestReranker:
"""Tests for Reranker class."""
def test_rerank_empty_texts(self):
"""Empty texts list returns empty indices."""
reranker = Reranker()
assert reranker.rerank("query", [], top_k=5) == []
def test_rerank_returns_correct_indices(self):
"""Reranker returns indices sorted by score descending."""
reranker = Reranker()
# Mock the cross-encoder model
mock_model = MagicMock()
# Scores: text[0]=0.1, text[1]=0.9, text[2]=0.5
mock_model.predict.return_value = [0.1, 0.9, 0.5]
reranker._model = mock_model
indices = reranker.rerank("test query", ["low", "high", "mid"], top_k=3)
assert indices == [1, 2, 0] # sorted by score desc
def test_rerank_top_k_limits_results(self):
"""top_k limits the number of returned indices."""
reranker = Reranker()
mock_model = MagicMock()
mock_model.predict.return_value = [0.1, 0.9, 0.5, 0.7, 0.3]
reranker._model = mock_model
indices = reranker.rerank("query", ["a", "b", "c", "d", "e"], top_k=2)
assert len(indices) == 2
assert indices[0] == 1 # highest score
assert indices[1] == 3 # second highest
def test_rerank_sends_pairs_to_model(self):
"""Model receives [[query, text], ...] pairs."""
reranker = Reranker()
mock_model = MagicMock()
mock_model.predict.return_value = [0.5, 0.8]
reranker._model = mock_model
reranker.rerank("my query", ["text A", "text B"], top_k=2)
call_args = mock_model.predict.call_args[0][0]
assert call_args == [["my query", "text A"], ["my query", "text B"]]
def test_lazy_init_not_loaded_until_rerank(self):
"""Model should not be loaded at construction time."""
reranker = Reranker()
assert reranker._model is None
def test_ensure_model_skips_if_already_loaded(self):
"""_ensure_model should not reload when model is already set."""
reranker = Reranker()
mock_model = MagicMock()
reranker._model = mock_model
# Call _ensure_model — should short-circuit since _model is set
reranker._ensure_model()
reranker._ensure_model()
# Model should still be the same mock
assert reranker._model is mock_model
# =============================================================================
# get_reranker Tests
# =============================================================================
class TestGetReranker:
"""Tests for the get_reranker factory."""
def test_disabled_returns_none(self):
"""When RERANK_ENABLED=false, get_reranker returns None."""
with patch("compliance.services.reranker.RERANK_ENABLED", False):
# Reset singleton
import compliance.services.reranker as mod
mod._reranker = None
result = mod.get_reranker()
assert result is None
def test_enabled_returns_reranker(self):
"""When RERANK_ENABLED=true, get_reranker returns a Reranker instance."""
import compliance.services.reranker as mod
mod._reranker = None
with patch.object(mod, "RERANK_ENABLED", True):
result = mod.get_reranker()
assert isinstance(result, Reranker)
mod._reranker = None # cleanup
def test_singleton_returns_same_instance(self):
"""get_reranker returns the same instance on repeated calls."""
import compliance.services.reranker as mod
mod._reranker = None
with patch.object(mod, "RERANK_ENABLED", True):
r1 = mod.get_reranker()
r2 = mod.get_reranker()
assert r1 is r2
mod._reranker = None # cleanup
# =============================================================================
# search_with_rerank Integration Tests
# =============================================================================
class TestSearchWithRerank:
"""Tests for ComplianceRAGClient.search_with_rerank."""
def _make_result(self, text: str, score: float) -> RAGSearchResult:
return RAGSearchResult(
text=text, regulation_code="eu_2016_679",
regulation_name="DSGVO", regulation_short="DSGVO",
category="regulation", article="", paragraph="",
source_url="", score=score,
)
@pytest.mark.asyncio
async def test_rerank_disabled_falls_through(self):
"""When reranker is disabled, search_with_rerank calls regular search."""
client = ComplianceRAGClient(base_url="http://fake")
results = [self._make_result("text1", 0.9)]
with patch.object(client, "search", new_callable=AsyncMock, return_value=results):
with patch("compliance.services.reranker.get_reranker", return_value=None):
got = await client.search_with_rerank("query", top_k=5)
assert len(got) == 1
assert got[0].text == "text1"
@pytest.mark.asyncio
async def test_rerank_reorders_results(self):
"""When reranker is enabled, results are re-ordered."""
client = ComplianceRAGClient(base_url="http://fake")
candidates = [
self._make_result("low relevance", 0.9),
self._make_result("high relevance", 0.7),
self._make_result("medium relevance", 0.8),
]
mock_reranker = MagicMock()
# Reranker says index 1 is best, then 2, then 0
mock_reranker.rerank.return_value = [1, 2, 0]
with patch.object(client, "search", new_callable=AsyncMock, return_value=candidates):
with patch("compliance.services.reranker.get_reranker", return_value=mock_reranker):
got = await client.search_with_rerank("query", top_k=2)
# Should get reranked top 2 (but our mock returns [1,2,0] and top_k=2
# means reranker.rerank is called with top_k=2, which returns [1, 2])
mock_reranker.rerank.assert_called_once()
# The rerank mock returns [1,2,0], so we get candidates[1] and candidates[2]
assert got[0].text == "high relevance"
assert got[1].text == "medium relevance"
@pytest.mark.asyncio
async def test_rerank_failure_returns_unranked(self):
"""If reranker fails, fall back to unranked results."""
client = ComplianceRAGClient(base_url="http://fake")
candidates = [self._make_result("text", 0.9)] * 5
mock_reranker = MagicMock()
mock_reranker.rerank.side_effect = RuntimeError("model error")
with patch.object(client, "search", new_callable=AsyncMock, return_value=candidates):
with patch("compliance.services.reranker.get_reranker", return_value=mock_reranker):
got = await client.search_with_rerank("query", top_k=3)
assert len(got) == 3 # falls back to first top_k

View File

@@ -23,8 +23,11 @@ class Settings(BaseSettings):
llm_model: str = "qwen2.5:32b"
# Document Processing
chunk_size: int = 512
chunk_overlap: int = 50
# NOTE: Changed from 512/50 to 1024/128 for improved retrieval quality.
# Existing collections (ingested with 512/50) are NOT affected —
# new settings apply only to new ingestions.
chunk_size: int = 1024
chunk_overlap: int = 128
# Legal Corpus
corpus_path: str = "./legal-corpus"

View File

@@ -85,8 +85,8 @@ upload_file() {
-F "use_case=${use_case}" \
-F "year=${year}" \
-F "chunk_strategy=recursive" \
-F "chunk_size=512" \
-F "chunk_overlap=50" \
-F "chunk_size=1024" \
-F "chunk_overlap=128" \
-F "metadata_json=${metadata_json}" \
2>/dev/null) || true

View File

@@ -323,8 +323,8 @@ PYEOF
-F "use_case=ce_risk_assessment" \
-F "year=2026" \
-F "chunk_strategy=recursive" \
-F "chunk_size=512" \
-F "chunk_overlap=50" \
-F "chunk_size=1024" \
-F "chunk_overlap=128" \
2>/dev/null)
rm -f "$TMPFILE"

View File

@@ -91,8 +91,8 @@ upload_file() {
-F "use_case=${use_case}" \
-F "year=${year}" \
-F "chunk_strategy=recursive" \
-F "chunk_size=512" \
-F "chunk_overlap=50" \
-F "chunk_size=1024" \
-F "chunk_overlap=128" \
-F "metadata_json=${metadata_json}" \
2>/dev/null) || true

View File

@@ -107,8 +107,8 @@ upload_file() {
-F "use_case=${use_case}" \
-F "year=${year}" \
-F "chunk_strategy=recursive" \
-F "chunk_size=512" \
-F "chunk_overlap=50" \
-F "chunk_size=1024" \
-F "chunk_overlap=128" \
-F "metadata_json=${metadata_json}" \
2>/dev/null) || true

View File

@@ -123,8 +123,8 @@ upload_file() {
-F "use_case=${use_case}" \
-F "year=${year}" \
-F "chunk_strategy=recursive" \
-F "chunk_size=512" \
-F "chunk_overlap=50" \
-F "chunk_size=1024" \
-F "chunk_overlap=128" \
-F "metadata_json=${metadata_json}" \
2>/dev/null) || true