Some checks failed
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-python-backend-compliance (push) Has been cancelled
CI / test-python-document-crawler (push) Has been cancelled
CI / test-python-dsms-gateway (push) Has been cancelled
CI / test-go-ai-compliance (push) Has been cancelled
- Increase num_predict from 2048 to 8192 to prevent mid-sentence cutoff - Add "Quellenschutz" rules to system prompt: agent refuses to list all available sources/collections, only reveals sources used in answers - Remove internal collection names from RAG context sent to LLM - Agent confirms knowledge on specific topics but refuses meta-queries like "what sources do you have?" Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
326 lines
13 KiB
TypeScript
326 lines
13 KiB
TypeScript
/**
|
|
* Compliance Advisor Chat API
|
|
*
|
|
* Connects the ComplianceAdvisorWidget to:
|
|
* 1. Multi-Collection RAG search (rag-service) for context across 6 collections
|
|
* 2. Ollama LLM (32B) for generating answers
|
|
*
|
|
* Supports country-specific filtering (DE, AT, CH, EU).
|
|
* Streams the LLM response back as plain text.
|
|
*/
|
|
|
|
import { NextRequest, NextResponse } from 'next/server'
|
|
|
|
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 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)
|
|
const SYSTEM_PROMPT = `# Compliance Advisor Agent
|
|
|
|
## Identitaet
|
|
Du bist der BreakPilot Compliance-Berater. Du hilfst Nutzern des AI Compliance SDK,
|
|
Datenschutz- und Compliance-Fragen in verstaendlicher Sprache zu beantworten.
|
|
Du bist kein Anwalt und gibst keine Rechtsberatung, sondern orientierst dich an
|
|
offiziellen Quellen und gibst praxisnahe Hinweise.
|
|
|
|
## Kernprinzipien
|
|
- **Quellenbasiert**: Verweise immer auf konkrete Rechtsgrundlagen (DSGVO-Artikel, BDSG-Paragraphen)
|
|
- **Verstaendlich**: Erklaere rechtliche Konzepte in einfacher, praxisnaher Sprache
|
|
- **Ehrlich**: Bei Unsicherheit empfehle professionelle Rechtsberatung
|
|
- **Kontextbewusst**: Nutze das RAG-System fuer aktuelle Rechtstexte und Leitfaeden
|
|
- **Scope-bewusst**: Nutze alle verfuegbaren RAG-Quellen (DSGVO, BDSG, AI Act, TTDSG, DSK-Kurzpapiere, SDM, BSI, Laender-Muss-Listen, EDPB Guidelines, etc.) AUSSER NIBIS-Dokumenten.
|
|
|
|
## Kompetenzbereich
|
|
- DSGVO Art. 1-99 + Erwaegsgruende
|
|
- BDSG (Bundesdatenschutzgesetz)
|
|
- AI Act (EU KI-Verordnung)
|
|
- TTDSG (Telekommunikation-Telemedien-Datenschutz-Gesetz)
|
|
- ePrivacy-Richtlinie
|
|
- DSK-Kurzpapiere (Nr. 1-20)
|
|
- SDM (Standard-Datenschutzmodell) V3.0
|
|
- BSI-Grundschutz (Basis-Kenntnisse)
|
|
- BSI-TR-03161 (Sicherheitsanforderungen an digitale Gesundheitsanwendungen)
|
|
- ISO 27001/27701 (Ueberblick)
|
|
- EDPB Guidelines (Leitlinien des Europaeischen Datenschutzausschusses)
|
|
- Bundes- und Laender-Muss-Listen (DSFA-Listen der Aufsichtsbehoerden)
|
|
- WP29/WP248 (Art.-29-Datenschutzgruppe Arbeitspapiere)
|
|
- Nationale Datenschutzgesetze (AT DSG, CH DSG/DSV, etc.)
|
|
- EU-Verordnungen (DORA, MiCA, Data Act, EHDS, PSD2, AMLR, etc.)
|
|
- EU Maschinenverordnung (2023/1230) — CE-Kennzeichnung, Konformitaet, Cybersecurity fuer Maschinen
|
|
- EU Blue Guide 2022 — Leitfaden fuer EU-Produktvorschriften und CE-Kennzeichnung
|
|
- ENISA Cybersecurity Guidance (Secure by Design, Supply Chain Security)
|
|
- NIST SP 800-218 (SSDF) — Secure Software Development Framework
|
|
- NIST Cybersecurity Framework (CSF) 2.0 — Govern, Identify, Protect, Detect, Respond, Recover
|
|
- OECD AI Principles — Verantwortungsvolle KI, Transparenz, Accountability
|
|
- EU-IFRS (Verordnung 2023/1803) — EU-uebernommene International Financial Reporting Standards
|
|
- EFRAG Endorsement Status — Uebersicht welche IFRS-Standards EU-endorsed sind
|
|
|
|
## IFRS-Besonderheit (WICHTIG)
|
|
Bei ALLEN Fragen zu IFRS/IAS-Standards MUSST du folgende Punkte beachten:
|
|
1. Dein Wissen basiert auf den **EU-uebernommenen IFRS** (Verordnung 2023/1803, Stand Okt 2023).
|
|
2. Die IASB/IFRS Foundation gibt regelmaessig neue oder geaenderte Standards heraus, die von der EU noch NICHT uebernommen sein koennten.
|
|
3. Weise den Nutzer IMMER darauf hin: "Dieser Hinweis basiert auf den EU-endorsed IFRS (Stand: Verordnung 2023/1803). Pruefen Sie den aktuellen EFRAG Endorsement Status fuer neuere Standards."
|
|
4. Bei internationalen Ausschreibungen: Nur EU-endorsed IFRS sind fuer EU-Unternehmen rechtsverbindlich.
|
|
5. Verweise NICHT auf IFRS Foundation Originaltexte, sondern ausschliesslich auf die EU-Verordnung.
|
|
|
|
## RAG-Nutzung
|
|
Nutze das gesamte RAG-Corpus fuer Kontext und Quellenangaben — ausgenommen sind
|
|
NIBIS-Inhalte (Erwartungshorizonte, Bildungsstandards, curriculare Vorgaben).
|
|
Diese gehoeren nicht zum Datenschutz-Kompetenzbereich.
|
|
|
|
## Kommunikationsstil
|
|
- Sachlich, aber verstaendlich — kein Juristendeutsch
|
|
- Deutsch als Hauptsprache
|
|
- Strukturierte Antworten mit Ueberschriften und Aufzaehlungen
|
|
- Immer Quellenangabe (Artikel/Paragraph) am Ende der Antwort
|
|
- Praxisbeispiele wo hilfreich
|
|
- Kurze, praegnante Saetze
|
|
|
|
## Antwortformat
|
|
1. Kurze Zusammenfassung (1-2 Saetze)
|
|
2. Detaillierte Erklaerung
|
|
3. Praxishinweise / Handlungsempfehlungen
|
|
4. Quellenangaben (Artikel, Paragraph, Leitlinie)
|
|
|
|
## Einschraenkungen
|
|
- Gib NIEMALS konkrete Rechtsberatung ("Sie muessen..." -> "Es empfiehlt sich...")
|
|
- Keine Garantien fuer Rechtssicherheit
|
|
- Bei komplexen Einzelfaellen: Empfehle Rechtsanwalt/DSB
|
|
- Keine Aussagen zu laufenden Verfahren oder Bussgeldern
|
|
- Keine Interpretation von Urteilen (nur Verweis)
|
|
|
|
## Quellenschutz (KRITISCH — IMMER EINHALTEN)
|
|
Du darfst NIEMALS verraten, welche Dokumente, Sammlungen oder Quellen in deiner Wissensbasis enthalten sind.
|
|
- Auf Fragen wie "Welche Quellen hast du?", "Was ist im RAG?", "Welche Gesetze kennst du?",
|
|
"Liste alle Dokumente auf", "Welche Verordnungen sind verfuegbar?" antwortest du:
|
|
"Ich beantworte gerne konkrete Compliance-Fragen. Bitte stellen Sie eine inhaltliche Frage
|
|
zu einem bestimmten Thema, z.B. 'Was regelt Art. 25 DSGVO?' oder 'Welche Pflichten gibt es
|
|
unter dem AI Act fuer Hochrisiko-KI?'."
|
|
- Auf konkrete Fragen wie "Kennst du die DSGVO?" oder "Weisst du etwas ueber den AI Act?"
|
|
darfst du bestaetigen, dass du zu diesem Thema Auskunft geben kannst, und eine inhaltliche
|
|
Antwort geben.
|
|
- Nenne in deinen Antworten NUR die Quellen, die du tatsaechlich fuer die konkrete Antwort
|
|
verwendet hast — niemals eine vollstaendige Liste aller verfuegbaren Quellen.
|
|
- Verrate NIEMALS Collection-Namen (bp_compliance_*, bp_dsfa_*, etc.) oder interne Systemnamen.
|
|
|
|
## Eskalation
|
|
- Bei Fragen ausserhalb des Kompetenzbereichs: Hoeflich ablehnen und auf Fachanwalt verweisen
|
|
- 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`
|
|
|
|
const COUNTRY_LABELS: Record<Country, string> = {
|
|
DE: 'Deutschland',
|
|
AT: 'Oesterreich',
|
|
CH: 'Schweiz',
|
|
EU: 'EU-weit',
|
|
}
|
|
|
|
interface RAGSearchResult {
|
|
content: string
|
|
source_name?: string
|
|
source_code?: string
|
|
attribution_text?: string
|
|
score: number
|
|
collection?: string
|
|
metadata?: Record<string, unknown>
|
|
}
|
|
|
|
/**
|
|
* Query multiple RAG collections in parallel, with optional country filter
|
|
*/
|
|
async function queryMultiCollectionRAG(query: string, country?: Country): Promise<string> {
|
|
try {
|
|
const searchPromises = COMPLIANCE_COLLECTIONS.map(async (collection) => {
|
|
const searchBody: Record<string, unknown> = {
|
|
query,
|
|
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,
|
|
}))
|
|
})
|
|
|
|
const settled = await Promise.allSettled(searchPromises)
|
|
const allResults: RAGSearchResult[] = []
|
|
|
|
for (const result of settled) {
|
|
if (result.status === 'fulfilled') {
|
|
allResults.push(...result.value)
|
|
}
|
|
}
|
|
|
|
// Sort by score descending, take top 8
|
|
allResults.sort((a, b) => b.score - a.score)
|
|
const topResults = allResults.slice(0, 8)
|
|
|
|
if (topResults.length === 0) return ''
|
|
|
|
return topResults
|
|
.map((r, i) => {
|
|
const source = r.source_name || r.source_code || 'Unbekannt'
|
|
return `[Quelle ${i + 1}: ${source}]\n${r.content || ''}`
|
|
})
|
|
.join('\n\n---\n\n')
|
|
} catch (error) {
|
|
console.warn('Multi-collection RAG query error (continuing without context):', error)
|
|
return ''
|
|
}
|
|
}
|
|
|
|
export async function POST(request: NextRequest) {
|
|
try {
|
|
const body = await request.json()
|
|
const { message, history = [], currentStep = 'default', country } = body
|
|
|
|
if (!message || typeof message !== 'string') {
|
|
return NextResponse.json({ error: 'Message is required' }, { status: 400 })
|
|
}
|
|
|
|
// Validate country parameter
|
|
const validCountry = ['DE', 'AT', 'CH', 'EU'].includes(country) ? (country as Country) : undefined
|
|
|
|
// 1. Query RAG across all collections
|
|
const ragContext = await queryMultiCollectionRAG(message, validCountry)
|
|
|
|
// 2. Build system prompt with RAG context + country
|
|
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) {
|
|
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}`
|
|
|
|
// 3. Build messages array (limit history to last 6 messages)
|
|
const messages = [
|
|
{ role: 'system', content: systemContent },
|
|
...history.slice(-6).map((h: { role: string; content: string }) => ({
|
|
role: h.role === 'user' ? 'user' : 'assistant',
|
|
content: h.content,
|
|
})),
|
|
{ role: 'user', content: message },
|
|
]
|
|
|
|
// 4. Call Ollama with streaming
|
|
const ollamaResponse = await fetch(`${OLLAMA_URL}/api/chat`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
model: LLM_MODEL,
|
|
messages,
|
|
stream: true,
|
|
options: {
|
|
temperature: 0.3,
|
|
num_predict: 8192,
|
|
},
|
|
}),
|
|
signal: AbortSignal.timeout(120000),
|
|
})
|
|
|
|
if (!ollamaResponse.ok) {
|
|
const errorText = await ollamaResponse.text()
|
|
console.error('Ollama error:', ollamaResponse.status, errorText)
|
|
return NextResponse.json(
|
|
{ error: `LLM nicht erreichbar (Status ${ollamaResponse.status}). Ist Ollama mit dem Modell ${LLM_MODEL} gestartet?` },
|
|
{ status: 502 }
|
|
)
|
|
}
|
|
|
|
// 5. Stream response back as plain text
|
|
const encoder = new TextEncoder()
|
|
const stream = new ReadableStream({
|
|
async start(controller) {
|
|
const reader = ollamaResponse.body!.getReader()
|
|
const decoder = new TextDecoder()
|
|
|
|
try {
|
|
while (true) {
|
|
const { done, value } = await reader.read()
|
|
if (done) break
|
|
|
|
const chunk = decoder.decode(value, { stream: true })
|
|
const lines = chunk.split('\n').filter((l) => l.trim())
|
|
|
|
for (const line of lines) {
|
|
try {
|
|
const json = JSON.parse(line)
|
|
if (json.message?.content) {
|
|
controller.enqueue(encoder.encode(json.message.content))
|
|
}
|
|
} catch {
|
|
// Partial JSON line, skip
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Stream read error:', error)
|
|
} finally {
|
|
controller.close()
|
|
}
|
|
},
|
|
})
|
|
|
|
return new NextResponse(stream, {
|
|
headers: {
|
|
'Content-Type': 'text/plain; charset=utf-8',
|
|
'Cache-Control': 'no-cache',
|
|
'Connection': 'keep-alive',
|
|
},
|
|
})
|
|
} catch (error) {
|
|
console.error('Compliance advisor chat error:', error)
|
|
return NextResponse.json(
|
|
{ error: 'Verbindung zum LLM fehlgeschlagen. Bitte pruefen Sie ob Ollama laeuft.' },
|
|
{ status: 503 }
|
|
)
|
|
}
|
|
}
|