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.` 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( function answerSystem(
soul: string | null, soul: string | null,
country: Country | undefined, country: Country | undefined,
evidenceBlock: string, evidenceBlock: string,
withCitations = true, withCitations = true,
audience = '',
): string { ): string {
let s = soul || FALLBACK_SYSTEM let s = soul || FALLBACK_SYSTEM
if (country) s += countryBlock(country) 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## Belegte Evidence (nummeriert — DEINE EINZIGEN Quellen)\n${evidenceBlock || '(keine Evidence gefunden)'}`
s += `\n\n## Antwortformat (WICHTIG) 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) { if (withCitations) {
s += `\n- Belege Kernaussagen mit [n], wobei n die NUMMER der Evidence-Quelle oben ist (z. B. [1], [2]). 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.` - 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 body = await request.json()
const question = String(body.question ?? body.message ?? '').trim() const question = String(body.question ?? body.message ?? '').trim()
const context: string | null = body.context ?? null 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) const country = (['DE', 'AT', 'CH', 'EU'] as const).includes(body.country)
? (body.country as Country) ? (body.country as Country)
: undefined : undefined
@@ -87,7 +97,7 @@ export async function POST(request: NextRequest) {
const legacyEvidence = retrieved.evidence ?? [] const legacyEvidence = retrieved.evidence ?? []
const legacySoul = await readSoulFile('compliance-advisor') const legacySoul = await readSoulFile('compliance-advisor')
const legacyStream = await streamAdvisorAnswer([ 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 }, { role: 'user', content: question },
]) ])
if (!legacyStream) { if (!legacyStream) {
@@ -106,7 +116,7 @@ export async function POST(request: NextRequest) {
if (mode === 'clarify') { if (mode === 'clarify') {
const general = await completeAdvisorAnswer([ const general = await completeAdvisorAnswer([
{ role: 'system', content: L1_SYSTEM }, { role: 'system', content: L1_SYSTEM + audienceBlock(audience) },
{ role: 'user', content: question }, { role: 'user', content: question },
]) ])
if (general === null) { if (general === null) {
@@ -130,7 +140,7 @@ export async function POST(request: NextRequest) {
const evidence = retrieved.evidence ?? [] const evidence = retrieved.evidence ?? []
const soul = await readSoulFile('compliance-advisor') const soul = await readSoulFile('compliance-advisor')
const messages: ChatMessage[] = [ 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 }, { role: 'user', content: question },
] ]
const answer = await completeAdvisorAnswer(messages) const answer = await completeAdvisorAnswer(messages)
@@ -1,10 +1,16 @@
'use client' '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 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, icon,
value, value,
label, label,
@@ -30,26 +36,100 @@ function Card({
) )
} }
/** function GroupRow({ group, icon }: { group: FamilyGroup; icon: React.ReactNode }) {
* "Antwort basiert auf" — objective counts only (no fabricated trust score). Regelwerke = distinct // A single-unit guidance doc needs no "1 Fundstelle" noise; norms always show their provisions.
* document families. Leitlinien deliberately omitted until bindingness exists in the Legal-KG. const detail = group.sections.length === 0 && group.units <= 1 ? '' : provisionSummary(group)
*/
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'
return ( return (
<div> <div className="flex items-start gap-2 py-0.5">
<div className="mb-1.5 text-[10px] font-semibold uppercase tracking-wide text-gray-400"> <span className="mt-0.5 shrink-0 text-gray-400">{icon}</span>
Antwort basiert auf <span className="min-w-0 flex-1 text-[12px] leading-snug text-gray-700">{group.label}</span>
</div> {detail && <span className="shrink-0 text-[11px] font-medium text-gray-500">{detail}</span>}
<div className="grid grid-cols-2 gap-1.5"> </div>
<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} /> function Section({
</div> 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> </div>
) )
} }
@@ -40,7 +40,7 @@ const ANSWER = {
clarity: { is_underspecified: false, dominant_context: 'cyber', concentration: 0.88 }, clarity: { is_underspecified: false, dominant_context: 'cyber', concentration: 0.88 },
answer: 'Die Meldung erfolgt unverzüglich [1].', answer: 'Die Meldung erfolgt unverzüglich [1].',
evidence: [ 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: [ citations: [
{ citation_id: 'c1', number: 1, evidence_id: 'e1', document: 'CRA', section: 'Art. 14', paragraph: 'Abs. 1' }, { 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.getByText(/unverzüglich/)).toBeVisible()
await expect(sdkPage.getByTitle('Beleg 1 anzeigen')).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 }) => { 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 url?: string
regulation_code?: string // preferred key for family grouping (from /retrieve) regulation_code?: string // preferred key for family grouping (from /retrieve)
context?: string // knowledge space / domain 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). */ /** 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,
}
}