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>
This commit is contained in:
Benjamin Admin
2026-07-01 15:17:21 +02:00
parent f37081b60b
commit a9b04e5286
6 changed files with 290 additions and 28 deletions
@@ -45,17 +45,26 @@ folgen belegte Quellen. Wenn der Begriff in mehreren Bereichen vorkommt, erwaehn
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)
- Gut gegliedertes Markdown: kurze ## Ueberschriften je Aspekt, Aufzaehlungen, **Fettung** fuer Kernbegriffe.`
- 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.`
@@ -71,6 +80,7 @@ export async function POST(request: NextRequest) {
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
@@ -87,7 +97,7 @@ export async function POST(request: NextRequest) {
const legacyEvidence = retrieved.evidence ?? []
const legacySoul = await readSoulFile('compliance-advisor')
const legacyStream = await streamAdvisorAnswer([
{ role: 'system', content: answerSystem(legacySoul, country, numberedEvidenceForPrompt(legacyEvidence), false) },
{ role: 'system', content: answerSystem(legacySoul, country, numberedEvidenceForPrompt(legacyEvidence), false, audience) },
{ role: 'user', content: question },
])
if (!legacyStream) {
@@ -106,7 +116,7 @@ export async function POST(request: NextRequest) {
if (mode === 'clarify') {
const general = await completeAdvisorAnswer([
{ role: 'system', content: L1_SYSTEM },
{ role: 'system', content: L1_SYSTEM + audienceBlock(audience) },
{ role: 'user', content: question },
])
if (general === null) {
@@ -130,7 +140,7 @@ export async function POST(request: NextRequest) {
const evidence = retrieved.evidence ?? []
const soul = await readSoulFile('compliance-advisor')
const messages: ChatMessage[] = [
{ role: 'system', content: answerSystem(soul, country, numberedEvidenceForPrompt(evidence)) },
{ role: 'system', content: answerSystem(soul, country, numberedEvidenceForPrompt(evidence), true, audience) },
{ role: 'user', content: question },
]
const answer = await completeAdvisorAnswer(messages)