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:
@@ -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** (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) {
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user