All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-ai-compliance (push) Successful in 35s
CI / test-python-backend-compliance (push) Successful in 26s
CI / test-python-document-crawler (push) Successful in 22s
CI / test-python-dsms-gateway (push) Successful in 19s
Add legal context enrichment from Qdrant vector corpus to the two highest-priority modules (Requirements AI assistant and DSFA drafting engine). Go SDK: - Add SearchCollection() with collection override + whitelist validation - Refactor Search() to delegate to shared searchInternal() Python backend: - New ComplianceRAGClient proxying POST /sdk/v1/rag/search (error-tolerant) - AI assistant: enrich interpret_requirement() and suggest_controls() with RAG - Requirements API: add ?include_legal_context=true query parameter Admin (Next.js): - Extract shared queryRAG() utility from chat route - Inject RAG legal context into v1 and v2 draft pipelines Tests for all three layers (Go, Python, TypeScript shared utility). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
130 lines
3.8 KiB
Python
130 lines
3.8 KiB
Python
"""
|
|
Compliance RAG Client — Proxy to Go SDK RAG Search.
|
|
|
|
Lightweight HTTP client that queries the Go AI Compliance SDK's
|
|
POST /sdk/v1/rag/search endpoint. This avoids needing embedding
|
|
models or direct Qdrant access in Python.
|
|
|
|
Error-tolerant: RAG failures never break the calling function.
|
|
"""
|
|
|
|
import logging
|
|
import os
|
|
from dataclasses import dataclass
|
|
from typing import List, Optional
|
|
|
|
import httpx
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
SDK_URL = os.getenv("SDK_URL", "http://ai-compliance-sdk:8090")
|
|
RAG_SEARCH_TIMEOUT = 15.0 # seconds
|
|
|
|
|
|
@dataclass
|
|
class RAGSearchResult:
|
|
"""A single search result from the compliance corpus."""
|
|
text: str
|
|
regulation_code: str
|
|
regulation_name: str
|
|
regulation_short: str
|
|
category: str
|
|
article: str
|
|
paragraph: str
|
|
source_url: str
|
|
score: float
|
|
|
|
|
|
class ComplianceRAGClient:
|
|
"""
|
|
RAG client that proxies search requests to the Go SDK.
|
|
|
|
Usage:
|
|
client = get_rag_client()
|
|
results = await client.search("DSGVO Art. 35", collection="bp_compliance_recht")
|
|
context_str = client.format_for_prompt(results)
|
|
"""
|
|
|
|
def __init__(self, base_url: str = SDK_URL):
|
|
self._search_url = f"{base_url}/sdk/v1/rag/search"
|
|
|
|
async def search(
|
|
self,
|
|
query: str,
|
|
collection: str = "bp_compliance_ce",
|
|
regulations: Optional[List[str]] = None,
|
|
top_k: int = 5,
|
|
) -> List[RAGSearchResult]:
|
|
"""
|
|
Search the RAG corpus via Go SDK.
|
|
|
|
Returns an empty list on any error (never raises).
|
|
"""
|
|
payload = {
|
|
"query": query,
|
|
"collection": collection,
|
|
"top_k": top_k,
|
|
}
|
|
if regulations:
|
|
payload["regulations"] = regulations
|
|
|
|
try:
|
|
async with httpx.AsyncClient(timeout=RAG_SEARCH_TIMEOUT) as client:
|
|
resp = await client.post(self._search_url, json=payload)
|
|
|
|
if resp.status_code != 200:
|
|
logger.warning(
|
|
"RAG search returned %d: %s", resp.status_code, resp.text[:200]
|
|
)
|
|
return []
|
|
|
|
data = resp.json()
|
|
results = []
|
|
for r in data.get("results", []):
|
|
results.append(RAGSearchResult(
|
|
text=r.get("text", ""),
|
|
regulation_code=r.get("regulation_code", ""),
|
|
regulation_name=r.get("regulation_name", ""),
|
|
regulation_short=r.get("regulation_short", ""),
|
|
category=r.get("category", ""),
|
|
article=r.get("article", ""),
|
|
paragraph=r.get("paragraph", ""),
|
|
source_url=r.get("source_url", ""),
|
|
score=r.get("score", 0.0),
|
|
))
|
|
return results
|
|
|
|
except Exception as e:
|
|
logger.warning("RAG search failed: %s", e)
|
|
return []
|
|
|
|
def format_for_prompt(
|
|
self, results: List[RAGSearchResult], max_results: int = 5
|
|
) -> str:
|
|
"""Format search results as Markdown for inclusion in an LLM prompt."""
|
|
if not results:
|
|
return ""
|
|
|
|
lines = ["## Relevanter Rechtskontext\n"]
|
|
for i, r in enumerate(results[:max_results]):
|
|
header = f"{i + 1}. **{r.regulation_short}** ({r.regulation_code})"
|
|
if r.article:
|
|
header += f" — {r.article}"
|
|
lines.append(header)
|
|
text = r.text[:400] + "..." if len(r.text) > 400 else r.text
|
|
lines.append(f" > {text}\n")
|
|
|
|
return "\n".join(lines)
|
|
|
|
|
|
# Singleton
|
|
_rag_client: Optional[ComplianceRAGClient] = None
|
|
|
|
|
|
def get_rag_client() -> ComplianceRAGClient:
|
|
"""Get the shared RAG client instance."""
|
|
global _rag_client
|
|
if _rag_client is None:
|
|
_rag_client = ComplianceRAGClient()
|
|
return _rag_client
|