Files
breakpilot-compliance/admin-compliance/app/api/sdk/compliance-advisor/chat/route.ts
T
Benjamin Admin a9b04e5286 feat(advisor): evidence-framed header + bindingness contract seam
Rework the Compliance Advisor header ("Diese Antwort stuetzt sich auf")
to describe the EVIDENCE rather than the documents: binding
Rechtsgrundlagen split from Leitlinien (soft-law guidance), a
per-regulation breakdown, plus Abbildungen, Fussnoten and Evidence Units.
No fabricated trust score — objective counts only.

- bindingness is a canonical Legal-KG fact (APEX rule): added an optional
  EvidenceUnit.bindingness contract seam; the FE renders the split from it
  and degrades to a neutral per-regulation breakdown when it is absent
  (SDK/RAG asked via board to populate it in /retrieve).
- evidence-grouping.ts: pure, tested grouping/counting model.
- route.ts: optional `audience` field (tonality) kept out of the retrieval
  question; answers lead with a "Kurz gesagt" summary, structured by theme.
- E2E + unit tests updated for the evidence framing.

Not deployed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-01 15:17:21 +02:00

168 lines
6.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<Country, string> = {
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.`
// Optional audience/tonality guidance (e.g. the workspace's role hint). Kept out of the retrieval
// `question` on purpose — it only shapes the answer's tone, so it belongs in the system prompt.
function audienceBlock(audience: string): string {
return audience ? `\n\n## Ansprache / Zielgruppe\n${audience}` : ''
}
function answerSystem(
soul: string | null,
country: Country | undefined,
evidenceBlock: string,
withCitations = true,
audience = '',
): string {
let s = soul || FALLBACK_SYSTEM
if (country) s += countryBlock(country)
s += audienceBlock(audience)
s += `\n\n## Belegte Evidence (nummeriert — DEINE EINZIGEN Quellen)\n${evidenceBlock || '(keine Evidence gefunden)'}`
s += `\n\n## Antwortformat (WICHTIG)
- Beginne mit einer **Kurzzusammenfassung** (12 Saetze, "Kurz gesagt: …"), die den Kern direkt beantwortet.
- Danach gut gegliedertes Markdown: kurze ## Ueberschriften je THEMA/Aspekt (nicht je Rechtsquelle), 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 audience = typeof body.audience === 'string' ? body.audience.trim() : ''
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, audience) },
{ 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 + audienceBlock(audience) },
{ 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), true, audience) },
{ 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 })
}
}