From a9b04e5286f4aee38abaaec918c1ba0374babc26 Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Wed, 1 Jul 2026 15:17:21 +0200 Subject: [PATCH] feat(advisor): evidence-framed header + bindingness contract seam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../api/sdk/compliance-advisor/chat/route.ts | 18 ++- .../sdk/advisor/EvidenceSummary.tsx | 124 ++++++++++++++---- .../e2e/specs/compliance-advisor.spec.ts | 7 +- .../advisor-evidence-grouping.test.ts | 85 ++++++++++++ admin-compliance/lib/sdk/advisor/contract.ts | 4 + .../lib/sdk/advisor/evidence-grouping.ts | 80 +++++++++++ 6 files changed, 290 insertions(+), 28 deletions(-) create mode 100644 admin-compliance/lib/sdk/__tests__/advisor-evidence-grouping.test.ts create mode 100644 admin-compliance/lib/sdk/advisor/evidence-grouping.ts diff --git a/admin-compliance/app/api/sdk/compliance-advisor/chat/route.ts b/admin-compliance/app/api/sdk/compliance-advisor/chat/route.ts index 0bea8949..4cd8647e 100644 --- a/admin-compliance/app/api/sdk/compliance-advisor/chat/route.ts +++ b/admin-compliance/app/api/sdk/compliance-advisor/chat/route.ts @@ -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** (1–2 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) diff --git a/admin-compliance/components/sdk/advisor/EvidenceSummary.tsx b/admin-compliance/components/sdk/advisor/EvidenceSummary.tsx index b4e313e2..af1f1869 100644 --- a/admin-compliance/components/sdk/advisor/EvidenceSummary.tsx +++ b/admin-compliance/components/sdk/advisor/EvidenceSummary.tsx @@ -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 ( -
-
- Antwort basiert auf -
-
- } value={families} label="Regelwerke" /> - } value={response.evidence.length} label="Evidence Units" /> - } value={response.visual_evidence.length} label="Diagramme" dim={response.visual_evidence.length === 0} /> - } value={response.footnotes.length} label="Fußnoten" dim={response.footnotes.length === 0} /> -
+
+ {icon} + {group.label} + {detail && {detail}} +
+ ) +} + +function Section({ + title, + groups, + icon, +}: { + title: string + groups: FamilyGroup[] + icon: React.ReactNode +}) { + if (groups.length === 0) return null + return ( +
+
{title}
+ {groups.map((g) => ( + + ))} +
+ ) +} + +/** + * "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 ( +
+
+ Diese Antwort stützt sich auf +
+ +
+ {m.hasBindingness && ( + <> + } + value={m.normProvisions} + label={plural(m.normProvisions, 'Rechtsgrundlage', 'Rechtsgrundlagen')} + /> + } + value={m.guidanceCount} + label={plural(m.guidanceCount, 'Leitlinie', 'Leitlinien')} + dim={m.guidanceCount === 0} + /> + + )} + } + value={figures} + label={plural(figures, 'Abbildung', 'Abbildungen')} + dim={figures === 0} + /> + } + value={notes} + label={plural(notes, 'Fußnote', 'Fußnoten')} + dim={notes === 0} + /> + } value={m.unitCount} label="Evidence Units" /> +
+ + {m.groups.length > 0 && ( +
+ {m.hasBindingness ? ( + <> +
} /> +
} /> +
} /> + + ) : ( + m.groups.map((g) => } />) + )} +
+ )}
) } diff --git a/admin-compliance/e2e/specs/compliance-advisor.spec.ts b/admin-compliance/e2e/specs/compliance-advisor.spec.ts index e176794a..5bf1cca7 100644 --- a/admin-compliance/e2e/specs/compliance-advisor.spec.ts +++ b/admin-compliance/e2e/specs/compliance-advisor.spec.ts @@ -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 }) => { diff --git a/admin-compliance/lib/sdk/__tests__/advisor-evidence-grouping.test.ts b/admin-compliance/lib/sdk/__tests__/advisor-evidence-grouping.test.ts new file mode 100644 index 00000000..6bbc5b37 --- /dev/null +++ b/admin-compliance/lib/sdk/__tests__/advisor-evidence-grouping.test.ts @@ -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 & { 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') + }) +}) diff --git a/admin-compliance/lib/sdk/advisor/contract.ts b/admin-compliance/lib/sdk/advisor/contract.ts index 47e4f70a..38da6304 100644 --- a/admin-compliance/lib/sdk/advisor/contract.ts +++ b/admin-compliance/lib/sdk/advisor/contract.ts @@ -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). */ diff --git a/admin-compliance/lib/sdk/advisor/evidence-grouping.ts b/admin-compliance/lib/sdk/advisor/evidence-grouping.ts new file mode 100644 index 00000000..9f783b5e --- /dev/null +++ b/admin-compliance/lib/sdk/advisor/evidence-grouping.ts @@ -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() + 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, + } +}