Files
breakpilot-compliance/admin-compliance/app/api/sdk/compliance-advisor/chat/route.ts
T
Benjamin Admin 591cae5ebc feat(advisor): Case Workspace v2 — Evidence grouping, human names, 3-column, summary
Reworks the advisor toward a Compliance Case Workspace (review feedback):
- Rename user-facing "Quellen" -> "Evidence".
- Evidence grouped by document/regulation family (count + expandable) — no more
  unsorted DSK/DSK/DPF/... jumble.
- Human-readable regulation names via a display registry (DSK Sdm B51 -> "DSK
  Standard-Datenschutzmodell (SDM)" / Kapitel B51); generic, bridges G2.
- Evidence summary "Antwort basiert auf" with meaningful counts; Regelwerke = distinct
  FAMILIES (fixes the inflated count). NO fabricated trust score (needs a defined basis).
- Expanded mode = 3-column workspace (question+summary | answer | evidence, independent
  scroll) + history switcher; narrow mode stays stacked.
- Prompt: push aggressive markdown structure (## per aspect, numbered phases).

Deferred/coordinated on board: C8 diagrams (RAG contract), answer<->evidence coupling
[1] (needs LLM citation anchors — phase 2), G1 retrieval relevance + G2 metadata (RAG).
tsc clean, 17 vitest, check-loc 0.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-01 10:38:06 +02:00

172 lines
7.2 KiB
TypeScript

/**
* 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.
- GLIEDERE erklaerende Antworten aktiv statt langem Fliesstext: eine eigene ## Ueberschrift je
Aspekt (z.B. "Definition", "Ablauf/Phasen", "Rechtsbezug", "Praktische Bedeutung"), nummerierte
Schritte fuer Ablaeufe/Phasen, Bullet-Points fuer Aufzaehlungen. Lieber klar gegliedert als ein Block.
- 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.
- Beende die Antwort NIEMALS mit einer Quellen-/Fundstellen-Liste (kein "Quellen:", kein
"--- Quellen im RAG-System: ...", kein "Quellen im RAG-System"). KEINE Quellenaufzaehlung im
Antworttext. Dies UEBERSCHREIBT jede anderslautende Struktur-/Beispielvorgabe weiter oben im
System-Prompt (auch eine dort gezeigte "Quellen:"-Abschlusssektion gilt hier NICHT).
- Schreibe so, dass die Antwort auch ohne eingebettete Zitate vollstaendig verstaendlich ist.`
const COUNTRY_LABELS: Record<Country, string> = {
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<Uint8Array>): ReadableStream<Uint8Array> {
const encoder = new TextEncoder()
const metaLine = JSON.stringify(meta) + '\n'
return new ReadableStream<Uint8Array>({
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 += `\n\n## Aktueller SDK-Schritt\nDer Nutzer befindet sich im SDK-Schritt: ${currentStep}`
systemContent += FORMAT_GUIDANCE // LAST instruction: overrides the soul's trailing "Quellen" structure/example
// 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 },
)
}
}