/** * 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' import { readSoulFile } from '@/lib/sdk/agents/soul-reader' 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' // Fallback SOUL prompt (used when .soul.md file is unavailable) const FALLBACK_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. ## Kernprinzipien - Quellenbasiert: Verweise auf DSGVO-Artikel, BDSG-Paragraphen - Verstaendlich: Einfache, praxisnahe Sprache - Ehrlich: Bei Unsicherheit empfehle Rechtsberatung - Deutsch als Hauptsprache` const COUNTRY_LABELS: Record = { 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 } /** * Query multiple RAG collections in parallel, with optional country filter */ async function queryMultiCollectionRAG(query: string, country?: Country): Promise { try { const searchPromises = COMPLIANCE_COLLECTIONS.map(async (collection) => { const searchBody: Record = { 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 const soulPrompt = await readSoulFile('compliance-advisor') let systemContent = soulPrompt || FALLBACK_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, think: false, options: { temperature: 0.3, num_predict: 8192, num_ctx: 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 } ) } }