/** * 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 { 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: 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 } ) } }