/** * Compliance Advisor Chat API — Evidence Workspace envelope. * * Verbindet das ComplianceAdvisorWidget mit: * 1. Strukturierter RAG-Evidence ueber die ai-compliance-sdk — siehe advisor-rag * 2. Strukturierten Controls zum erkannten Thema — buildControlsContext * 3. LLM-Kaskade OVH (prod) -> Ollama (Dev) — siehe advisor-llm * * Antwort-Format (evidence-meta-v1): ERSTE Zeile = JSON `AdvisorEvidenceMeta` * (Quellen/Abbildungen/Fussnoten/Stats), danach streamt die Antwort als Markdown-Text. * Das Frontend rendert NUR diese strukturierten Daten und parst NIE den Antworttext. */ import { NextRequest, NextResponse } from 'next/server' import { readSoulFile } from '@/lib/sdk/agents/soul-reader' import { buildControlsContext } from '@/lib/sdk/agents/controls-augmentation' import { retrieveAdvisorEvidence } from '@/lib/sdk/agents/advisor-rag' import { adaptEvidence, type RawFigure, type RawFootnote } from '@/lib/sdk/advisor/evidence-adapter' import { streamAdvisorAnswer, type ChatMessage } from '@/lib/sdk/agents/advisor-llm' type Country = 'DE' | 'AT' | 'CH' | 'EU' 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: Stuetze dich auf die bereitgestellten Rechtsquellen - Verstaendlich: Einfache, praxisnahe Sprache - Ehrlich: Bei Unsicherheit empfehle Rechtsberatung - Deutsch als Hauptsprache` // Antwort = saubere Prosa OHNE Inline-Fundstellen; die Quellen zeigt das Frontend separat an. const FORMAT_GUIDANCE = `\n\n## Antwortformat (WICHTIG) - Schreibe gut strukturiertes **Markdown**: kurze Abschnittsueberschriften (##), Aufzaehlungen (-), nummerierte Schritte und **Fettung** fuer Schluesselbegriffe. Halte Absaetze kurz. - Nenne Fundstellen/Quellen NICHT im Fliesstext (kein "(Art. 30 DSGVO)", keine "[Quelle 1]"). Die Quellen werden dem Nutzer in einem EIGENEN Bereich neben der Antwort angezeigt. - Schreibe so, dass die Antwort auch ohne eingebettete Zitate vollstaendig verstaendlich ist.` const COUNTRY_LABELS: Record = { DE: 'Deutschland', AT: 'Oesterreich', CH: 'Schweiz', EU: 'EU-weit', } function countryBlock(c: Country): string { const label = COUNTRY_LABELS[c] const nationalLaws = c === 'DE' ? 'BDSG, TDDDG, TKG, UWG' : c === 'AT' ? 'AT DSG, ECG, TKG, KSchG, MedienG' : 'CH DSG, DSV, OR, UWG, FMG' const guidance = c === 'EU' ? 'EU-weiten Fragen: Beziehe dich auf EU-Verordnungen und -Richtlinien' : `${label}: Beziehe nationale Gesetze (${nationalLaws}) mit ein` return `\n\n## Laenderspezifische Auskunft Der Nutzer hat "${label} (${c})" gewaehlt. - Beziehe dich AUSSCHLIESSLICH auf ${c}-Recht + anwendbares EU-Recht - Nenne IMMER explizit das Land in deiner Antwort - Verwende NIEMALS Gesetze eines anderen Landes - Bei ${guidance}` } /** Stellt der gestreamten Antwort eine JSON-Meta-Zeile voran (evidence-meta-v1). */ function withEvidenceMeta(meta: unknown, answer: ReadableStream): ReadableStream { const encoder = new TextEncoder() const metaLine = JSON.stringify(meta) + '\n' return new ReadableStream({ async start(controller) { controller.enqueue(encoder.encode(metaLine)) const reader = answer.getReader() try { for (;;) { const { done, value } = await reader.read() if (done) break if (value) controller.enqueue(value) } } catch (e) { controller.error(e) return } controller.close() }, }) } 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 }) } const validCountry = (['DE', 'AT', 'CH', 'EU'] as const).includes(country) ? (country as Country) : undefined // 1. Strukturierte RAG-Evidence + Controls zum Thema — parallel const [evidence, controlsContext] = await Promise.all([ retrieveAdvisorEvidence(message), buildControlsContext(message), ]) // 2. Evidence-Meta fuer das Frontend (strukturiert, nicht geparst) const meta = adaptEvidence({ results: evidence.results, figures: evidence.figures as RawFigure[] | undefined, footnotes: evidence.footnotes as RawFootnote[] | undefined, }) // 3. System-Prompt const soulPrompt = await readSoulFile('compliance-advisor') let systemContent = soulPrompt || FALLBACK_SYSTEM_PROMPT if (validCountry) systemContent += countryBlock(validCountry) if (evidence.contextText) { systemContent += `\n\n## Relevanter Kontext aus dem RAG-System (deine EINZIGEN Rechtsquellen)\n\nDies sind deine einzigen zulaessigen Rechtsquellen. Triff keine konkrete Rechtsaussage (Zahl, Frist, Schwelle, Pflicht, Fundstelle), die nicht hier oder im Controls-Block belegt ist — sonst sage offen, dass du sie aus deinen Quellen nicht belegen kannst.\n\n${evidence.contextText}` } if (controlsContext) systemContent += `\n\n${controlsContext}` systemContent += FORMAT_GUIDANCE systemContent += `\n\n## Aktueller SDK-Schritt\nDer Nutzer befindet sich im SDK-Schritt: ${currentStep}` // 4. Nachrichten (History auf die letzten 6 begrenzen) const messages: ChatMessage[] = [ { 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 }, ] // 5. LLM-Kaskade -> Meta-Zeile + Text-Stream const stream = await streamAdvisorAnswer(messages) if (!stream) { return NextResponse.json( { error: 'LLM nicht erreichbar. Weder OVH/LiteLLM noch Ollama haben geantwortet.' }, { status: 502 }, ) } return new NextResponse(withEvidenceMeta(meta, stream), { headers: { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-cache', Connection: 'keep-alive', 'X-Advisor-Format': 'evidence-meta-v1', }, }) } catch (error) { console.error('Compliance advisor chat error:', error) return NextResponse.json( { error: 'Verbindung zum LLM fehlgeschlagen.' }, { status: 503 }, ) } }