diff --git a/admin-compliance/components/sdk/ComplianceAdvisorWidget.tsx b/admin-compliance/components/sdk/ComplianceAdvisorWidget.tsx index 5f09e85e..90b41e8c 100644 --- a/admin-compliance/components/sdk/ComplianceAdvisorWidget.tsx +++ b/admin-compliance/components/sdk/ComplianceAdvisorWidget.tsx @@ -4,7 +4,7 @@ import { useCallback, useState } from 'react' import { Check, Loader2, Mail, Maximize2, MessagesSquare, Minimize2, Send, Square, X } from 'lucide-react' import { EXAMPLE_QUESTIONS } from './advisor/EmptyState' import { EvidenceWorkspace } from './advisor/EvidenceWorkspace' -import { useAdvisorStream } from './advisor/useAdvisorStream' +import { useAdvisorCase } from './advisor/useAdvisorCase' import { useAdvisorEmail } from './advisor/useAdvisorEmail' interface ComplianceAdvisorWidgetProps { @@ -15,9 +15,9 @@ type Country = 'DE' | 'AT' | 'CH' | 'EU' const COUNTRIES: Country[] = ['DE', 'AT', 'CH', 'EU'] /** - * Compliance Advisor — Evidence Workspace as a floating widget on every SDK page. - * Renders ONLY structured evidence from the SDK (answer + sources + figures + footnotes); - * it never parses the answer text. See memory: advisor-evidence-workspace-no-parse. + * Compliance Advisor — a floating Case Workspace on every SDK page. + * Renders ONLY structured SDK data (clarify/answer contract); it never parses the answer text. + * See memory: advisor-evidence-workspace-no-parse, advisor-clarity-gate-contract. */ export function ComplianceAdvisorWidget({ currentStep = 'default' }: ComplianceAdvisorWidgetProps) { const [isOpen, setIsOpen] = useState(false) @@ -25,17 +25,17 @@ export function ComplianceAdvisorWidget({ currentStep = 'default' }: ComplianceA const [inputValue, setInputValue] = useState('') const [country, setCountry] = useState('DE') - const { turns, isStreaming, send, stop } = useAdvisorStream({ currentStep, country }) - const email = useAdvisorEmail(turns, country, currentStep) + const { cases, busy, ask, selectContext, stop } = useAdvisorCase({ currentStep, country }) + const email = useAdvisorEmail(cases, country, currentStep) const exampleQuestions = EXAMPLE_QUESTIONS[currentStep] || EXAMPLE_QUESTIONS.default const submit = useCallback( (q: string) => { - if (!q.trim() || isStreaming) return + if (!q.trim() || busy) return setInputValue('') - void send(q) + ask(q) }, - [isStreaming, send], + [busy, ask], ) const onKeyDown = (e: React.KeyboardEvent) => { @@ -63,7 +63,6 @@ export function ComplianceAdvisorWidget({ currentStep = 'default' }: ComplianceA isExpanded ? 'h-[85vh] w-[960px]' : 'h-[560px] w-[420px]' }`} > - {/* Header */}
@@ -87,7 +86,7 @@ export function ComplianceAdvisorWidget({ currentStep = 'default' }: ComplianceA
- {turns.length > 0 && ( + {cases.length > 0 && ( )}
- {/* Evidence Workspace */} - {/* Input */}
setInputValue(e.target.value)} onKeyDown={onKeyDown} placeholder="Frage eingeben..." - disabled={isStreaming} + disabled={busy} className="flex-1 rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-transparent focus:outline-none focus:ring-2 focus:ring-purple-500 disabled:opacity-50" /> - {isStreaming ? ( - ) : ( diff --git a/admin-compliance/components/sdk/advisor/AnswerPane.tsx b/admin-compliance/components/sdk/advisor/AnswerPane.tsx deleted file mode 100644 index 8eb8fa9c..00000000 --- a/admin-compliance/components/sdk/advisor/AnswerPane.tsx +++ /dev/null @@ -1,37 +0,0 @@ -'use client' - -import { Markdown } from './Markdown' - -/** The answer panel — rendered markdown (clean prose, no inline citations). */ -export function AnswerPane({ - answer, - streaming, - error, -}: { - answer: string - streaming?: boolean - error?: string -}) { - if (error) { - return ( -
- {error} -
- ) - } - if (!answer && streaming) { - return ( -
- - - -
- ) - } - return ( -
- - {streaming && } -
- ) -} diff --git a/admin-compliance/components/sdk/advisor/CaseView.test.tsx b/admin-compliance/components/sdk/advisor/CaseView.test.tsx new file mode 100644 index 00000000..acc4f8ed --- /dev/null +++ b/admin-compliance/components/sdk/advisor/CaseView.test.tsx @@ -0,0 +1,68 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, fireEvent } from '@testing-library/react' +import { CaseView } from './CaseView' +import type { AdvisorCase } from './useAdvisorCase' +import type { AdvisorResponse } from '@/lib/sdk/advisor/contract' + +const clarify: AdvisorResponse = { + mode: 'clarify', + question: 'Was ist PDCA?', + clarity: { + is_underspecified: true, + concentration: 0.38, + suggested_contexts: [ + { id: 'datenschutz', label: 'Datenschutz' }, + { id: 'qm', label: 'Qualitätsmanagement' }, + ], + }, + general_answer: 'PDCA steht für **Plan-Do-Check-Act**.', + answer: null, + evidence: [], + citations: [], + visual_evidence: [], + footnotes: [], +} + +const answer: AdvisorResponse = { + mode: 'answer', + question: 'PDCA im Datenschutz?', + clarity: { is_underspecified: false, dominant_context: 'datenschutz', concentration: 0.88 }, + answer: 'Der DSM-Zyklus [1] beschreibt den Ablauf.', + evidence: [ + { evidence_id: 'e1', document: 'DSK Sdm B41', section: 'Art. 5', paragraph: 'Abs. 2', snippet: 'x' }, + ], + citations: [ + { citation_id: 'c1', evidence_id: 'e1', document: 'DSK Sdm B41', section: 'Art. 5', paragraph: 'Abs. 2' }, + ], + visual_evidence: [ + { visual_id: 'v1', visual_type: 'flowchart', caption: 'PDCA-Zyklus', document: 'DSK SDM', vision_summary: 's' }, + ], + footnotes: [], +} + +function mk(response: AdvisorResponse): AdvisorCase { + return { id: 'case1', question: response.question, response, selectedContext: null, status: 'done' } +} + +describe('CaseView — clarify mode', () => { + it('renders the L1 general answer + context chips and fires onSelectContext', () => { + const onSel = vi.fn() + const { container, getByText } = render( + , + ) + expect(container.textContent).toContain('Plan-Do-Check-Act') + expect(container.textContent).toContain('Allgemeine Definition') + fireEvent.click(getByText('Datenschutz')) + expect(onSel).toHaveBeenCalledWith('datenschutz') + }) +}) + +describe('CaseView — answer mode', () => { + it('renders answer with a clickable [n] citation, grouped evidence (friendly name), and visual', () => { + const { container } = render( {}} />) + expect(container.textContent).toContain('DSM-Zyklus') + expect(container.querySelector('button[title="Beleg 1 anzeigen"]')).not.toBeNull() + expect(container.textContent).toContain('DSK Standard-Datenschutzmodell') + expect(container.textContent).toContain('PDCA-Zyklus') + }) +}) diff --git a/admin-compliance/components/sdk/advisor/CaseView.tsx b/admin-compliance/components/sdk/advisor/CaseView.tsx new file mode 100644 index 00000000..4c7c0d8f --- /dev/null +++ b/admin-compliance/components/sdk/advisor/CaseView.tsx @@ -0,0 +1,75 @@ +'use client' + +import type { AdvisorResponse } from '@/lib/sdk/advisor/contract' +import type { AdvisorCase } from './useAdvisorCase' +import { ClarifyView } from './ClarifyView' +import { EvidenceSummary } from './EvidenceSummary' +import { EvidencePane } from './EvidencePane' +import { VisualEvidencePane } from './VisualEvidencePane' +import { FootnotesPane } from './FootnotesPane' +import { Markdown } from './Markdown' +import { useCitationHighlight } from './useCitationHighlight' + +export function LoadingDots() { + return ( +
+ + + +
+ ) +} + +export function ErrorBox({ msg }: { msg?: string }) { + return ( +
+ {msg || 'Verbindung fehlgeschlagen'} +
+ ) +} + +/** Answer mode body (stacked): summary + answer (with [n] coupling) + evidence/visual/footnotes. */ +export function AnswerBody({ response }: { response: AdvisorResponse }) { + const { highlightedId, cite } = useCitationHighlight(response.citations) + return ( +
+ +
+ +
+ + + +
+ ) +} + +/** One case rendered stacked (narrow mode). Clarify -> L1 + chips; answer -> full evidence body. */ +export function CaseView({ + c, + onSelectContext, + busy, + showQuestion, +}: { + c: AdvisorCase + onSelectContext: (ctx: string) => void + busy: boolean + showQuestion?: boolean +}) { + const r = c.response + return ( +
+ {showQuestion && ( +
+ Frage: {c.question} +
+ )} + {c.status === 'loading' && } + {c.status === 'error' && } + {r && r.mode === 'clarify' && ( + + )} + {r && r.mode === 'answer' && } +
+ ) +} diff --git a/admin-compliance/components/sdk/advisor/ClarifyView.tsx b/admin-compliance/components/sdk/advisor/ClarifyView.tsx new file mode 100644 index 00000000..7d27a215 --- /dev/null +++ b/admin-compliance/components/sdk/advisor/ClarifyView.tsx @@ -0,0 +1,52 @@ +'use client' + +import { Info } from 'lucide-react' +import type { AdvisorResponse } from '@/lib/sdk/advisor/contract' +import { Markdown } from './Markdown' + +/** + * Clarify mode: a short general (L1) definition — explicitly marked as general, no legal source — + * plus domain context chips. Picking a chip re-runs the case scoped to that domain (-> L2). + */ +export function ClarifyView({ + response, + onSelectContext, + busy, +}: { + response: AdvisorResponse + onSelectContext: (id: string) => void + busy: boolean +}) { + const chips = response.clarity.suggested_contexts ?? [] + return ( +
+
+
+ + Allgemeine Definition (ohne Rechtsquelle) +
+ +
+ {chips.length > 0 && ( +
+
+ Meintest du einen bestimmten Kontext? +
+
+ {chips.map((c) => ( + + ))} +
+
+ )} +
+ ) +} diff --git a/admin-compliance/components/sdk/advisor/EvidencePane.tsx b/admin-compliance/components/sdk/advisor/EvidencePane.tsx index 3f97cdc4..871a71bb 100644 --- a/admin-compliance/components/sdk/advisor/EvidencePane.tsx +++ b/admin-compliance/components/sdk/advisor/EvidencePane.tsx @@ -2,21 +2,21 @@ import { useState } from 'react' import { ChevronDown, ChevronRight, Library } from 'lucide-react' -import type { KnowledgeUnit } from '@/lib/sdk/advisor/evidence' +import type { EvidenceUnit } from '@/lib/sdk/advisor/contract' import { resolveRegulation } from '@/lib/sdk/advisor/regulation-display' -import { KnowledgeUnitCard } from './KnowledgeUnitCard' +import { EvidenceUnitCard } from './EvidenceUnitCard' import { PaneHeader } from './PaneHeader' -interface EvidenceGroupData { +interface Group { key: string label: string - units: KnowledgeUnit[] + units: EvidenceUnit[] } -function groupByFamily(sources: KnowledgeUnit[]): EvidenceGroupData[] { - const map = new Map() - for (const u of sources) { - const d = resolveRegulation(u.regulation) +function groupByFamily(units: EvidenceUnit[]): Group[] { + const map = new Map() + for (const u of units) { + const d = resolveRegulation({ code: u.document, short: u.document }) const g = map.get(d.familyKey) ?? { key: d.familyKey, label: d.familyLabel, units: [] } g.units.push(u) map.set(d.familyKey, g) @@ -24,7 +24,7 @@ function groupByFamily(sources: KnowledgeUnit[]): EvidenceGroupData[] { return [...map.values()].sort((a, b) => b.units.length - a.units.length) } -function EvidenceGroup({ group }: { group: EvidenceGroupData }) { +function EvidenceGroup({ group, highlightedId }: { group: Group; highlightedId?: string }) { const [open, setOpen] = useState(group.units.length <= 3) return (
@@ -42,7 +42,7 @@ function EvidenceGroup({ group }: { group: EvidenceGroupData }) { {open && (
{group.units.map((u) => ( - + ))}
)} @@ -50,22 +50,24 @@ function EvidenceGroup({ group }: { group: EvidenceGroupData }) { ) } -/** Evidence pane — retrieved units grouped by document/regulation family, count + expandable. */ -export function EvidencePane({ sources }: { sources: KnowledgeUnit[] }) { - const groups = groupByFamily(sources) +/** Evidence pane — units grouped by document/regulation family, count + expandable. */ +export function EvidencePane({ + evidence, + highlightedId, +}: { + evidence: EvidenceUnit[] + highlightedId?: string +}) { + const groups = groupByFamily(evidence) return (
- } - title="Evidence" - count={sources.length} - /> + } title="Evidence" count={evidence.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 index 072d27bc..b4e313e2 100644 --- a/admin-compliance/components/sdk/advisor/EvidenceSummary.tsx +++ b/admin-compliance/components/sdk/advisor/EvidenceSummary.tsx @@ -1,7 +1,7 @@ 'use client' import { FileText, Hash, Image as ImageIcon, Library } from 'lucide-react' -import type { AdvisorEvidenceMeta } from '@/lib/sdk/advisor/evidence' +import type { AdvisorResponse } from '@/lib/sdk/advisor/contract' import { resolveRegulation } from '@/lib/sdk/advisor/regulation-display' function Card({ @@ -31,12 +31,13 @@ function Card({ } /** - * "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). + * "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({ meta }: { meta: AdvisorEvidenceMeta }) { - const families = new Set(meta.sources.map((s) => resolveRegulation(s.regulation).familyKey)).size +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 (
@@ -45,9 +46,9 @@ export function EvidenceSummary({ meta }: { meta: AdvisorEvidenceMeta }) {
} 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} /> + } 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} />
) diff --git a/admin-compliance/components/sdk/advisor/KnowledgeUnitCard.tsx b/admin-compliance/components/sdk/advisor/EvidenceUnitCard.tsx similarity index 60% rename from admin-compliance/components/sdk/advisor/KnowledgeUnitCard.tsx rename to admin-compliance/components/sdk/advisor/EvidenceUnitCard.tsx index 11e49172..f89a2bbd 100644 --- a/admin-compliance/components/sdk/advisor/KnowledgeUnitCard.tsx +++ b/admin-compliance/components/sdk/advisor/EvidenceUnitCard.tsx @@ -2,48 +2,40 @@ import { useState } from 'react' import { ChevronDown, ChevronRight, ExternalLink } from 'lucide-react' -import type { KnowledgeUnit } from '@/lib/sdk/advisor/evidence' +import type { EvidenceUnit } from '@/lib/sdk/advisor/contract' import { resolveRegulation } from '@/lib/sdk/advisor/regulation-display' -/** - * 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, compact }: { unit: KnowledgeUnit; compact?: boolean }) { +/** One evidence unit (contract shape). Compact inside a document group: chapter/section only. */ +export function EvidenceUnitCard({ + unit, + compact, + highlighted, +}: { + unit: EvidenceUnit + compact?: boolean + highlighted?: boolean +}) { const [open, setOpen] = useState(false) - 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 d = resolveRegulation({ code: unit.document, short: unit.document }) + const crumbs = [unit.section, unit.paragraph].filter((x): x is string => Boolean(x)) + const canOpen = !!unit.url && /^https?:\/\//i.test(unit.url) - 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) - } + const header = compact ? (d.chapter ? `Kapitel ${d.chapter}` : crumbs[0] || d.familyLabel) : d.familyLabel + const sub = compact && !d.chapter && crumbs.length ? crumbs.slice(1) : crumbs return (
{header}
- {sub.length > 0 ? ( + {sub.length > 0 && (
{sub.map((c, i) => ( @@ -52,17 +44,11 @@ export function KnowledgeUnitCard({ unit, compact }: { unit: KnowledgeUnit; comp ))}
- ) : ( - !compact && - unit.label && - unit.label !== header && ( -
{unit.label}
- ) )}
{canOpen && ( )}
- {unit.snippet && (
))}
@@ -95,21 +103,32 @@ export function EvidenceWorkspace({ )} - {/* Center: answer */}
- {active && ( - + {active?.status === 'loading' && } + {active?.status === 'error' && } + {r?.mode === 'clarify' && ( + active && onSelectContext(active.id, ctx)} + /> + )} + {r?.mode === 'answer' && ( +
+ +
)}
- {/* Right: evidence */}
diff --git a/admin-compliance/components/sdk/advisor/FiguresPane.tsx b/admin-compliance/components/sdk/advisor/FiguresPane.tsx deleted file mode 100644 index 2ea88a68..00000000 --- a/admin-compliance/components/sdk/advisor/FiguresPane.tsx +++ /dev/null @@ -1,71 +0,0 @@ -'use client' - -import { Image as ImageIcon, ExternalLink } from 'lucide-react' -import type { FigureUnit } from '@/lib/sdk/advisor/evidence' -import { PaneHeader } from './PaneHeader' - -function FigureCard({ fig }: { fig: FigureUnit }) { - const canOpen = !!fig.imageUrl && /^https?:\/\//i.test(fig.imageUrl) - return ( -
- -
- Quelle: {fig.source.short} - {fig.section ? ` · ${fig.section}` : ''} -
- {canOpen ? ( - - {/* eslint-disable-next-line @next/next/no-img-element */} - {fig.caption - - ) : ( -
- Original-Abbildung folgt -
- )} - {fig.visionSummary && ( -

{fig.visionSummary}

- )} -
- ) -} - -/** Figures pane (C8) — original document figures, rendered only when present. */ -export function FiguresPane({ figures }: { figures: FigureUnit[] }) { - if (figures.length === 0) return null - return ( -
- } - title="Abbildungen & Diagramme" - count={figures.length} - /> -
- {figures.map((f) => ( - - ))} -
-
- ) -} diff --git a/admin-compliance/components/sdk/advisor/FootnotesPane.tsx b/admin-compliance/components/sdk/advisor/FootnotesPane.tsx index 71992436..48bde062 100644 --- a/admin-compliance/components/sdk/advisor/FootnotesPane.tsx +++ b/admin-compliance/components/sdk/advisor/FootnotesPane.tsx @@ -1,24 +1,26 @@ 'use client' import { Hash } from 'lucide-react' -import type { FootnoteUnit } from '@/lib/sdk/advisor/evidence' +import type { Footnote } from '@/lib/sdk/advisor/contract' import { PaneHeader } from './PaneHeader' /** Footnotes pane (C-FN) — rendered only when present. */ -export function FootnotesPane({ footnotes }: { footnotes: FootnoteUnit[] }) { +export function FootnotesPane({ footnotes }: { footnotes: Footnote[] }) { if (footnotes.length === 0) return null return (
} title="Fußnoten" count={footnotes.length} />
- {footnotes.map((fn) => ( -
- {fn.ref} - - {' · '} - {fn.source.short} - {fn.section ? ` / ${fn.section}` : ''} - + {footnotes.map((fn, i) => ( +
+ {fn.ref || `Fußnote ${i + 1}`} + {(fn.document || fn.section) && ( + + {' · '} + {fn.document} + {fn.section ? ` / ${fn.section}` : ''} + + )} {fn.text &&

{fn.text}

}
))} diff --git a/admin-compliance/components/sdk/advisor/KnowledgeUnitCard.test.tsx b/admin-compliance/components/sdk/advisor/KnowledgeUnitCard.test.tsx deleted file mode 100644 index 3c3ff43f..00000000 --- a/admin-compliance/components/sdk/advisor/KnowledgeUnitCard.test.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { render } from '@testing-library/react' -import { KnowledgeUnitCard } from './KnowledgeUnitCard' -import type { KnowledgeUnit } from '@/lib/sdk/advisor/evidence' - -describe('KnowledgeUnitCard', () => { - 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('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/Markdown.tsx b/admin-compliance/components/sdk/advisor/Markdown.tsx index deb3315f..a1bdd2df 100644 --- a/admin-compliance/components/sdk/advisor/Markdown.tsx +++ b/admin-compliance/components/sdk/advisor/Markdown.tsx @@ -2,11 +2,17 @@ // Minimal, SAFE markdown -> React renderer. No dangerouslySetInnerHTML, no dependency. // Covers the subset LLMs emit: headings, bold, italic, inline code, fenced code, ul/ol, links. -// (The Evidence Workspace renders citations in a separate pane, so links are rarely needed.) +// Plus deliberate [n] citation markers (mapped via `citations`, NOT parsed for structure). -const INLINE_RE = /(`[^`]+`|\*\*[^*]+\*\*|\*[^*\s][^*]*\*|_[^_]+_|\[[^\]]+\]\([^)]+\))/g +export interface CiteHandler { + count: number + onSelect: (n: number) => void +} -function renderInline(text: string, kp: string): React.ReactNode[] { +const INLINE_RE = + /(`[^`]+`|\*\*[^*]+\*\*|\*[^*\s][^*]*\*|_[^_]+_|\[[^\]]+\]\([^)]+\)|\[\d+\])/g + +function renderInline(text: string, kp: string, cite?: CiteHandler): React.ReactNode[] { const nodes: React.ReactNode[] = [] let last = 0 let idx = 0 @@ -30,6 +36,23 @@ function renderInline(text: string, kp: string): React.ReactNode[] { ) } else if (tok.startsWith('*') || tok.startsWith('_')) { nodes.push({tok.slice(1, -1)}) + } else if (/^\[\d+\]$/.test(tok)) { + const n = parseInt(tok.slice(1, -1), 10) + if (cite && n >= 1 && n <= cite.count) { + nodes.push( + , + ) + } else { + nodes.push(tok) + } } else { const mm = /^\[([^\]]+)\]\(([^)]+)\)$/.exec(tok) if (mm && /^https?:\/\//i.test(mm[2])) { @@ -54,8 +77,8 @@ function renderInline(text: string, kp: string): React.ReactNode[] { return nodes } -function Heading({ level, kp, text }: { level: number; kp: string; text: string }) { - const children = renderInline(text, kp) +function Heading({ level, kp, text, cite }: { level: number; kp: string; text: string; cite?: CiteHandler }) { + const children = renderInline(text, kp, cite) if (level <= 1) return

{children}

if (level === 2) return

{children}

return
{children}
@@ -65,13 +88,13 @@ const UL_RE = /^\s*[-*]\s+/ const OL_RE = /^\s*\d+\.\s+/ const H_RE = /^(#{1,6})\s+(.*)$/ -export function Markdown({ content }: { content: string }) { +export function Markdown({ content, citations }: { content: string; citations?: CiteHandler }) { const lines = (content || '').replace(/\r\n/g, '\n').split('\n') const blocks: React.ReactNode[] = [] let i = 0 while (i < lines.length) { const line = lines[i] - const key = `b${blocks.length}` // unique per pushed block (blocks.length is the next index) + const key = `b${blocks.length}` if (line.trim().startsWith('```')) { const buf: string[] = [] @@ -97,7 +120,7 @@ export function Markdown({ content }: { content: string }) { } const h = H_RE.exec(line) if (h) { - blocks.push() + blocks.push() i++ continue } @@ -110,7 +133,7 @@ export function Markdown({ content }: { content: string }) { blocks.push(
    {items.map((it, k) => ( -
  • {renderInline(it, `${key}-${k}`)}
  • +
  • {renderInline(it, `${key}-${k}`, citations)}
  • ))}
, ) @@ -125,7 +148,7 @@ export function Markdown({ content }: { content: string }) { blocks.push(
    {items.map((it, k) => ( -
  1. {renderInline(it, `${key}-${k}`)}
  2. +
  3. {renderInline(it, `${key}-${k}`, citations)}
  4. ))}
, ) @@ -145,7 +168,7 @@ export function Markdown({ content }: { content: string }) { } blocks.push(

- {renderInline(para.join(' '), key)} + {renderInline(para.join(' '), key, citations)}

, ) } diff --git a/admin-compliance/components/sdk/advisor/TurnView.tsx b/admin-compliance/components/sdk/advisor/TurnView.tsx deleted file mode 100644 index a959b89b..00000000 --- a/admin-compliance/components/sdk/advisor/TurnView.tsx +++ /dev/null @@ -1,27 +0,0 @@ -'use client' - -import type { AdvisorTurn } from './useAdvisorStream' -import { EvidenceSummary } from './EvidenceSummary' -import { AnswerPane } from './AnswerPane' -import { EvidencePane } from './EvidencePane' -import { FiguresPane } from './FiguresPane' -import { FootnotesPane } from './FootnotesPane' - -/** 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 ( -
- {showQuestion && ( -
- Frage: {turn.question} -
- )} - - - - - -
- ) -} diff --git a/admin-compliance/components/sdk/advisor/VisualEvidencePane.tsx b/admin-compliance/components/sdk/advisor/VisualEvidencePane.tsx new file mode 100644 index 00000000..a9dd6987 --- /dev/null +++ b/admin-compliance/components/sdk/advisor/VisualEvidencePane.tsx @@ -0,0 +1,70 @@ +'use client' + +import { ExternalLink, Image as ImageIcon } from 'lucide-react' +import type { VisualEvidence } from '@/lib/sdk/advisor/contract' +import { PaneHeader } from './PaneHeader' + +function VisualCard({ v }: { v: VisualEvidence }) { + const canOpen = !!v.image_ref && /^https?:\/\//i.test(v.image_ref) + return ( +
+
+
+
{v.caption || v.visual_type}
+
+ + {v.visual_type} + + Quelle: {v.document} +
+
+ {canOpen && ( + + + Original anzeigen + + )} +
+ {canOpen ? ( + + {/* eslint-disable-next-line @next/next/no-img-element */} + {v.caption + + ) : ( +
+ Original-Darstellung folgt +
+ )} + {v.vision_summary &&

{v.vision_summary}

} +
+ ) +} + +/** Visual evidence (C8) — diagrams/figures, rendered only when present. */ +export function VisualEvidencePane({ items }: { items: VisualEvidence[] }) { + if (items.length === 0) return null + return ( +
+ } + title="Diagramme & Abbildungen" + count={items.length} + /> +
+ {items.map((v) => ( + + ))} +
+
+ ) +} diff --git a/admin-compliance/components/sdk/advisor/useAdvisorCase.ts b/admin-compliance/components/sdk/advisor/useAdvisorCase.ts new file mode 100644 index 00000000..cdc59abd --- /dev/null +++ b/admin-compliance/components/sdk/advisor/useAdvisorCase.ts @@ -0,0 +1,97 @@ +'use client' + +import { useCallback, useRef, useState } from 'react' +import type { AdvisorResponse } from '@/lib/sdk/advisor/contract' + +export interface AdvisorCase { + id: string + question: string + response: AdvisorResponse | null + selectedContext: string | null + status: 'loading' | 'done' | 'error' + error?: string +} + +interface UseAdvisorCaseArgs { + currentStep: string + country: string +} + +/** + * Drives the Advisor as a series of CASES. Each ask posts {question, context?} and receives a + * structured AdvisorResponse (mode: clarify | answer) — no streaming, no answer-text parsing. + * selectContext() re-runs the same case scoped to a chosen domain (clarify -> answer). + */ +export function useAdvisorCase({ currentStep, country }: UseAdvisorCaseArgs) { + const [cases, setCases] = useState([]) + const [busy, setBusy] = useState(false) + const abortRef = useRef(null) + + const patch = useCallback((id: string, p: Partial) => { + setCases((prev) => prev.map((c) => (c.id === id ? { ...c, ...p } : c))) + }, []) + + const run = useCallback( + async (id: string, question: string, context: string | null) => { + setBusy(true) + abortRef.current = new AbortController() + try { + const res = await fetch('/api/sdk/compliance-advisor/chat', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ question, context, currentStep, country }), + signal: abortRef.current.signal, + }) + if (!res.ok) { + const e = await res.json().catch(() => ({ error: 'Unbekannter Fehler' })) + throw new Error(e.error || `Server-Fehler (${res.status})`) + } + const data = (await res.json()) as AdvisorResponse + patch(id, { response: data, status: 'done', selectedContext: context }) + } catch (err) { + if ((err as Error).name === 'AbortError') { + patch(id, { status: 'done' }) + return + } + patch(id, { + status: 'error', + error: err instanceof Error ? err.message : 'Verbindung fehlgeschlagen', + }) + } finally { + setBusy(false) + } + }, + [currentStep, country, patch], + ) + + const ask = useCallback( + (question: string) => { + const q = question.trim() + if (!q || busy) return + const id = `case-${Date.now()}` + setCases((prev) => [ + ...prev, + { id, question: q, response: null, selectedContext: null, status: 'loading' }, + ]) + void run(id, q, null) + }, + [busy, run], + ) + + const selectContext = useCallback( + (id: string, context: string) => { + const c = cases.find((x) => x.id === id) + if (!c || busy) return + patch(id, { status: 'loading', selectedContext: context }) + void run(id, c.question, context) + }, + [cases, busy, run, patch], + ) + + const stop = useCallback(() => { + abortRef.current?.abort() + setBusy(false) + }, []) + + return { cases, busy, ask, selectContext, stop } +} diff --git a/admin-compliance/components/sdk/advisor/useAdvisorEmail.ts b/admin-compliance/components/sdk/advisor/useAdvisorEmail.ts index f27bd7b3..b06c6f71 100644 --- a/admin-compliance/components/sdk/advisor/useAdvisorEmail.ts +++ b/admin-compliance/components/sdk/advisor/useAdvisorEmail.ts @@ -1,7 +1,7 @@ 'use client' import { useCallback, useState } from 'react' -import type { AdvisorTurn } from './useAdvisorStream' +import type { AdvisorCase } from './useAdvisorCase' function esc(s: string): string { return s @@ -11,33 +11,34 @@ function esc(s: string): string { .replace(/"/g, '"') } -function sourcesHtml(turn: AdvisorTurn): string { - if (turn.meta.sources.length === 0) return '' - const items = turn.meta.sources - .map((s) => { - const hier = [s.section, s.subsection, s.paragraph, s.footnoteRef].filter(Boolean).join(' › ') - return `
  • ${esc(s.regulation.short || '')}${hier ? ` — ${esc(hier)}` : ''}
  • ` - }) +function evidenceHtml(c: AdvisorCase): string { + const ev = c.response?.evidence ?? [] + if (ev.length === 0) return '' + const items = ev + .map( + (e) => + `
  • ${esc(e.document)}${e.section ? ` — ${esc(e.section)}` : ''}${e.paragraph ? ` ${esc(e.paragraph)}` : ''}
  • `, + ) .join('') - return `

    Quellen:

      ${items}
    ` + return `

    Evidence:

      ${items}
    ` } -/** Sends the consultation transcript (question + answer + structured sources) as an email to the DSB. */ -export function useAdvisorEmail(turns: AdvisorTurn[], country: string, currentStep: string) { +/** Sends the consultation cases (question + answer + evidence) as an email to the DSB. */ +export function useAdvisorEmail(cases: AdvisorCase[], country: string, currentStep: string) { const [sending, setSending] = useState(false) const [sent, setSent] = useState(false) const send = useCallback(async () => { - if (turns.length === 0 || sending) return + if (cases.length === 0 || sending) return setSending(true) try { - const qaHtml = turns - .map( - (t) => - `

    Frage: ${esc( - t.question, - )}

    ${esc(t.answer)}

    ${sourcesHtml(t)}
    `, - ) + const qaHtml = cases + .map((c) => { + const a = c.response?.answer || c.response?.general_answer || '(keine Antwort)' + return `

    Frage: ${esc( + c.question, + )}

    ${esc(a)}

    ${evidenceHtml(c)}
    ` + }) .join('') const bodyHtml = ` @@ -53,7 +54,7 @@ export function useAdvisorEmail(turns: AdvisorTurn[], country: string, currentSt headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ recipient: 'dsb@breakpilot.local', - subject: `Compliance Advisor — ${turns.length} Fragen (${currentStep})`, + subject: `Compliance Advisor — ${cases.length} Fragen (${currentStep})`, body_html: bodyHtml, role: 'Datenschutzbeauftragter', }), @@ -65,7 +66,7 @@ export function useAdvisorEmail(turns: AdvisorTurn[], country: string, currentSt } finally { setSending(false) } - }, [turns, sending, country, currentStep]) + }, [cases, sending, country, currentStep]) return { send, sending, sent } } diff --git a/admin-compliance/components/sdk/advisor/useAdvisorStream.ts b/admin-compliance/components/sdk/advisor/useAdvisorStream.ts deleted file mode 100644 index f6269372..00000000 --- a/admin-compliance/components/sdk/advisor/useAdvisorStream.ts +++ /dev/null @@ -1,110 +0,0 @@ -'use client' - -import { useCallback, useRef, useState } from 'react' -import type { AdvisorEvidenceMeta } from '@/lib/sdk/advisor/evidence' -import { emptyStats } from '@/lib/sdk/advisor/evidence' - -export interface AdvisorTurn { - id: string - question: string - answer: string - meta: AdvisorEvidenceMeta - status: 'streaming' | 'done' | 'error' - error?: string -} - -function emptyMeta(): AdvisorEvidenceMeta { - return { stats: emptyStats(), sources: [], figures: [], footnotes: [] } -} - -interface UseAdvisorStreamArgs { - currentStep: string - country: string -} - -/** - * Drives the Evidence Workspace: posts a question, parses the FIRST line of the response as - * structured `AdvisorEvidenceMeta`, then streams the remaining bytes as the markdown answer. - * The answer text is NEVER parsed for structure — sources/figures/footnotes come from the meta. - */ -export function useAdvisorStream({ currentStep, country }: UseAdvisorStreamArgs) { - const [turns, setTurns] = useState([]) - const [isStreaming, setIsStreaming] = useState(false) - const abortRef = useRef(null) - - const patch = useCallback((id: string, p: Partial) => { - setTurns((prev) => prev.map((t) => (t.id === id ? { ...t, ...p } : t))) - }, []) - - const stop = useCallback(() => { - abortRef.current?.abort() - setIsStreaming(false) - }, []) - - const send = useCallback( - async (question: string) => { - const q = question.trim() - if (!q || isStreaming) return - - const id = `turn-${Date.now()}` - const history = turns.flatMap((t) => [ - { role: 'user', content: t.question }, - { role: 'assistant', content: t.answer }, - ]) - setTurns((prev) => [...prev, { id, question: q, answer: '', meta: emptyMeta(), status: 'streaming' }]) - setIsStreaming(true) - abortRef.current = new AbortController() - - try { - const res = await fetch('/api/sdk/compliance-advisor/chat', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ message: q, history, currentStep, country }), - signal: abortRef.current.signal, - }) - if (!res.ok || !res.body) { - const e = await res.json().catch(() => ({ error: 'Unbekannter Fehler' })) - throw new Error(e.error || `Server-Fehler (${res.status})`) - } - - const reader = res.body.getReader() - const decoder = new TextDecoder() - let buf = '' - let metaEnd = -1 - let meta: AdvisorEvidenceMeta | null = null - - for (;;) { - const { done, value } = await reader.read() - if (done) break - buf += decoder.decode(value, { stream: true }) - if (metaEnd === -1) { - const nl = buf.indexOf('\n') - if (nl === -1) continue - metaEnd = nl + 1 - try { - meta = JSON.parse(buf.slice(0, nl)) as AdvisorEvidenceMeta - } catch { - meta = null // no valid meta -> treat whole stream as answer - metaEnd = 0 - } - } - patch(id, { answer: buf.slice(metaEnd), ...(meta ? { meta } : {}) }) - } - - buf += decoder.decode() - patch(id, { answer: buf.slice(metaEnd === -1 ? 0 : metaEnd), status: 'done', ...(meta ? { meta } : {}) }) - setIsStreaming(false) - } catch (err) { - setIsStreaming(false) - if ((err as Error).name === 'AbortError') { - patch(id, { status: 'done' }) - return - } - patch(id, { status: 'error', error: err instanceof Error ? err.message : 'Verbindung fehlgeschlagen' }) - } - }, - [isStreaming, turns, currentStep, country, patch], - ) - - return { turns, isStreaming, send, stop } -} diff --git a/admin-compliance/components/sdk/advisor/useCitationHighlight.ts b/admin-compliance/components/sdk/advisor/useCitationHighlight.ts new file mode 100644 index 00000000..5d1c3e68 --- /dev/null +++ b/admin-compliance/components/sdk/advisor/useCitationHighlight.ts @@ -0,0 +1,34 @@ +'use client' + +import { useState } from 'react' +import type { Citation } from '@/lib/sdk/advisor/contract' +import type { CiteHandler } from './Markdown' + +/** + * Couples answer [n] markers to evidence cards: clicking [n] highlights + scrolls to the referenced + * evidence unit. Works across layout columns via the card's DOM id (ev-). + */ +export function useCitationHighlight(citations: Citation[]): { + highlightedId?: string + cite?: CiteHandler +} { + const [highlightedId, setHighlightedId] = useState() + if (citations.length === 0) return { highlightedId } + return { + highlightedId, + cite: { + count: citations.length, + onSelect: (n: number) => { + const c = citations[n - 1] + if (!c) return + setHighlightedId(c.evidence_id) + if (typeof document !== 'undefined') { + document.getElementById(`ev-${c.evidence_id}`)?.scrollIntoView({ + behavior: 'smooth', + block: 'center', + }) + } + }, + }, + } +} diff --git a/admin-compliance/lib/sdk/advisor/contract.ts b/admin-compliance/lib/sdk/advisor/contract.ts new file mode 100644 index 00000000..77f41c60 --- /dev/null +++ b/admin-compliance/lib/sdk/advisor/contract.ts @@ -0,0 +1,76 @@ +// FE-facing contract for the Compliance Advisor "Case" (Clarity Gate). +// Matches the SDK<->FE contract (board 2026-07-01 / memory advisor-clarity-gate-contract). +// The FE renders ONLY these structured fields; it never extracts structure from the answer text. +// The only exception is rendering the deliberate [n] citation markers, mapped via `citations`. + +export interface SuggestedContext { + id: string // e.g. "datenschutz" + label: string // e.g. "Datenschutz" +} + +export interface ClarityInfo { + is_underspecified: boolean + concentration: number + suggested_contexts?: SuggestedContext[] // clarify mode + dominant_context?: string // answer mode +} + +/** A retrieved evidence unit. (`evidence[]` item shape — confirm with SDK; see board rückfrage.) */ +export interface EvidenceUnit { + evidence_id: string + document: string + section?: string + paragraph?: string + snippet?: string + url?: string +} + +/** Numbered [n] <-> evidence coupling, produced by the SDK (not parsed from the answer). */ +export interface Citation { + citation_id: string + evidence_id: string + document: string + section?: string | null + paragraph?: string | null + footnote?: string | null + figure?: string | null +} + +/** C8 / visual evidence — `visual_type` generalizes beyond figures (flowchart/bpmn/state_machine/...). */ +export interface VisualEvidence { + visual_id: string + visual_type: string + caption?: string + document: string + context?: string + image_ref?: string + vision_summary?: string +} + +export interface Footnote { + footnote_id?: string + ref?: string + document?: string + section?: string + text?: string +} + +export type AdvisorMode = 'clarify' | 'answer' + +export interface AdvisorResponse { + mode: AdvisorMode + question: string + clarity: ClarityInfo + general_answer?: string | null // L1 (clarify mode) + answer?: string | null // L2 (answer mode) + scoped_query?: string | null + evidence: EvidenceUnit[] + citations: Citation[] + visual_evidence: VisualEvidence[] + footnotes: Footnote[] +} + +export interface AdvisorRequest { + question: string + context?: string | null +}