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)
@@ -1,10 +1,16 @@
'use client'
import { FileText, Hash, Image as ImageIcon, Library } from 'lucide-react'
import { BookMarked, FileText, Hash, Image as ImageIcon, Library, Scale } from 'lucide-react'
import type { AdvisorResponse } from '@/lib/sdk/advisor/contract'
import { resolveRegulation } from '@/lib/sdk/advisor/regulation-display'
import {
provisionSummary,
summarizeEvidence,
type FamilyGroup,
} from '@/lib/sdk/advisor/evidence-grouping'
function Card({
const plural = (n: number, one: string, many: string) => (n === 1 ? one : many)
function Count({
icon,
value,
label,
@@ -30,26 +36,100 @@ function Card({
)
}
/**
* "Antwort basiert auf" — objective counts only (no fabricated trust score). Regelwerke = distinct
* document families. Leitlinien deliberately omitted until bindingness exists in the Legal-KG.
*/
export function EvidenceSummary({ response }: { response: AdvisorResponse }) {
const families = new Set(
response.evidence.map((e) => resolveRegulation({ code: e.document, short: e.document }).familyKey),
).size
const cls = 'h-4 w-4'
function GroupRow({ group, icon }: { group: FamilyGroup; icon: React.ReactNode }) {
// A single-unit guidance doc needs no "1 Fundstelle" noise; norms always show their provisions.
const detail = group.sections.length === 0 && group.units <= 1 ? '' : provisionSummary(group)
return (
<div>
<div className="mb-1.5 text-[10px] font-semibold uppercase tracking-wide text-gray-400">
Antwort basiert auf
</div>
<div className="grid grid-cols-2 gap-1.5">
<Card icon={<Library className={cls} />} value={families} label="Regelwerke" />
<Card icon={<FileText className={cls} />} value={response.evidence.length} label="Evidence Units" />
<Card icon={<ImageIcon className={cls} />} value={response.visual_evidence.length} label="Diagramme" dim={response.visual_evidence.length === 0} />
<Card icon={<Hash className={cls} />} value={response.footnotes.length} label="Fußnoten" dim={response.footnotes.length === 0} />
</div>
<div className="flex items-start gap-2 py-0.5">
<span className="mt-0.5 shrink-0 text-gray-400">{icon}</span>
<span className="min-w-0 flex-1 text-[12px] leading-snug text-gray-700">{group.label}</span>
{detail && <span className="shrink-0 text-[11px] font-medium text-gray-500">{detail}</span>}
</div>
)
}
function Section({
title,
groups,
icon,
}: {
title: string
groups: FamilyGroup[]
icon: React.ReactNode
}) {
if (groups.length === 0) return null
return (
<div className="mt-2 first:mt-0">
<div className="mb-0.5 text-[10px] font-semibold uppercase tracking-wide text-gray-400">{title}</div>
{groups.map((g) => (
<GroupRow key={g.key} group={g} icon={icon} />
))}
</div>
)
}
/**
* "Diese Antwort stützt sich auf" — describes the EVIDENCE (not the documents), objective counts
* only (no fabricated trust score). When the Legal-KG ships `bindingness`, binding Rechtsgrundlagen
* are split from Leitlinien (soft-law guidance); until then it shows a neutral evidence breakdown.
*/
export function EvidenceSummary({ response }: { response: AdvisorResponse }) {
const m = summarizeEvidence(response.evidence)
const figures = response.visual_evidence.length
const notes = response.footnotes.length
const cls = 'h-4 w-4'
const smallIcon = 'h-3.5 w-3.5'
return (
<div>
<div className="mb-1.5 text-[10px] font-semibold uppercase tracking-wide text-gray-400">
Diese Antwort stützt sich auf
</div>
<div className="grid grid-cols-2 gap-1.5">
{m.hasBindingness && (
<>
<Count
icon={<Scale className={cls} />}
value={m.normProvisions}
label={plural(m.normProvisions, 'Rechtsgrundlage', 'Rechtsgrundlagen')}
/>
<Count
icon={<BookMarked className={cls} />}
value={m.guidanceCount}
label={plural(m.guidanceCount, 'Leitlinie', 'Leitlinien')}
dim={m.guidanceCount === 0}
/>
</>
)}
<Count
icon={<ImageIcon className={cls} />}
value={figures}
label={plural(figures, 'Abbildung', 'Abbildungen')}
dim={figures === 0}
/>
<Count
icon={<Hash className={cls} />}
value={notes}
label={plural(notes, 'Fußnote', 'Fußnoten')}
dim={notes === 0}
/>
<Count icon={<FileText className={cls} />} value={m.unitCount} label="Evidence Units" />
</div>
{m.groups.length > 0 && (
<div className="mt-2.5 rounded-lg border border-gray-100 bg-gray-50/60 px-2.5 py-1.5">
{m.hasBindingness ? (
<>
<Section title="Rechtsgrundlagen" groups={m.norms} icon={<Scale className={smallIcon} />} />
<Section title="Leitlinien" groups={m.guidance} icon={<BookMarked className={smallIcon} />} />
<Section title="Weitere" groups={m.other} icon={<FileText className={smallIcon} />} />
</>
) : (
m.groups.map((g) => <GroupRow key={g.key} group={g} icon={<Library className={smallIcon} />} />)
)}
</div>
)}
</div>
)
}
@@ -40,7 +40,7 @@ const ANSWER = {
clarity: { is_underspecified: false, dominant_context: 'cyber', concentration: 0.88 },
answer: 'Die Meldung erfolgt unverzüglich [1].',
evidence: [
{ evidence_id: 'e1', document: 'CRA', section: 'Art. 14', paragraph: 'Abs. 1', snippet: 'unverzüglich melden' },
{ evidence_id: 'e1', document: 'CRA', section: 'Art. 14', paragraph: 'Abs. 1', snippet: 'unverzüglich melden', bindingness: 'binding' },
],
citations: [
{ citation_id: 'c1', number: 1, evidence_id: 'e1', document: 'CRA', section: 'Art. 14', paragraph: 'Abs. 1' },
@@ -77,7 +77,10 @@ test.describe('Compliance Advisor — Clarity Gate', () => {
await expect(sdkPage.getByText(/unverzüglich/)).toBeVisible()
await expect(sdkPage.getByTitle('Beleg 1 anzeigen')).toBeVisible()
await expect(sdkPage.getByText('Cyber Resilience Act (CRA)')).toBeVisible()
// bindingness present -> header splits into Rechtsgrundlagen vs Leitlinien (evidence framing)
await expect(sdkPage.getByText('Rechtsgrundlagen').first()).toBeVisible()
// family name resolved for the user (shown both in the summary breakdown and the evidence card)
await expect(sdkPage.getByText('Cyber Resilience Act (CRA)').first()).toBeVisible()
})
test('clarify -> pick a context -> scoped answer', async ({ sdkPage }) => {
@@ -0,0 +1,85 @@
import { describe, it, expect } from 'vitest'
import {
groupByFamily,
provisionSummary,
summarizeEvidence,
type FamilyGroup,
} from '../advisor/evidence-grouping'
import type { EvidenceUnit } from '../advisor/contract'
function u(p: Partial<EvidenceUnit> & { document: string }): EvidenceUnit {
return { evidence_id: Math.random().toString(36).slice(2), ...p }
}
// The Datenschutzerklärung scenario the user reviewed: 6 Kernnormen (5 DSGVO Artikel + § 25 TDDDG)
// + 2 Leitlinien (DSK, EDPB) across 8 evidence units.
const DSE: EvidenceUnit[] = [
u({ document: 'DSGVO', section: 'Art. 6', bindingness: 'binding' }),
u({ document: 'DSGVO', section: 'Art. 7', bindingness: 'binding' }),
u({ document: 'DSGVO', section: 'Art. 12', bindingness: 'binding' }),
u({ document: 'DSGVO', section: 'Art. 13', bindingness: 'binding' }),
u({ document: 'DSGVO', section: 'Art. 14', bindingness: 'binding' }),
u({ document: 'TDDDG', section: '§ 25', bindingness: 'binding' }),
u({ document: 'DSK', bindingness: 'guidance' }),
u({ document: 'EDPB WP 259', bindingness: 'guidance' }),
]
describe('groupByFamily', () => {
it('groups a family and collects distinct provisions in order', () => {
const groups = groupByFamily(DSE)
const dsgvo = groups.find((g) => g.key === 'dsgvo')!
expect(dsgvo.units).toBe(5)
expect(dsgvo.sections).toEqual(['Art. 6', 'Art. 7', 'Art. 12', 'Art. 13', 'Art. 14'])
expect(dsgvo.bindingness).toBe('binding')
})
it('does not duplicate a repeated section', () => {
const groups = groupByFamily([
u({ document: 'DSGVO', section: 'Art. 13', bindingness: 'binding' }),
u({ document: 'DSGVO', section: 'Art. 13', bindingness: 'binding' }),
])
expect(groups[0].sections).toEqual(['Art. 13'])
expect(groups[0].units).toBe(2)
})
})
describe('summarizeEvidence', () => {
it('splits binding norms from guidance with correct counts', () => {
const m = summarizeEvidence(DSE)
expect(m.hasBindingness).toBe(true)
expect(m.normProvisions).toBe(6) // 5 DSGVO Artikel + § 25 TDDDG
expect(m.guidanceCount).toBe(2) // DSK + EDPB
expect(m.unitCount).toBe(8)
expect(m.norms.map((g) => g.key).sort()).toEqual(['dsgvo', 'tddg'])
})
it('degrades to a neutral breakdown when bindingness is absent', () => {
const m = summarizeEvidence([
u({ document: 'DSGVO', section: 'Art. 30' }),
u({ document: 'CRA', section: 'Art. 14' }),
])
expect(m.hasBindingness).toBe(false)
expect(m.groups).toHaveLength(2)
expect(m.normProvisions).toBe(0)
expect(m.guidanceCount).toBe(0)
})
})
describe('provisionSummary', () => {
const g = (sections: string[], units = sections.length): FamilyGroup => ({
key: 'k',
label: 'L',
sections,
units,
bindingness: 'binding',
})
it('names Artikel, §§, single provisions and bare units', () => {
expect(provisionSummary(g(['Art. 6', 'Art. 7', 'Art. 13']))).toBe('3 Artikel')
expect(provisionSummary(g(['§ 25']))).toBe('§ 25')
expect(provisionSummary(g(['§ 25', '§ 26']))).toBe('2 §§')
expect(provisionSummary(g(['Art. 13', '§ 25', 'Anhang I']))).toBe('3 Fundstellen')
expect(provisionSummary(g([], 3))).toBe('3 Fundstellen')
expect(provisionSummary(g([], 1))).toBe('1 Fundstelle')
})
})
@@ -25,6 +25,10 @@ export interface EvidenceUnit {
url?: string
regulation_code?: string // preferred key for family grouping (from /retrieve)
context?: string // knowledge space / domain
// Canonical Legal-KG fact (APEX rule): binding norm vs. soft-law guidance. Owned by the
// Legal-KG/RAG, not derived in the FE. Absent until /retrieve populates it (board request 2026-07-01);
// the FE degrades to a neutral per-regulation breakdown when it is missing.
bindingness?: 'binding' | 'guidance'
}
/** Numbered [n] <-> evidence coupling, produced by the SDK (not parsed from the answer). */
@@ -0,0 +1,80 @@
// Pure grouping/counting for the "Diese Antwort stützt sich auf" evidence header. No React, testable.
// Splits evidence into binding norms (Kernnormen) vs. soft-law guidance (Leitlinien) using the
// Legal-KG-owned `bindingness` fact (APEX rule) — the FE never derives bindingness itself. When the
// fact is absent it degrades to a neutral per-regulation breakdown (no norm/guidance labels, no
// fabricated legal classification).
import type { EvidenceUnit } from './contract'
import { resolveRegulation } from './regulation-display'
export type Bindingness = 'binding' | 'guidance' | 'unknown'
export interface FamilyGroup {
key: string // stable family key (grouping)
label: string // human-readable regulation name
sections: string[] // distinct provisions in first-seen order (e.g. "Art. 13", "§ 25")
units: number // raw evidence units in this family
bindingness: Bindingness
}
export interface EvidenceSummaryModel {
groups: FamilyGroup[]
norms: FamilyGroup[] // bindingness === 'binding'
guidance: FamilyGroup[] // bindingness === 'guidance'
other: FamilyGroup[] // bindingness unknown
hasBindingness: boolean // at least one unit carries the Legal-KG fact
normProvisions: number // distinct binding provisions (Kernnormen)
guidanceCount: number // distinct guidance documents (Leitlinien)
unitCount: number // total evidence units
}
export function groupByFamily(evidence: EvidenceUnit[]): FamilyGroup[] {
const byKey = new Map<string, FamilyGroup>()
for (const e of evidence) {
const { familyKey, familyLabel } = resolveRegulation({
code: e.regulation_code || e.document,
short: e.document,
})
let g = byKey.get(familyKey)
if (!g) {
g = { key: familyKey, label: familyLabel, sections: [], units: 0, bindingness: 'unknown' }
byKey.set(familyKey, g)
}
g.units += 1
if (e.section && !g.sections.includes(e.section)) g.sections.push(e.section)
if (e.bindingness && g.bindingness === 'unknown') g.bindingness = e.bindingness
}
return [...byKey.values()]
}
/** distinct provisions for a family; falls back to raw unit count when no section is known. */
export function provisionCount(g: FamilyGroup): number {
return g.sections.length || g.units
}
/** "5 Artikel" / "§ 25" / "3 Fundstellen" — the noun follows the family's own citation style. */
export function provisionSummary(g: FamilyGroup): string {
const n = g.sections.length
if (n === 0) return `${g.units} ${g.units === 1 ? 'Fundstelle' : 'Fundstellen'}`
if (n === 1) return g.sections[0]
if (g.sections.every((s) => /^\s*art/i.test(s))) return `${n} Artikel`
if (g.sections.every((s) => s.trim().startsWith('§'))) return `${n} §§`
return `${n} Fundstellen`
}
export function summarizeEvidence(evidence: EvidenceUnit[]): EvidenceSummaryModel {
const groups = groupByFamily(evidence)
const norms = groups.filter((g) => g.bindingness === 'binding')
const guidance = groups.filter((g) => g.bindingness === 'guidance')
const other = groups.filter((g) => g.bindingness === 'unknown')
return {
groups,
norms,
guidance,
other,
hasBindingness: norms.length > 0 || guidance.length > 0,
normProvisions: norms.reduce((n, g) => n + provisionCount(g), 0),
guidanceCount: guidance.length,
unitCount: evidence.length,
}
}