feat(advisor): Case Workspace v2 — Evidence grouping, human names, 3-column, summary
Reworks the advisor toward a Compliance Case Workspace (review feedback): - Rename user-facing "Quellen" -> "Evidence". - Evidence grouped by document/regulation family (count + expandable) — no more unsorted DSK/DSK/DPF/... jumble. - Human-readable regulation names via a display registry (DSK Sdm B51 -> "DSK Standard-Datenschutzmodell (SDM)" / Kapitel B51); generic, bridges G2. - Evidence summary "Antwort basiert auf" with meaningful counts; Regelwerke = distinct FAMILIES (fixes the inflated count). NO fabricated trust score (needs a defined basis). - Expanded mode = 3-column workspace (question+summary | answer | evidence, independent scroll) + history switcher; narrow mode stays stacked. - Prompt: push aggressive markdown structure (## per aspect, numbered phases). Deferred/coordinated on board: C8 diagrams (RAG contract), answer<->evidence coupling [1] (needs LLM citation anchors — phase 2), G1 retrieval relevance + G2 metadata (RAG). tsc clean, 17 vitest, check-loc 0. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -36,6 +36,9 @@ Datenschutz- und Compliance-Fragen in verstaendlicher Sprache zu beantworten.
|
|||||||
const FORMAT_GUIDANCE = `\n\n## Antwortformat (WICHTIG)
|
const FORMAT_GUIDANCE = `\n\n## Antwortformat (WICHTIG)
|
||||||
- Schreibe gut strukturiertes **Markdown**: kurze Abschnittsueberschriften (##), Aufzaehlungen (-),
|
- Schreibe gut strukturiertes **Markdown**: kurze Abschnittsueberschriften (##), Aufzaehlungen (-),
|
||||||
nummerierte Schritte und **Fettung** fuer Schluesselbegriffe. Halte Absaetze kurz.
|
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]").
|
- 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.
|
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
|
- Beende die Antwort NIEMALS mit einer Quellen-/Fundstellen-Liste (kein "Quellen:", kein
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export function ComplianceAdvisorWidget({ currentStep = 'default' }: ComplianceA
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`fixed bottom-6 right-6 z-50 flex max-h-screen flex-col rounded-2xl border border-gray-200 bg-white shadow-2xl transition-all duration-200 ${
|
className={`fixed bottom-6 right-6 z-50 flex max-h-screen flex-col rounded-2xl border border-gray-200 bg-white shadow-2xl transition-all duration-200 ${
|
||||||
isExpanded ? 'h-[85vh] w-[760px]' : 'h-[560px] w-[420px]'
|
isExpanded ? 'h-[85vh] w-[960px]' : 'h-[560px] w-[420px]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -122,7 +122,12 @@ export function ComplianceAdvisorWidget({ currentStep = 'default' }: ComplianceA
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Evidence Workspace */}
|
{/* Evidence Workspace */}
|
||||||
<EvidenceWorkspace turns={turns} exampleQuestions={exampleQuestions} onExample={submit} />
|
<EvidenceWorkspace
|
||||||
|
turns={turns}
|
||||||
|
expanded={isExpanded}
|
||||||
|
exampleQuestions={exampleQuestions}
|
||||||
|
onExample={submit}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Input */}
|
{/* Input */}
|
||||||
<div className="rounded-b-2xl border-t border-gray-200 bg-white p-3">
|
<div className="rounded-b-2xl border-t border-gray-200 bg-white p-3">
|
||||||
|
|||||||
@@ -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<string, EvidenceGroupData>()
|
||||||
|
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 (
|
||||||
|
<div className="rounded-lg border border-gray-200 bg-white">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
className="flex w-full items-center justify-between gap-2 px-2.5 py-2 text-left"
|
||||||
|
>
|
||||||
|
<span className="min-w-0 truncate text-xs font-semibold text-gray-900">{group.label}</span>
|
||||||
|
<span className="flex flex-shrink-0 items-center gap-1 text-[11px] text-gray-500">
|
||||||
|
{group.units.length} Treffer
|
||||||
|
{open ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="space-y-1 border-t border-gray-100 px-2 py-2">
|
||||||
|
{group.units.map((u) => (
|
||||||
|
<KnowledgeUnitCard key={u.id} unit={u} compact />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Evidence pane — retrieved units grouped by document/regulation family, count + expandable. */
|
||||||
|
export function EvidencePane({ sources }: { sources: KnowledgeUnit[] }) {
|
||||||
|
const groups = groupByFamily(sources)
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<PaneHeader
|
||||||
|
icon={<Library className="h-3.5 w-3.5 text-gray-500" />}
|
||||||
|
title="Evidence"
|
||||||
|
count={sources.length}
|
||||||
|
/>
|
||||||
|
{groups.length === 0 ? (
|
||||||
|
<p className="px-1 text-[11px] text-gray-400">Keine strukturierte Evidence zu dieser Antwort.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{groups.map((g) => (
|
||||||
|
<EvidenceGroup key={g.key} group={g} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-2 rounded-lg border px-2.5 py-1.5 ${
|
||||||
|
dim ? 'border-gray-100 bg-gray-50' : 'border-gray-200 bg-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className={dim ? 'text-gray-300' : 'text-indigo-500'}>{icon}</span>
|
||||||
|
<span>
|
||||||
|
<span className={`text-sm font-bold ${dim ? 'text-gray-400' : 'text-gray-900'}`}>{value}</span>{' '}
|
||||||
|
<span className="text-[11px] text-gray-500">{label}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "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 (
|
||||||
|
<div>
|
||||||
|
<div className="mb-1.5 text-[10px] font-semibold uppercase tracking-wide text-gray-400">
|
||||||
|
Antwort basiert auf
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-1.5">
|
||||||
|
<Card icon={<Library className={cls} />} value={families} label="Regelwerke" />
|
||||||
|
<Card icon={<FileText className={cls} />} value={meta.sources.length} label="Evidence Units" />
|
||||||
|
<Card icon={<ImageIcon className={cls} />} value={meta.figures.length} label="Abbildungen" dim={meta.figures.length === 0} />
|
||||||
|
<Card icon={<Hash className={cls} />} value={meta.footnotes.length} label="Fußnoten" dim={meta.footnotes.length === 0} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,34 +1,49 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import type { AdvisorTurn } from './useAdvisorStream'
|
import type { AdvisorTurn } from './useAdvisorStream'
|
||||||
import { StickyQuestion } from './StickyQuestion'
|
import { StickyQuestion } from './StickyQuestion'
|
||||||
import { TurnView } from './TurnView'
|
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'
|
import { AdvisorEmptyState } from './EmptyState'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The Evidence Workspace body: a pinned "last question" + a scrollable history of turns, each
|
* The Evidence Workspace body.
|
||||||
* showing the answer alongside its sources / figures / footnotes. Scroll up to revisit a past
|
* - Narrow (collapsed): stacked panels with a pinned last question + scrollable turn history.
|
||||||
* answer with its full evidence.
|
* - 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({
|
export function EvidenceWorkspace({
|
||||||
turns,
|
turns,
|
||||||
|
expanded,
|
||||||
exampleQuestions,
|
exampleQuestions,
|
||||||
onExample,
|
onExample,
|
||||||
}: {
|
}: {
|
||||||
turns: AdvisorTurn[]
|
turns: AdvisorTurn[]
|
||||||
|
expanded: boolean
|
||||||
exampleQuestions: string[]
|
exampleQuestions: string[]
|
||||||
onExample: (q: string) => void
|
onExample: (q: string) => void
|
||||||
}) {
|
}) {
|
||||||
|
const [activeId, setActiveId] = useState<string | null>(null)
|
||||||
const endRef = useRef<HTMLDivElement>(null)
|
const endRef = useRef<HTMLDivElement>(null)
|
||||||
const latest = turns[turns.length - 1]
|
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,
|
// A new turn refocuses the latest (null = follow latest).
|
||||||
// so the user can scroll up to review history while the answer streams).
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
endRef.current?.scrollIntoView({ behavior: 'smooth' })
|
setActiveId(null)
|
||||||
}, [turns.length])
|
}, [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) {
|
if (turns.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 overflow-y-auto bg-gray-50">
|
<div className="flex-1 overflow-y-auto bg-gray-50">
|
||||||
@@ -37,15 +52,66 @@ export function EvidenceWorkspace({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
if (!expanded) {
|
||||||
<div className="flex-1 overflow-y-auto bg-gray-50">
|
return (
|
||||||
{latest && <StickyQuestion question={latest.question} />}
|
<div className="min-h-0 flex-1 overflow-y-auto bg-gray-50">
|
||||||
<div className="space-y-4 p-4">
|
{latest && <StickyQuestion question={latest.question} />}
|
||||||
{turns.map((t, i) => (
|
<div className="space-y-4 p-4">
|
||||||
<TurnView key={t.id} turn={t} showQuestion={i !== turns.length - 1} />
|
{turns.map((t, i) => (
|
||||||
))}
|
<TurnView key={t.id} turn={t} showQuestion={i !== turns.length - 1} />
|
||||||
<div ref={endRef} />
|
))}
|
||||||
|
<div ref={endRef} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid min-h-0 flex-1 grid-cols-[220px_1fr_320px] divide-x divide-gray-200 overflow-hidden">
|
||||||
|
{/* Left rail: question + summary + history */}
|
||||||
|
<aside className="min-h-0 overflow-y-auto bg-indigo-50/40 p-3">
|
||||||
|
<div className="text-[10px] font-semibold uppercase tracking-wide text-indigo-400">Frage</div>
|
||||||
|
<div className="mb-3 text-sm font-medium text-gray-800">{active?.question}</div>
|
||||||
|
{active && <EvidenceSummary meta={active.meta} />}
|
||||||
|
{turns.length > 1 && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<div className="mb-1 text-[10px] font-semibold uppercase tracking-wide text-gray-400">
|
||||||
|
Verlauf
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{turns.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
onClick={() => setActiveId(t.id)}
|
||||||
|
className={`block w-full truncate rounded px-2 py-1 text-left text-[11px] ${
|
||||||
|
t.id === active?.id ? 'bg-indigo-100 text-indigo-800' : 'text-gray-600 hover:bg-gray-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t.question}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Center: answer */}
|
||||||
|
<main className="min-h-0 overflow-y-auto bg-gray-50 p-4">
|
||||||
|
{active && (
|
||||||
|
<AnswerPane answer={active.answer} streaming={active.status === 'streaming'} error={active.error} />
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* Right: evidence */}
|
||||||
|
<aside className="min-h-0 space-y-3 overflow-y-auto bg-gray-50 p-3">
|
||||||
|
{active && (
|
||||||
|
<>
|
||||||
|
<EvidencePane sources={active.meta.sources} />
|
||||||
|
<FiguresPane figures={active.meta.figures} />
|
||||||
|
<FootnotesPane footnotes={active.meta.footnotes} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,26 +3,29 @@ import { render } from '@testing-library/react'
|
|||||||
import { KnowledgeUnitCard } from './KnowledgeUnitCard'
|
import { KnowledgeUnitCard } from './KnowledgeUnitCard'
|
||||||
import type { KnowledgeUnit } from '@/lib/sdk/advisor/evidence'
|
import type { KnowledgeUnit } from '@/lib/sdk/advisor/evidence'
|
||||||
|
|
||||||
const base: KnowledgeUnit = { id: 's1', regulation: { code: 'dsk', short: 'DSK Sdm B51' } }
|
|
||||||
|
|
||||||
describe('KnowledgeUnitCard', () => {
|
describe('KnowledgeUnitCard', () => {
|
||||||
it('does not duplicate the regulation when label equals the short name', () => {
|
it('shows the friendly regulation name (not the raw code) when standalone', () => {
|
||||||
const { container } = render(<KnowledgeUnitCard unit={{ ...base, label: 'DSK Sdm B51' }} />)
|
const unit: KnowledgeUnit = { id: 's1', regulation: { code: 'cra', short: 'CRA' } }
|
||||||
const occurrences = (container.textContent?.match(/DSK Sdm B51/g) || []).length
|
const { container } = render(<KnowledgeUnitCard unit={unit} />)
|
||||||
expect(occurrences).toBe(1)
|
expect(container.textContent).toContain('Cyber Resilience Act (CRA)')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows the label when it differs from the short name (no breadcrumb)', () => {
|
it('renders the section/paragraph breadcrumb', () => {
|
||||||
const { container } = render(<KnowledgeUnitCard unit={{ ...base, label: 'Art. 30 DSGVO' }} />)
|
const unit: KnowledgeUnit = {
|
||||||
expect(container.textContent).toContain('DSK Sdm B51')
|
id: 's2',
|
||||||
expect(container.textContent).toContain('Art. 30 DSGVO')
|
regulation: { code: 'dsgvo', short: 'DSGVO' },
|
||||||
})
|
section: 'Art. 5',
|
||||||
|
paragraph: 'Abs. 2',
|
||||||
it('renders the section/paragraph breadcrumb when present', () => {
|
}
|
||||||
const { container } = render(
|
const { container } = render(<KnowledgeUnitCard unit={unit} />)
|
||||||
<KnowledgeUnitCard unit={{ ...base, section: 'Art. 5', paragraph: 'Abs. 2' }} />,
|
|
||||||
)
|
|
||||||
expect(container.textContent).toContain('Art. 5')
|
expect(container.textContent).toContain('Art. 5')
|
||||||
expect(container.textContent).toContain('Abs. 2')
|
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(<KnowledgeUnitCard unit={unit} compact />)
|
||||||
|
expect(container.textContent).toContain('Kapitel B51')
|
||||||
|
expect(container.textContent).not.toContain('Standard-Datenschutzmodell')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,26 +3,49 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { ChevronDown, ChevronRight, ExternalLink } from 'lucide-react'
|
import { ChevronDown, ChevronRight, ExternalLink } from 'lucide-react'
|
||||||
import type { KnowledgeUnit } from '@/lib/sdk/advisor/evidence'
|
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),
|
* A single evidence unit. Standalone: friendly regulation name + hierarchy. Compact (inside a
|
||||||
* not a text-list line. [öffnen] resolves to the original source when available; the optional
|
* document group): chapter/section only (the group already names the regulation). [öffnen] opens
|
||||||
* snippet lets the user peek the cited text.
|
* 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 [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 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 (
|
return (
|
||||||
<div className="rounded-lg border border-gray-200 bg-white p-2.5">
|
<div
|
||||||
|
className={
|
||||||
|
compact
|
||||||
|
? 'rounded-md border border-gray-100 bg-gray-50 p-2'
|
||||||
|
: 'rounded-lg border border-gray-200 bg-white p-2.5'
|
||||||
|
}
|
||||||
|
>
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="truncate text-xs font-semibold text-gray-900">{unit.regulation.short}</div>
|
<div className="truncate text-xs font-semibold text-gray-900">{header}</div>
|
||||||
{crumbs.length > 0 ? (
|
{sub.length > 0 ? (
|
||||||
<div className="mt-0.5 flex flex-wrap items-center gap-x-1 text-[11px] text-gray-500">
|
<div className="mt-0.5 flex flex-wrap items-center gap-x-1 text-[11px] text-gray-500">
|
||||||
{crumbs.map((c, i) => (
|
{sub.map((c, i) => (
|
||||||
<span key={i} className="flex items-center gap-1">
|
<span key={i} className="flex items-center gap-1">
|
||||||
{i > 0 && <span className="text-gray-300">›</span>}
|
{i > 0 && <span className="text-gray-300">›</span>}
|
||||||
{c}
|
{c}
|
||||||
@@ -30,8 +53,9 @@ export function KnowledgeUnitCard({ unit }: { unit: KnowledgeUnit }) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
!compact &&
|
||||||
unit.label &&
|
unit.label &&
|
||||||
unit.label !== unit.regulation.short && (
|
unit.label !== header && (
|
||||||
<div className="mt-0.5 text-[11px] text-gray-500">{unit.label}</div>
|
<div className="mt-0.5 text-[11px] text-gray-500">{unit.label}</div>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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 (
|
|
||||||
<section>
|
|
||||||
<PaneHeader icon={<Library className="h-3.5 w-3.5 text-gray-500" />} title="Quellen" count={sources.length} />
|
|
||||||
{sources.length === 0 ? (
|
|
||||||
<p className="px-1 text-[11px] text-gray-400">Keine strukturierten Quellen zu dieser Antwort.</p>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
{sources.map((s) => (
|
|
||||||
<KnowledgeUnitCard key={s.id} unit={s} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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 (
|
|
||||||
<div
|
|
||||||
className={`flex items-center gap-1 rounded-md border px-1.5 py-0.5 text-[11px] ${
|
|
||||||
dim ? 'border-gray-100 bg-gray-50 text-gray-400' : 'border-gray-200 bg-white text-gray-600'
|
|
||||||
}`}
|
|
||||||
title={`${label}: ${value}`}
|
|
||||||
>
|
|
||||||
{icon}
|
|
||||||
<span className="font-semibold text-gray-900">{value}</span>
|
|
||||||
<span>{label}</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Compact evidence summary: "Diese Antwort basiert auf N Quellen / M Regelwerken ...". */
|
|
||||||
export function StatsBar({ stats }: { stats: AdvisorStats }) {
|
|
||||||
const cls = 'h-3 w-3'
|
|
||||||
return (
|
|
||||||
<div className="flex flex-wrap items-center gap-1.5">
|
|
||||||
<Chip icon={<FileText className={cls} />} label="Quellen" value={stats.sources} />
|
|
||||||
<Chip icon={<Library className={cls} />} label="Regelwerke" value={stats.regulations} />
|
|
||||||
<Chip
|
|
||||||
icon={<ImageIcon className={cls} />}
|
|
||||||
label="Diagramme"
|
|
||||||
value={stats.figures}
|
|
||||||
dim={stats.figures === 0}
|
|
||||||
/>
|
|
||||||
<Chip
|
|
||||||
icon={<Hash className={cls} />}
|
|
||||||
label="Fußnoten"
|
|
||||||
value={stats.footnotes}
|
|
||||||
dim={stats.footnotes === 0}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import type { AdvisorTurn } from './useAdvisorStream'
|
import type { AdvisorTurn } from './useAdvisorStream'
|
||||||
import { StatsBar } from './StatsBar'
|
import { EvidenceSummary } from './EvidenceSummary'
|
||||||
import { AnswerPane } from './AnswerPane'
|
import { AnswerPane } from './AnswerPane'
|
||||||
import { SourcesPane } from './SourcesPane'
|
import { EvidencePane } from './EvidencePane'
|
||||||
import { FiguresPane } from './FiguresPane'
|
import { FiguresPane } from './FiguresPane'
|
||||||
import { FootnotesPane } from './FootnotesPane'
|
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 }) {
|
export function TurnView({ turn, showQuestion }: { turn: AdvisorTurn; showQuestion?: boolean }) {
|
||||||
const streaming = turn.status === 'streaming'
|
const streaming = turn.status === 'streaming'
|
||||||
return (
|
return (
|
||||||
@@ -17,9 +17,9 @@ export function TurnView({ turn, showQuestion }: { turn: AdvisorTurn; showQuesti
|
|||||||
<span className="font-medium text-gray-400">Frage:</span> {turn.question}
|
<span className="font-medium text-gray-400">Frage:</span> {turn.question}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<StatsBar stats={turn.meta.stats} />
|
<EvidenceSummary meta={turn.meta} />
|
||||||
<AnswerPane answer={turn.answer} streaming={streaming} error={turn.error} />
|
<AnswerPane answer={turn.answer} streaming={streaming} error={turn.error} />
|
||||||
<SourcesPane sources={turn.meta.sources} />
|
<EvidencePane sources={turn.meta.sources} />
|
||||||
<FiguresPane figures={turn.meta.figures} />
|
<FiguresPane figures={turn.meta.figures} />
|
||||||
<FootnotesPane footnotes={turn.meta.footnotes} />
|
<FootnotesPane footnotes={turn.meta.footnotes} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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',
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user