/** * Compliance Advisor Chat API — Clarity-Gate orchestration. * * Consumes the SDK/RAG /retrieve (evidence/visual_evidence/footnotes/clarity) and returns the * FE-facing contract (advisor-clarity-gate-contract): * - clarify mode -> short L1 general answer (no RAG) + domain context chips * - answer mode -> L2 answer over the scoped evidence with [n] citation markers * Citations are generated here ([n] -> nth evidence unit). The FE renders ONLY this structured data. */ import { NextRequest, NextResponse } from 'next/server' import { readSoulFile } from '@/lib/sdk/agents/soul-reader' import { retrieveFull } from '@/lib/sdk/agents/advisor-rag' import { completeAdvisorAnswer, streamAdvisorAnswer, type ChatMessage } from '@/lib/sdk/agents/advisor-llm' import { buildCitations, isLegacyRequest, mapClarity, mapFootnotes, numberedEvidenceForPrompt, resolveMode, } from '@/lib/sdk/advisor/retrieve-mapping' import type { AdvisorResponse } from '@/lib/sdk/advisor/contract' type Country = 'DE' | 'AT' | 'CH' | 'EU' const COUNTRY_LABELS: Record = { DE: 'Deutschland', AT: 'Oesterreich', CH: 'Schweiz', EU: 'EU-weit', } function countryBlock(c: Country): string { const label = COUNTRY_LABELS[c] return `\n\n## Laenderspezifische Auskunft Der Nutzer hat "${label} (${c})" gewaehlt. Beziehe dich auf ${c}-Recht + anwendbares EU-Recht und nenne das Land.` } // L1: general knowledge, deliberately NOT grounded (the clarify step precedes the legal retrieval). const L1_SYSTEM = `Du bist der BreakPilot Compliance-Berater. Gib eine KURZE, allgemeine Definition/Erklaerung des gefragten Begriffs aus Allgemeinwissen — 2 bis 4 Saetze, Markdown, neutral. NENNE KEINE Rechtsquellen, Paragraphen, Artikel oder Fundstellen; der Nutzer waehlt anschliessend einen konkreten Kontext, erst dann folgen belegte Quellen. Wenn der Begriff in mehreren Bereichen vorkommt, erwaehne das in einem Halbsatz.` const FALLBACK_SYSTEM = `Du bist der BreakPilot Compliance-Berater. Antworte quellenbasiert, verstaendlich und ehrlich auf Deutsch.` function answerSystem( soul: string | null, country: Country | undefined, evidenceBlock: string, withCitations = true, ): string { let s = soul || FALLBACK_SYSTEM if (country) s += countryBlock(country) s += `\n\n## Belegte Evidence (nummeriert — DEINE EINZIGEN Quellen)\n${evidenceBlock || '(keine Evidence gefunden)'}` s += `\n\n## Antwortformat (WICHTIG) - Gut gegliedertes Markdown: kurze ## Ueberschriften je Aspekt, Aufzaehlungen, **Fettung** fuer Kernbegriffe.` if (withCitations) { s += `\n- Belege Kernaussagen mit [n], wobei n die NUMMER der Evidence-Quelle oben ist (z. B. [1], [2]). - Nenne KEINE Quellen-/Fundstellen-Liste im Fliesstext — die Quellen werden dem Nutzer separat angezeigt.` } else { s += `\n- Nenne Fundstellen nur, wo sie der Antwort dienen (natuerlich im Text, KEIN [n]-Markup).` } s += `\n- Triff KEINE Aussage, die nicht durch die nummerierte Evidence belegt ist; fehlt der Beleg, sage das offen.` return s } export async function POST(request: NextRequest) { try { const body = await request.json() const question = String(body.question ?? body.message ?? '').trim() const context: string | null = body.context ?? null const country = (['DE', 'AT', 'CH', 'EU'] as const).includes(body.country) ? (body.country as Country) : undefined if (!question) { return NextResponse.json({ error: 'Question is required' }, { status: 400 }) } const retrieved = await retrieveFull(question, context) // Backward-compat: legacy consumers (breakpilot-workspace) send {message} and read a plain-text // stream. Serve the L2 answer streamed (clean prose, no [n]); no clarify gate, no JSON. if (isLegacyRequest(body)) { const legacyEvidence = retrieved.evidence ?? [] const legacySoul = await readSoulFile('compliance-advisor') const legacyStream = await streamAdvisorAnswer([ { role: 'system', content: answerSystem(legacySoul, country, numberedEvidenceForPrompt(legacyEvidence), false) }, { role: 'user', content: question }, ]) if (!legacyStream) { return NextResponse.json({ error: 'LLM nicht erreichbar.' }, { status: 502 }) } return new NextResponse(legacyStream, { headers: { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-cache', 'X-Advisor-Format': 'legacy-stream', }, }) } const mode = resolveMode(retrieved.clarity?.mode, !!context) if (mode === 'clarify') { const general = await completeAdvisorAnswer([ { role: 'system', content: L1_SYSTEM }, { role: 'user', content: question }, ]) if (general === null) { return NextResponse.json({ error: 'LLM nicht erreichbar.' }, { status: 502 }) } const resp: AdvisorResponse = { mode: 'clarify', question, clarity: mapClarity(retrieved.clarity, 'clarify'), general_answer: general, answer: null, scoped_query: null, evidence: [], citations: [], visual_evidence: [], footnotes: [], } return NextResponse.json(resp) } const evidence = retrieved.evidence ?? [] const soul = await readSoulFile('compliance-advisor') const messages: ChatMessage[] = [ { role: 'system', content: answerSystem(soul, country, numberedEvidenceForPrompt(evidence)) }, { role: 'user', content: question }, ] const answer = await completeAdvisorAnswer(messages) if (answer === null) { return NextResponse.json({ error: 'LLM nicht erreichbar.' }, { status: 502 }) } const resp: AdvisorResponse = { mode: 'answer', question, clarity: mapClarity(retrieved.clarity, 'answer'), general_answer: null, answer, scoped_query: context, evidence, citations: buildCitations(evidence), visual_evidence: retrieved.visual_evidence ?? [], footnotes: mapFootnotes(retrieved.footnotes), } return NextResponse.json(resp) } catch (error) { console.error('Compliance advisor chat error:', error) return NextResponse.json({ error: 'Verbindung zum Advisor fehlgeschlagen.' }, { status: 503 }) } }