feat(advisor): multi-collection RAG search + country filter (DE/AT/CH/EU)
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 40s
CI / test-python-backend-compliance (push) Successful in 26s
CI / test-python-document-crawler (push) Successful in 20s
CI / test-python-dsms-gateway (push) Successful in 18s
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 40s
CI / test-python-backend-compliance (push) Successful in 26s
CI / test-python-document-crawler (push) Successful in 20s
CI / test-python-dsms-gateway (push) Successful in 18s
- Replace single DSFA corpus query with parallel search across 6 collections via RAG service (port 8097) - Add country parameter with metadata filter for bp_compliance_gesetze - Add country-specific system prompt section - Add DE/AT/CH/EU toggle buttons in ComplianceAdvisorWidget header Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,18 +2,31 @@
|
|||||||
* Compliance Advisor Chat API
|
* Compliance Advisor Chat API
|
||||||
*
|
*
|
||||||
* Connects the ComplianceAdvisorWidget to:
|
* Connects the ComplianceAdvisorWidget to:
|
||||||
* 1. RAG legal corpus search (klausur-service) for context
|
* 1. Multi-Collection RAG search (rag-service) for context across 6 collections
|
||||||
* 2. Ollama LLM (32B) for generating answers
|
* 2. Ollama LLM (32B) for generating answers
|
||||||
*
|
*
|
||||||
|
* Supports country-specific filtering (DE, AT, CH, EU).
|
||||||
* Streams the LLM response back as plain text.
|
* Streams the LLM response back as plain text.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
const KLAUSUR_SERVICE_URL = process.env.KLAUSUR_SERVICE_URL || 'http://klausur-service:8086'
|
const RAG_SERVICE_URL = process.env.RAG_SERVICE_URL || 'http://rag-service:8097'
|
||||||
const OLLAMA_URL = process.env.OLLAMA_URL || 'http://host.docker.internal:11434'
|
const OLLAMA_URL = process.env.OLLAMA_URL || 'http://host.docker.internal:11434'
|
||||||
const LLM_MODEL = process.env.COMPLIANCE_LLM_MODEL || 'qwen2.5vl:32b'
|
const LLM_MODEL = process.env.COMPLIANCE_LLM_MODEL || 'qwen2.5vl:32b'
|
||||||
|
|
||||||
|
// All compliance-relevant collections (without NiBiS)
|
||||||
|
const COMPLIANCE_COLLECTIONS = [
|
||||||
|
'bp_compliance_gesetze',
|
||||||
|
'bp_compliance_ce',
|
||||||
|
'bp_compliance_datenschutz',
|
||||||
|
'bp_dsfa_corpus',
|
||||||
|
'bp_compliance_recht',
|
||||||
|
'bp_legal_templates',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
type Country = 'DE' | 'AT' | 'CH' | 'EU'
|
||||||
|
|
||||||
// SOUL system prompt (from agent-core/soul/compliance-advisor.soul.md)
|
// SOUL system prompt (from agent-core/soul/compliance-advisor.soul.md)
|
||||||
const SYSTEM_PROMPT = `# Compliance Advisor Agent
|
const SYSTEM_PROMPT = `# Compliance Advisor Agent
|
||||||
|
|
||||||
@@ -44,6 +57,8 @@ offiziellen Quellen und gibst praxisnahe Hinweise.
|
|||||||
- EDPB Guidelines (Leitlinien des Europaeischen Datenschutzausschusses)
|
- EDPB Guidelines (Leitlinien des Europaeischen Datenschutzausschusses)
|
||||||
- Bundes- und Laender-Muss-Listen (DSFA-Listen der Aufsichtsbehoerden)
|
- Bundes- und Laender-Muss-Listen (DSFA-Listen der Aufsichtsbehoerden)
|
||||||
- WP29/WP248 (Art.-29-Datenschutzgruppe Arbeitspapiere)
|
- WP29/WP248 (Art.-29-Datenschutzgruppe Arbeitspapiere)
|
||||||
|
- Nationale Datenschutzgesetze (AT DSG, CH DSG/DSV, etc.)
|
||||||
|
- EU-Verordnungen (DORA, MiCA, Data Act, EHDS, PSD2, AMLR, etc.)
|
||||||
|
|
||||||
## RAG-Nutzung
|
## RAG-Nutzung
|
||||||
Nutze das gesamte RAG-Corpus fuer Kontext und Quellenangaben — ausgenommen sind
|
Nutze das gesamte RAG-Corpus fuer Kontext und Quellenangaben — ausgenommen sind
|
||||||
@@ -76,44 +91,87 @@ Diese gehoeren nicht zum Datenschutz-Kompetenzbereich.
|
|||||||
- Bei widerspruechlichen Rechtslagen: Beide Positionen darstellen und DSB-Konsultation empfehlen
|
- Bei widerspruechlichen Rechtslagen: Beide Positionen darstellen und DSB-Konsultation empfehlen
|
||||||
- Bei dringenden Datenpannen: Auf 72-Stunden-Frist (Art. 33 DSGVO) hinweisen und Notfallplan-Modul empfehlen`
|
- Bei dringenden Datenpannen: Auf 72-Stunden-Frist (Art. 33 DSGVO) hinweisen und Notfallplan-Modul empfehlen`
|
||||||
|
|
||||||
interface RAGResult {
|
const COUNTRY_LABELS: Record<Country, string> = {
|
||||||
|
DE: 'Deutschland',
|
||||||
|
AT: 'Oesterreich',
|
||||||
|
CH: 'Schweiz',
|
||||||
|
EU: 'EU-weit',
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RAGSearchResult {
|
||||||
content: string
|
content: string
|
||||||
source_name: string
|
source_name?: string
|
||||||
source_code: string
|
source_code?: string
|
||||||
attribution_text: string
|
attribution_text?: string
|
||||||
score: number
|
score: number
|
||||||
|
collection?: string
|
||||||
|
metadata?: Record<string, unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Query the DSFA RAG corpus for relevant documents
|
* Query multiple RAG collections in parallel, with optional country filter
|
||||||
*/
|
*/
|
||||||
async function queryRAG(query: string): Promise<string> {
|
async function queryMultiCollectionRAG(query: string, country?: Country): Promise<string> {
|
||||||
try {
|
try {
|
||||||
const url = `${KLAUSUR_SERVICE_URL}/api/v1/dsfa-rag/search?query=${encodeURIComponent(query)}&top_k=5`
|
const searchPromises = COMPLIANCE_COLLECTIONS.map(async (collection) => {
|
||||||
const res = await fetch(url, {
|
const searchBody: Record<string, unknown> = {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
query,
|
||||||
signal: AbortSignal.timeout(10000),
|
collection,
|
||||||
|
top_k: 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply country filter for gesetze collection
|
||||||
|
if (collection === 'bp_compliance_gesetze' && country && country !== 'EU') {
|
||||||
|
searchBody.metadata_filter = {
|
||||||
|
must: [
|
||||||
|
{
|
||||||
|
key: 'country',
|
||||||
|
match: { any: [country, 'EU'] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(`${RAG_SERVICE_URL}/api/v1/search`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(searchBody),
|
||||||
|
signal: AbortSignal.timeout(10000),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) return []
|
||||||
|
|
||||||
|
const data = await res.json()
|
||||||
|
return (data.results || []).map((r: RAGSearchResult) => ({
|
||||||
|
...r,
|
||||||
|
collection,
|
||||||
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!res.ok) {
|
const settled = await Promise.allSettled(searchPromises)
|
||||||
console.warn('RAG search failed:', res.status)
|
const allResults: RAGSearchResult[] = []
|
||||||
return ''
|
|
||||||
|
for (const result of settled) {
|
||||||
|
if (result.status === 'fulfilled') {
|
||||||
|
allResults.push(...result.value)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await res.json()
|
// Sort by score descending, take top 8
|
||||||
|
allResults.sort((a, b) => b.score - a.score)
|
||||||
|
const topResults = allResults.slice(0, 8)
|
||||||
|
|
||||||
if (data.results && data.results.length > 0) {
|
if (topResults.length === 0) return ''
|
||||||
return data.results
|
|
||||||
.map(
|
|
||||||
(r: RAGResult, i: number) =>
|
|
||||||
`[Quelle ${i + 1}: ${r.source_name || r.source_code || 'Unbekannt'}]\n${r.content || ''}`
|
|
||||||
)
|
|
||||||
.join('\n\n---\n\n')
|
|
||||||
}
|
|
||||||
|
|
||||||
return ''
|
return topResults
|
||||||
|
.map((r, i) => {
|
||||||
|
const source = r.source_name || r.source_code || 'Unbekannt'
|
||||||
|
const col = r.collection ? ` [${r.collection}]` : ''
|
||||||
|
return `[Quelle ${i + 1}: ${source}${col}]\n${r.content || ''}`
|
||||||
|
})
|
||||||
|
.join('\n\n---\n\n')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('RAG query error (continuing without context):', error)
|
console.warn('Multi-collection RAG query error (continuing without context):', error)
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -121,25 +179,38 @@ async function queryRAG(query: string): Promise<string> {
|
|||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
const { message, history = [], currentStep = 'default' } = body
|
const { message, history = [], currentStep = 'default', country } = body
|
||||||
|
|
||||||
if (!message || typeof message !== 'string') {
|
if (!message || typeof message !== 'string') {
|
||||||
return NextResponse.json({ error: 'Message is required' }, { status: 400 })
|
return NextResponse.json({ error: 'Message is required' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Query RAG for relevant context
|
// Validate country parameter
|
||||||
const ragContext = await queryRAG(message)
|
const validCountry = ['DE', 'AT', 'CH', 'EU'].includes(country) ? (country as Country) : undefined
|
||||||
|
|
||||||
// 2. Build system prompt with RAG context
|
// 1. Query RAG across all collections
|
||||||
|
const ragContext = await queryMultiCollectionRAG(message, validCountry)
|
||||||
|
|
||||||
|
// 2. Build system prompt with RAG context + country
|
||||||
let systemContent = SYSTEM_PROMPT
|
let systemContent = SYSTEM_PROMPT
|
||||||
|
|
||||||
|
if (validCountry) {
|
||||||
|
const countryLabel = COUNTRY_LABELS[validCountry]
|
||||||
|
systemContent += `\n\n## Laenderspezifische Auskunft
|
||||||
|
Der Nutzer hat "${countryLabel} (${validCountry})" gewaehlt.
|
||||||
|
- Beziehe dich AUSSCHLIESSLICH auf ${validCountry}-Recht + anwendbares EU-Recht
|
||||||
|
- Nenne IMMER explizit das Land in deiner Antwort
|
||||||
|
- Verwende NIEMALS Gesetze eines anderen Landes
|
||||||
|
- Bei ${validCountry === 'EU' ? 'EU-weiten Fragen: Beziehe dich auf EU-Verordnungen und -Richtlinien' : `${countryLabel}: Beziehe nationale Gesetze (${validCountry === 'DE' ? 'BDSG, TDDDG, TKG, UWG' : validCountry === 'AT' ? 'AT DSG, ECG, TKG, KSchG, MedienG' : 'CH DSG, DSV, OR, UWG, FMG'}) mit ein`}`
|
||||||
|
}
|
||||||
|
|
||||||
if (ragContext) {
|
if (ragContext) {
|
||||||
systemContent += `\n\n## Relevanter Kontext aus dem RAG-System\n\nNutze die folgenden Quellen fuer deine Antwort. Verweise in deiner Antwort auf die jeweilige Quelle:\n\n${ragContext}`
|
systemContent += `\n\n## Relevanter Kontext aus dem RAG-System\n\nNutze die folgenden Quellen fuer deine Antwort. Verweise in deiner Antwort auf die jeweilige Quelle:\n\n${ragContext}`
|
||||||
}
|
}
|
||||||
|
|
||||||
systemContent += `\n\n## Aktueller SDK-Schritt\nDer Nutzer befindet sich im SDK-Schritt: ${currentStep}`
|
systemContent += `\n\n## Aktueller SDK-Schritt\nDer Nutzer befindet sich im SDK-Schritt: ${currentStep}`
|
||||||
|
|
||||||
// 3. Build messages array (limit history to last 10 messages)
|
// 3. Build messages array (limit history to last 6 messages)
|
||||||
const messages = [
|
const messages = [
|
||||||
{ role: 'system', content: systemContent },
|
{ role: 'system', content: systemContent },
|
||||||
...history.slice(-6).map((h: { role: string; content: string }) => ({
|
...history.slice(-6).map((h: { role: string; content: string }) => ({
|
||||||
|
|||||||
@@ -59,6 +59,15 @@ const EXAMPLE_QUESTIONS: Record<string, string[]> = {
|
|||||||
// COMPONENT
|
// COMPONENT
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
|
type Country = 'DE' | 'AT' | 'CH' | 'EU'
|
||||||
|
|
||||||
|
const COUNTRIES: { code: Country; label: string }[] = [
|
||||||
|
{ code: 'DE', label: 'DE' },
|
||||||
|
{ code: 'AT', label: 'AT' },
|
||||||
|
{ code: 'CH', label: 'CH' },
|
||||||
|
{ code: 'EU', label: 'EU' },
|
||||||
|
]
|
||||||
|
|
||||||
export function ComplianceAdvisorWidget({ currentStep = 'default', enableDraftingEngine = false }: ComplianceAdvisorWidgetProps) {
|
export function ComplianceAdvisorWidget({ currentStep = 'default', enableDraftingEngine = false }: ComplianceAdvisorWidgetProps) {
|
||||||
// Feature-flag: If Drafting Engine enabled, render DraftingEngineWidget instead
|
// Feature-flag: If Drafting Engine enabled, render DraftingEngineWidget instead
|
||||||
if (enableDraftingEngine) {
|
if (enableDraftingEngine) {
|
||||||
@@ -71,6 +80,7 @@ export function ComplianceAdvisorWidget({ currentStep = 'default', enableDraftin
|
|||||||
const [messages, setMessages] = useState<Message[]>([])
|
const [messages, setMessages] = useState<Message[]>([])
|
||||||
const [inputValue, setInputValue] = useState('')
|
const [inputValue, setInputValue] = useState('')
|
||||||
const [isTyping, setIsTyping] = useState(false)
|
const [isTyping, setIsTyping] = useState(false)
|
||||||
|
const [selectedCountry, setSelectedCountry] = useState<Country>('DE')
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||||
const abortControllerRef = useRef<AbortController | null>(null)
|
const abortControllerRef = useRef<AbortController | null>(null)
|
||||||
|
|
||||||
@@ -124,6 +134,7 @@ export function ComplianceAdvisorWidget({ currentStep = 'default', enableDraftin
|
|||||||
message: content.trim(),
|
message: content.trim(),
|
||||||
history,
|
history,
|
||||||
currentStep,
|
currentStep,
|
||||||
|
country: selectedCountry,
|
||||||
}),
|
}),
|
||||||
signal: abortControllerRef.current.signal,
|
signal: abortControllerRef.current.signal,
|
||||||
})
|
})
|
||||||
@@ -201,7 +212,7 @@ export function ComplianceAdvisorWidget({ currentStep = 'default', enableDraftin
|
|||||||
setIsTyping(false)
|
setIsTyping(false)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[isTyping, messages, currentStep]
|
[isTyping, messages, currentStep, selectedCountry]
|
||||||
)
|
)
|
||||||
|
|
||||||
// Handle stop generation
|
// Handle stop generation
|
||||||
@@ -269,7 +280,21 @@ export function ComplianceAdvisorWidget({ currentStep = 'default', enableDraftin
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="font-semibold text-sm">Compliance Advisor</div>
|
<div className="font-semibold text-sm">Compliance Advisor</div>
|
||||||
<div className="text-xs text-white/80">KI-gestuetzter Assistent</div>
|
<div className="flex items-center gap-1 mt-0.5">
|
||||||
|
{COUNTRIES.map(({ code, label }) => (
|
||||||
|
<button
|
||||||
|
key={code}
|
||||||
|
onClick={() => setSelectedCountry(code)}
|
||||||
|
className={`px-1.5 py-0.5 text-[10px] font-medium rounded transition-colors ${
|
||||||
|
selectedCountry === code
|
||||||
|
? 'bg-white text-indigo-700'
|
||||||
|
: 'bg-white/15 text-white/80 hover:bg-white/25'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
|
|||||||
Reference in New Issue
Block a user