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,
+ }
+}