Switch from /admin/legal-corpus/search to /dsfa-rag/search endpoint. Add RAGResult interface for type safety. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
226 lines
7.7 KiB
TypeScript
226 lines
7.7 KiB
TypeScript
/**
|
|
* Compliance Advisor Chat API
|
|
*
|
|
* Connects the ComplianceAdvisorWidget to:
|
|
* 1. RAG legal corpus search (klausur-service) for context
|
|
* 2. Ollama LLM (32B) for generating answers
|
|
*
|
|
* Streams the LLM response back as plain text.
|
|
*/
|
|
|
|
import { NextRequest, NextResponse } from 'next/server'
|
|
|
|
const KLAUSUR_SERVICE_URL = process.env.KLAUSUR_SERVICE_URL || 'http://klausur-service:8086'
|
|
const OLLAMA_URL = process.env.OLLAMA_URL || 'http://host.docker.internal:11434'
|
|
const LLM_MODEL = process.env.COMPLIANCE_LLM_MODEL || 'qwen2.5vl:32b'
|
|
|
|
// 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)
|
|
|
|
## 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)
|
|
|
|
## 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`
|
|
|
|
interface RAGResult {
|
|
content: string
|
|
source_name: string
|
|
source_code: string
|
|
attribution_text: string
|
|
score: number
|
|
}
|
|
|
|
/**
|
|
* Query the DSFA RAG corpus for relevant documents
|
|
*/
|
|
async function queryRAG(query: string): Promise<string> {
|
|
try {
|
|
const url = `${KLAUSUR_SERVICE_URL}/api/v1/dsfa-rag/search?query=${encodeURIComponent(query)}&top_k=5`
|
|
const res = await fetch(url, {
|
|
headers: { 'Content-Type': 'application/json' },
|
|
signal: AbortSignal.timeout(10000),
|
|
})
|
|
|
|
if (!res.ok) {
|
|
console.warn('RAG search failed:', res.status)
|
|
return ''
|
|
}
|
|
|
|
const data = await res.json()
|
|
|
|
if (data.results && data.results.length > 0) {
|
|
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 ''
|
|
} catch (error) {
|
|
console.warn('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' } = body
|
|
|
|
if (!message || typeof message !== 'string') {
|
|
return NextResponse.json({ error: 'Message is required' }, { status: 400 })
|
|
}
|
|
|
|
// 1. Query RAG for relevant context
|
|
const ragContext = await queryRAG(message)
|
|
|
|
// 2. Build system prompt with RAG context
|
|
let systemContent = SYSTEM_PROMPT
|
|
|
|
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 10 messages)
|
|
const messages = [
|
|
{ role: 'system', content: systemContent },
|
|
...history.slice(-10).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: 2048,
|
|
},
|
|
}),
|
|
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 }
|
|
)
|
|
}
|
|
}
|