feat(advisor): Clarity-Gate orchestration in route.ts (consumes /retrieve)
Completes the advisor stack (FE + orchestration; /retrieve is SDK/RAG-owned). The route
now returns the FE contract instead of a text stream:
- retrieveFull() calls /retrieve with {query, context}; consumes clarity/evidence/
visual_evidence/footnotes (exact shape per board 2026-07-01 12:25).
- mode-routing (resolveMode): clarify unless a context was chosen and /retrieve's
clarity.mode says so. clarify -> L1 general answer (completeAdvisorAnswer, ungrounded,
no sources). answer -> L2 answer over numbered evidence with [n] markers.
- citations generated here ([n] -> nth evidence unit); footnotes remapped; evidence /
visual_evidence passed through.
- advisor-llm: non-streaming completeAdvisorAnswer(). Pure mappings in retrieve-mapping.ts
(+ tests). Removed the dead v2 evidence.ts/evidence-adapter (RegulationRef moved to
regulation-display). controls-augmentation kept (tested; re-integrable later).
NOT deployed: joint deploy with the SDK /retrieve endpoint (deploy-coupling). tsc clean,
25 vitest (mapping/clarify/answer/markdown/registry/rag), check-loc 0.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,52 +1,28 @@
|
||||
/**
|
||||
* Compliance Advisor Chat API — Evidence Workspace envelope.
|
||||
* Compliance Advisor Chat API — Clarity-Gate orchestration.
|
||||
*
|
||||
* 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.
|
||||
* 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 { 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'
|
||||
import { retrieveFull } from '@/lib/sdk/agents/advisor-rag'
|
||||
import { completeAdvisorAnswer, type ChatMessage } from '@/lib/sdk/agents/advisor-llm'
|
||||
import {
|
||||
buildCitations,
|
||||
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 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',
|
||||
@@ -56,116 +32,94 @@ const COUNTRY_LABELS: Record<Country, string> = {
|
||||
|
||||
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}`
|
||||
Der Nutzer hat "${label} (${c})" gewaehlt. Beziehe dich auf ${c}-Recht + anwendbares EU-Recht und nenne das Land.`
|
||||
}
|
||||
|
||||
/** 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()
|
||||
},
|
||||
})
|
||||
// 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): 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.
|
||||
- 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.
|
||||
- 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 { 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)
|
||||
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
|
||||
|
||||
// 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 (!question) {
|
||||
return NextResponse.json({ error: 'Question is required' }, { status: 400 })
|
||||
}
|
||||
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 retrieved = await retrieveFull(question, context)
|
||||
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: systemContent },
|
||||
...history.slice(-6).map((h: { role: string; content: string }) => ({
|
||||
role: h.role === 'user' ? 'user' : 'assistant',
|
||||
content: h.content,
|
||||
})),
|
||||
{ role: 'user', content: message },
|
||||
{ role: 'system', content: answerSystem(soul, country, numberedEvidenceForPrompt(evidence)) },
|
||||
{ role: 'user', content: question },
|
||||
]
|
||||
|
||||
// 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 },
|
||||
)
|
||||
const answer = await completeAdvisorAnswer(messages)
|
||||
if (answer === null) {
|
||||
return NextResponse.json({ error: 'LLM nicht erreichbar.' }, { 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',
|
||||
},
|
||||
})
|
||||
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 LLM fehlgeschlagen.' },
|
||||
{ status: 503 },
|
||||
)
|
||||
return NextResponse.json({ error: 'Verbindung zum Advisor fehlgeschlagen.' }, { status: 503 })
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user