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 16eb284a..dd1afe7d 100644 --- a/admin-compliance/app/api/sdk/compliance-advisor/chat/route.ts +++ b/admin-compliance/app/api/sdk/compliance-advisor/chat/route.ts @@ -36,6 +36,9 @@ Datenschutz- und Compliance-Fragen in verstaendlicher Sprache zu beantworten. const FORMAT_GUIDANCE = `\n\n## Antwortformat (WICHTIG) - Schreibe gut strukturiertes **Markdown**: kurze Abschnittsueberschriften (##), Aufzaehlungen (-), nummerierte Schritte und **Fettung** fuer Schluesselbegriffe. Halte Absaetze kurz. +- GLIEDERE erklaerende Antworten aktiv statt langem Fliesstext: eine eigene ## Ueberschrift je + Aspekt (z.B. "Definition", "Ablauf/Phasen", "Rechtsbezug", "Praktische Bedeutung"), nummerierte + Schritte fuer Ablaeufe/Phasen, Bullet-Points fuer Aufzaehlungen. Lieber klar gegliedert als ein Block. - Nenne Fundstellen/Quellen NICHT im Fliesstext (kein "(Art. 30 DSGVO)", keine "[Quelle 1]"). Die Quellen werden dem Nutzer in einem EIGENEN Bereich neben der Antwort angezeigt. - Beende die Antwort NIEMALS mit einer Quellen-/Fundstellen-Liste (kein "Quellen:", kein diff --git a/admin-compliance/components/sdk/ComplianceAdvisorWidget.tsx b/admin-compliance/components/sdk/ComplianceAdvisorWidget.tsx index 1bd84cc2..5f09e85e 100644 --- a/admin-compliance/components/sdk/ComplianceAdvisorWidget.tsx +++ b/admin-compliance/components/sdk/ComplianceAdvisorWidget.tsx @@ -60,7 +60,7 @@ export function ComplianceAdvisorWidget({ currentStep = 'default' }: ComplianceA return (
{/* Header */} @@ -122,7 +122,12 @@ export function ComplianceAdvisorWidget({ currentStep = 'default' }: ComplianceA
{/* Evidence Workspace */} - + {/* Input */}
diff --git a/admin-compliance/components/sdk/advisor/EvidencePane.tsx b/admin-compliance/components/sdk/advisor/EvidencePane.tsx new file mode 100644 index 00000000..3f97cdc4 --- /dev/null +++ b/admin-compliance/components/sdk/advisor/EvidencePane.tsx @@ -0,0 +1,74 @@ +'use client' + +import { useState } from 'react' +import { ChevronDown, ChevronRight, Library } from 'lucide-react' +import type { KnowledgeUnit } from '@/lib/sdk/advisor/evidence' +import { resolveRegulation } from '@/lib/sdk/advisor/regulation-display' +import { KnowledgeUnitCard } from './KnowledgeUnitCard' +import { PaneHeader } from './PaneHeader' + +interface EvidenceGroupData { + key: string + label: string + units: KnowledgeUnit[] +} + +function groupByFamily(sources: KnowledgeUnit[]): EvidenceGroupData[] { + const map = new Map() + for (const u of sources) { + const d = resolveRegulation(u.regulation) + const g = map.get(d.familyKey) ?? { key: d.familyKey, label: d.familyLabel, units: [] } + g.units.push(u) + map.set(d.familyKey, g) + } + return [...map.values()].sort((a, b) => b.units.length - a.units.length) +} + +function EvidenceGroup({ group }: { group: EvidenceGroupData }) { + const [open, setOpen] = useState(group.units.length <= 3) + return ( +
+ + {open && ( +
+ {group.units.map((u) => ( + + ))} +
+ )} +
+ ) +} + +/** Evidence pane — retrieved units grouped by document/regulation family, count + expandable. */ +export function EvidencePane({ sources }: { sources: KnowledgeUnit[] }) { + const groups = groupByFamily(sources) + return ( +
+ } + title="Evidence" + count={sources.length} + /> + {groups.length === 0 ? ( +

Keine strukturierte Evidence zu dieser Antwort.

+ ) : ( +
+ {groups.map((g) => ( + + ))} +
+ )} +
+ ) +} diff --git a/admin-compliance/components/sdk/advisor/EvidenceSummary.tsx b/admin-compliance/components/sdk/advisor/EvidenceSummary.tsx new file mode 100644 index 00000000..072d27bc --- /dev/null +++ b/admin-compliance/components/sdk/advisor/EvidenceSummary.tsx @@ -0,0 +1,54 @@ +'use client' + +import { FileText, Hash, Image as ImageIcon, Library } from 'lucide-react' +import type { AdvisorEvidenceMeta } from '@/lib/sdk/advisor/evidence' +import { resolveRegulation } from '@/lib/sdk/advisor/regulation-display' + +function Card({ + icon, + value, + label, + dim, +}: { + icon: React.ReactNode + value: number + label: string + dim?: boolean +}) { + return ( +
+ {icon} + + {value}{' '} + {label} + +
+ ) +} + +/** + * "Antwort basiert auf" — honest, meaningful counts (not bare badges). Regelwerke = distinct + * document FAMILIES (via resolveRegulation), so multi-part works like the DSK SDM count once. + * No fabricated trust score — a real trust signal needs a defined basis (bindingness/coverage). + */ +export function EvidenceSummary({ meta }: { meta: AdvisorEvidenceMeta }) { + const families = new Set(meta.sources.map((s) => resolveRegulation(s.regulation).familyKey)).size + const cls = 'h-4 w-4' + return ( +
+
+ Antwort basiert auf +
+
+ } value={families} label="Regelwerke" /> + } value={meta.sources.length} label="Evidence Units" /> + } value={meta.figures.length} label="Abbildungen" dim={meta.figures.length === 0} /> + } value={meta.footnotes.length} label="Fußnoten" dim={meta.footnotes.length === 0} /> +
+
+ ) +} diff --git a/admin-compliance/components/sdk/advisor/EvidenceWorkspace.tsx b/admin-compliance/components/sdk/advisor/EvidenceWorkspace.tsx index 71d7eef3..bdb38867 100644 --- a/admin-compliance/components/sdk/advisor/EvidenceWorkspace.tsx +++ b/admin-compliance/components/sdk/advisor/EvidenceWorkspace.tsx @@ -1,34 +1,49 @@ 'use client' -import { useEffect, useRef } from 'react' +import { useEffect, useRef, useState } from 'react' import type { AdvisorTurn } from './useAdvisorStream' import { StickyQuestion } from './StickyQuestion' import { TurnView } from './TurnView' +import { EvidenceSummary } from './EvidenceSummary' +import { AnswerPane } from './AnswerPane' +import { EvidencePane } from './EvidencePane' +import { FiguresPane } from './FiguresPane' +import { FootnotesPane } from './FootnotesPane' import { AdvisorEmptyState } from './EmptyState' /** - * The Evidence Workspace body: a pinned "last question" + a scrollable history of turns, each - * showing the answer alongside its sources / figures / footnotes. Scroll up to revisit a past - * answer with its full evidence. + * The Evidence Workspace body. + * - Narrow (collapsed): stacked panels with a pinned last question + scrollable turn history. + * - Wide (expanded): a 3-column Compliance Case Workspace — question + summary (left, with a + * history switcher), answer (center scroll), evidence (right scroll) — each column scrolls + * independently so the user never loses the question or the evidence. */ export function EvidenceWorkspace({ turns, + expanded, exampleQuestions, onExample, }: { turns: AdvisorTurn[] + expanded: boolean exampleQuestions: string[] onExample: (q: string) => void }) { + const [activeId, setActiveId] = useState(null) const endRef = useRef(null) const latest = turns[turns.length - 1] + const active = turns.find((t) => t.id === activeId) ?? latest - // Scroll to the newest turn when a question is added (not on every streamed token, - // so the user can scroll up to review history while the answer streams). + // A new turn refocuses the latest (null = follow latest). useEffect(() => { - endRef.current?.scrollIntoView({ behavior: 'smooth' }) + setActiveId(null) }, [turns.length]) + // Autoscroll the stacked view to the newest turn (narrow mode only). + useEffect(() => { + if (!expanded) endRef.current?.scrollIntoView({ behavior: 'smooth' }) + }, [turns.length, expanded]) + if (turns.length === 0) { return (
@@ -37,15 +52,66 @@ export function EvidenceWorkspace({ ) } - return ( -
- {latest && } -
- {turns.map((t, i) => ( - - ))} -
+ if (!expanded) { + return ( +
+ {latest && } +
+ {turns.map((t, i) => ( + + ))} +
+
+ ) + } + + return ( +
+ {/* Left rail: question + summary + history */} + + + {/* Center: answer */} +
+ {active && ( + + )} +
+ + {/* Right: evidence */} +
) } diff --git a/admin-compliance/components/sdk/advisor/KnowledgeUnitCard.test.tsx b/admin-compliance/components/sdk/advisor/KnowledgeUnitCard.test.tsx index b0ab152e..3c3ff43f 100644 --- a/admin-compliance/components/sdk/advisor/KnowledgeUnitCard.test.tsx +++ b/admin-compliance/components/sdk/advisor/KnowledgeUnitCard.test.tsx @@ -3,26 +3,29 @@ import { render } from '@testing-library/react' import { KnowledgeUnitCard } from './KnowledgeUnitCard' import type { KnowledgeUnit } from '@/lib/sdk/advisor/evidence' -const base: KnowledgeUnit = { id: 's1', regulation: { code: 'dsk', short: 'DSK Sdm B51' } } - describe('KnowledgeUnitCard', () => { - it('does not duplicate the regulation when label equals the short name', () => { - const { container } = render() - const occurrences = (container.textContent?.match(/DSK Sdm B51/g) || []).length - expect(occurrences).toBe(1) + it('shows the friendly regulation name (not the raw code) when standalone', () => { + const unit: KnowledgeUnit = { id: 's1', regulation: { code: 'cra', short: 'CRA' } } + const { container } = render() + expect(container.textContent).toContain('Cyber Resilience Act (CRA)') }) - it('shows the label when it differs from the short name (no breadcrumb)', () => { - const { container } = render() - expect(container.textContent).toContain('DSK Sdm B51') - expect(container.textContent).toContain('Art. 30 DSGVO') - }) - - it('renders the section/paragraph breadcrumb when present', () => { - const { container } = render( - , - ) + it('renders the section/paragraph breadcrumb', () => { + const unit: KnowledgeUnit = { + id: 's2', + regulation: { code: 'dsgvo', short: 'DSGVO' }, + section: 'Art. 5', + paragraph: 'Abs. 2', + } + const { container } = render() expect(container.textContent).toContain('Art. 5') expect(container.textContent).toContain('Abs. 2') }) + + it('compact mode shows the chapter and omits the family name (group provides it)', () => { + const unit: KnowledgeUnit = { id: 's3', regulation: { code: 'dsk_sdm_b51', short: 'DSK Sdm B51' } } + const { container } = render() + expect(container.textContent).toContain('Kapitel B51') + expect(container.textContent).not.toContain('Standard-Datenschutzmodell') + }) }) diff --git a/admin-compliance/components/sdk/advisor/KnowledgeUnitCard.tsx b/admin-compliance/components/sdk/advisor/KnowledgeUnitCard.tsx index a43369be..11e49172 100644 --- a/admin-compliance/components/sdk/advisor/KnowledgeUnitCard.tsx +++ b/admin-compliance/components/sdk/advisor/KnowledgeUnitCard.tsx @@ -3,26 +3,49 @@ import { useState } from 'react' import { ChevronDown, ChevronRight, ExternalLink } from 'lucide-react' import type { KnowledgeUnit } from '@/lib/sdk/advisor/evidence' +import { resolveRegulation } from '@/lib/sdk/advisor/regulation-display' /** - * A source rendered as a hierarchical Knowledge Unit (Regelwerk → Section → Paragraph → Footnote), - * not a text-list line. [öffnen] resolves to the original source when available; the optional - * snippet lets the user peek the cited text. + * A single evidence unit. Standalone: friendly regulation name + hierarchy. Compact (inside a + * document group): chapter/section only (the group already names the regulation). [öffnen] opens + * the original source; the optional snippet lets the user peek the cited text. */ -export function KnowledgeUnitCard({ unit }: { unit: KnowledgeUnit }) { +export function KnowledgeUnitCard({ unit, compact }: { unit: KnowledgeUnit; compact?: boolean }) { const [open, setOpen] = useState(false) - const crumbs = [unit.section, unit.subsection, unit.paragraph, unit.footnoteRef].filter(Boolean) + const d = resolveRegulation(unit.regulation) + const crumbs = [unit.section, unit.subsection, unit.paragraph, unit.footnoteRef].filter( + (x): x is string => Boolean(x), + ) const href = unit.open?.originalUrl - const canOpen = href && /^https?:\/\//i.test(href) + const canOpen = !!href && /^https?:\/\//i.test(href) + + let header: string + let sub: string[] + if (!compact) { + header = d.familyLabel + sub = crumbs + } else if (d.chapter) { + header = `Kapitel ${d.chapter}` + sub = crumbs + } else { + header = crumbs[0] || unit.label || d.familyLabel + sub = crumbs.slice(1) + } return ( -
+
-
{unit.regulation.short}
- {crumbs.length > 0 ? ( +
{header}
+ {sub.length > 0 ? (
- {crumbs.map((c, i) => ( + {sub.map((c, i) => ( {i > 0 && } {c} @@ -30,8 +53,9 @@ export function KnowledgeUnitCard({ unit }: { unit: KnowledgeUnit }) { ))}
) : ( + !compact && unit.label && - unit.label !== unit.regulation.short && ( + unit.label !== header && (
{unit.label}
) )} diff --git a/admin-compliance/components/sdk/advisor/SourcesPane.tsx b/admin-compliance/components/sdk/advisor/SourcesPane.tsx deleted file mode 100644 index 3ba823b4..00000000 --- a/admin-compliance/components/sdk/advisor/SourcesPane.tsx +++ /dev/null @@ -1,24 +0,0 @@ -'use client' - -import { Library } from 'lucide-react' -import type { KnowledgeUnit } from '@/lib/sdk/advisor/evidence' -import { KnowledgeUnitCard } from './KnowledgeUnitCard' -import { PaneHeader } from './PaneHeader' - -/** Sources pane — the answer's evidence as hierarchical Knowledge Units, separate from the prose. */ -export function SourcesPane({ sources }: { sources: KnowledgeUnit[] }) { - return ( -
- } title="Quellen" count={sources.length} /> - {sources.length === 0 ? ( -

Keine strukturierten Quellen zu dieser Antwort.

- ) : ( -
- {sources.map((s) => ( - - ))} -
- )} -
- ) -} diff --git a/admin-compliance/components/sdk/advisor/StatsBar.tsx b/admin-compliance/components/sdk/advisor/StatsBar.tsx deleted file mode 100644 index 02d72fc3..00000000 --- a/admin-compliance/components/sdk/advisor/StatsBar.tsx +++ /dev/null @@ -1,52 +0,0 @@ -'use client' - -import { FileText, Library, Image as ImageIcon, Hash } from 'lucide-react' -import type { AdvisorStats } from '@/lib/sdk/advisor/evidence' - -function Chip({ - icon, - label, - value, - dim, -}: { - icon: React.ReactNode - label: string - value: number - dim?: boolean -}) { - return ( -
- {icon} - {value} - {label} -
- ) -} - -/** Compact evidence summary: "Diese Antwort basiert auf N Quellen / M Regelwerken ...". */ -export function StatsBar({ stats }: { stats: AdvisorStats }) { - const cls = 'h-3 w-3' - return ( -
- } label="Quellen" value={stats.sources} /> - } label="Regelwerke" value={stats.regulations} /> - } - label="Diagramme" - value={stats.figures} - dim={stats.figures === 0} - /> - } - label="Fußnoten" - value={stats.footnotes} - dim={stats.footnotes === 0} - /> -
- ) -} diff --git a/admin-compliance/components/sdk/advisor/TurnView.tsx b/admin-compliance/components/sdk/advisor/TurnView.tsx index 4095e3a8..a959b89b 100644 --- a/admin-compliance/components/sdk/advisor/TurnView.tsx +++ b/admin-compliance/components/sdk/advisor/TurnView.tsx @@ -1,13 +1,13 @@ 'use client' import type { AdvisorTurn } from './useAdvisorStream' -import { StatsBar } from './StatsBar' +import { EvidenceSummary } from './EvidenceSummary' import { AnswerPane } from './AnswerPane' -import { SourcesPane } from './SourcesPane' +import { EvidencePane } from './EvidencePane' import { FiguresPane } from './FiguresPane' import { FootnotesPane } from './FootnotesPane' -/** One question/answer turn rendered as stacked evidence panels. */ +/** One question/answer turn as stacked panels (collapsed / narrow layout). */ export function TurnView({ turn, showQuestion }: { turn: AdvisorTurn; showQuestion?: boolean }) { const streaming = turn.status === 'streaming' return ( @@ -17,9 +17,9 @@ export function TurnView({ turn, showQuestion }: { turn: AdvisorTurn; showQuesti Frage: {turn.question}
)} - + - +
diff --git a/admin-compliance/lib/sdk/__tests__/advisor-regulation-display.test.ts b/admin-compliance/lib/sdk/__tests__/advisor-regulation-display.test.ts new file mode 100644 index 00000000..208eb3cc --- /dev/null +++ b/admin-compliance/lib/sdk/__tests__/advisor-regulation-display.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from 'vitest' +import { resolveRegulation } from '../advisor/regulation-display' + +describe('resolveRegulation', () => { + it('groups DSK SDM building blocks under one family + extracts the chapter', () => { + const b51 = resolveRegulation({ code: 'dsk_sdm_b51', short: 'DSK Sdm B51' }) + const b41 = resolveRegulation({ code: 'dsk_sdm_b41', short: 'DSK Sdm B41' }) + const v31 = resolveRegulation({ code: 'dsk_sdm_v31', short: 'DSK Sdm V31' }) + expect(b51.familyKey).toBe('dsk_sdm') + expect(b41.familyKey).toBe('dsk_sdm') + expect(v31.familyKey).toBe('dsk_sdm') + expect(b51.familyLabel).toContain('Standard-Datenschutzmodell') + expect(b51.chapter).toBe('B51') + expect(v31.chapter).toBe('V31') + }) + + it('maps known regulations to friendly family keys', () => { + expect(resolveRegulation({ code: 'cra', short: 'CRA' }).familyKey).toBe('cra') + expect(resolveRegulation({ code: 'nis2', short: 'NIS2' }).familyKey).toBe('nis2') + expect(resolveRegulation({ code: 'dpf', short: 'DPF' }).familyKey).toBe('dpf') + expect(resolveRegulation({ code: 'dsgvo', short: 'DS-GVO' }).familyKey).toBe('dsgvo') + expect(resolveRegulation({ code: 'bdsg', short: 'BDSG' }).familyKey).toBe('bdsg') + }) + + it('falls back to code as family + short as label for unknown regulations', () => { + const r = resolveRegulation({ code: 'xyz_reg', short: 'XYZ' }) + expect(r.familyKey).toBe('xyz_reg') + expect(r.familyLabel).toBe('XYZ') + expect(r.chapter).toBeUndefined() + }) +}) diff --git a/admin-compliance/lib/sdk/advisor/regulation-display.ts b/admin-compliance/lib/sdk/advisor/regulation-display.ts new file mode 100644 index 00000000..2a125981 --- /dev/null +++ b/admin-compliance/lib/sdk/advisor/regulation-display.ts @@ -0,0 +1,53 @@ +// Human-readable display for regulations. Maps messy codes/short-names to a stable FAMILY key + +// friendly label (+ chapter for multi-part works like the DSK SDM). Presentation layer only: +// it bridges G2 (clean RAG metadata) and keeps working once codes are clean. Extend the table freely. + +import type { RegulationRef } from './evidence' + +export interface RegulationDisplay { + familyKey: string // stable key used to GROUP evidence + familyLabel: string // human-readable regulation name + chapter?: string // e.g. "B51" for a DSK SDM building block +} + +interface Rule { + test: RegExp + key: string + label: string + chapter?: RegExp +} + +// Order matters: more specific patterns first. +const RULES: Rule[] = [ + { + test: /dsk.?sdm|standard.?datenschutzmodell|(^|[^a-z])sdm([^a-z]|$)/i, + key: 'dsk_sdm', + label: 'DSK Standard-Datenschutzmodell (SDM)', + chapter: /\b([A-Z]\d{1,3})\b/, + }, + { test: /cyber.?resilience|(^|[^a-z])cra([^a-z]|$)/i, key: 'cra', label: 'Cyber Resilience Act (CRA)' }, + { test: /(^|[^a-z])nis.?2([^a-z]|$)/i, key: 'nis2', label: 'NIS2-Richtlinie' }, + { test: /data.?privacy.?framework|(^|[^a-z])dpf([^a-z]|$)/i, key: 'dpf', label: 'EU-US Data Privacy Framework' }, + { test: /maschinen|2023.?1230/i, key: 'maschinenvo', label: 'Maschinenverordnung (EU) 2023/1230' }, + { test: /ds.?gvo|gdpr/i, key: 'dsgvo', label: 'DSGVO – Datenschutz-Grundverordnung' }, + { test: /(^|[^a-z])bdsg([^a-z]|$)/i, key: 'bdsg', label: 'BDSG – Bundesdatenschutzgesetz' }, + { test: /tdddg|ttdsg/i, key: 'tddg', label: 'TDDDG (Digitale-Dienste-Datenschutz)' }, + { test: /edpb|edsa|(^|[^a-z])wp\s?\d+/i, key: 'edpb', label: 'EDPB / DSK Leitlinien' }, + { test: /(^|[^a-z])bsi([^a-z]|$)/i, key: 'bsi', label: 'BSI' }, +] + +export function resolveRegulation(reg: RegulationRef): RegulationDisplay { + const hay = `${reg.code || ''} ${reg.short || ''} ${reg.name || ''}` + for (const r of RULES) { + if (r.test.test(hay)) { + const chapter = r.chapter + ? r.chapter.exec(reg.short || reg.code || '')?.[1] || undefined + : undefined + return { familyKey: r.key, familyLabel: r.label, chapter } + } + } + return { + familyKey: reg.code || reg.short || 'unknown', + familyLabel: reg.short || reg.name || reg.code || 'Regelwerk', + } +}