feat(advisor): Evidence Workspace — structured panes, markdown, sources as knowledge units

Rebuilds the Compliance Advisor floating widget from a plain chat into an Evidence
Workspace: pinned last question, markdown-rendered answer (clean prose), and separate
panes for Sources (hierarchical Knowledge Units), Figures (C8, conditional) and
Footnotes (C-FN), plus a stats bar (Quellen/Regelwerke/Diagramme/Fußnoten). Scrollable
turn history; stays a floating icon on every SDK page.

Architecture (user direction): the frontend renders ONLY structured evidence and NEVER
parses the answer text. The proxy now returns a JSON AdvisorEvidenceMeta line followed
by the streamed markdown answer; advisor-rag exposes structured results; an adapter maps
RAG/compiler output to the frontend envelope. Figures/footnotes wire in once the
RAG-ingestion contract lands (requested on the board) — figures pane is conditional.

- lib/sdk/advisor/{evidence,evidence-adapter}.ts (+ adapter test, 7 cases)
- components/sdk/advisor/* panes + in-house safe Markdown (no new dep, no dangerouslySetInnerHTML) + test
- useAdvisorStream (meta-line parse + streamed answer) + useAdvisorEmail (escaped)
- proxy: evidence-meta-v1 envelope + clean-prose prompt (no inline citations)
- tsc clean, 11 vitest pass, check-loc 0. ESLint not installed in this node_modules -> CI lints on push.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-07-01 07:46:37 +02:00
parent f0120b237e
commit 49171e841f
22 changed files with 1379 additions and 421 deletions
@@ -0,0 +1,37 @@
'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 (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{error}
</div>
)
}
if (!answer && streaming) {
return (
<div className="flex space-x-1 px-1 py-2" aria-label="Antwort wird generiert">
<span className="h-2 w-2 animate-bounce rounded-full bg-gray-400" />
<span className="h-2 w-2 animate-bounce rounded-full bg-gray-400" style={{ animationDelay: '0.1s' }} />
<span className="h-2 w-2 animate-bounce rounded-full bg-gray-400" style={{ animationDelay: '0.2s' }} />
</div>
)
}
return (
<div className="rounded-lg border border-gray-200 bg-white px-3 py-2">
<Markdown content={answer} />
{streaming && <span className="ml-0.5 inline-block h-3 w-1.5 animate-pulse bg-indigo-400 align-middle" />}
</div>
)
}
@@ -0,0 +1,64 @@
'use client'
import { ShieldCheck } from 'lucide-react'
export const EXAMPLE_QUESTIONS: Record<string, string[]> = {
vvt: [
'Was ist ein Verarbeitungsverzeichnis?',
'Welche Informationen muss ich erfassen?',
'Wie dokumentiere ich die Rechtsgrundlage?',
],
'compliance-scope': [
'Was bedeutet L3?',
'Wann brauche ich eine DSFA?',
'Was ist der Unterschied zwischen L2 und L3?',
],
tom: [
'Was sind TOM?',
'Welche Massnahmen sind erforderlich?',
'Wie dokumentiere ich Verschluesselung?',
],
dsfa: ['Was ist eine DSFA?', 'Wann ist eine DSFA verpflichtend?', 'Wie bewerte ich Risiken?'],
loeschfristen: [
'Wie definiere ich Loeschfristen?',
'Unterschied Loeschpflicht und Aufbewahrungspflicht?',
'Wann muss ich Daten loeschen?',
],
default: [
'Wie starte ich mit dem SDK?',
'Was ist der erste Schritt?',
'Welche Compliance-Anforderungen gelten fuer KI-Systeme?',
],
}
export function AdvisorEmptyState({
exampleQuestions,
onExampleClick,
}: {
exampleQuestions: string[]
onExampleClick: (question: string) => void
}) {
return (
<div className="px-4 py-8 text-center">
<div className="mx-auto mb-3 flex h-14 w-14 items-center justify-center rounded-full bg-indigo-100">
<ShieldCheck className="h-7 w-7 text-indigo-600" />
</div>
<h3 className="text-sm font-semibold text-gray-900">Compliance Advisor</h3>
<p className="mx-auto mt-1 max-w-xs text-xs text-gray-500">
Antworten mit nachvollziehbaren Quellen, Fundstellen und wo vorhanden Original-Abbildungen.
</p>
<div className="mt-4 space-y-2 text-left">
<p className="text-xs font-medium text-gray-700">Beispielfragen</p>
{exampleQuestions.map((q, i) => (
<button
key={i}
onClick={() => onExampleClick(q)}
className="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-left text-xs text-gray-700 transition-colors hover:bg-indigo-50"
>
{q}
</button>
))}
</div>
</div>
)
}
@@ -0,0 +1,51 @@
'use client'
import { useEffect, useRef } from 'react'
import type { AdvisorTurn } from './useAdvisorStream'
import { StickyQuestion } from './StickyQuestion'
import { TurnView } from './TurnView'
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.
*/
export function EvidenceWorkspace({
turns,
exampleQuestions,
onExample,
}: {
turns: AdvisorTurn[]
exampleQuestions: string[]
onExample: (q: string) => void
}) {
const endRef = useRef<HTMLDivElement>(null)
const latest = turns[turns.length - 1]
// 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).
useEffect(() => {
endRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [turns.length])
if (turns.length === 0) {
return (
<div className="flex-1 overflow-y-auto bg-gray-50">
<AdvisorEmptyState exampleQuestions={exampleQuestions} onExampleClick={onExample} />
</div>
)
}
return (
<div className="flex-1 overflow-y-auto bg-gray-50">
{latest && <StickyQuestion question={latest.question} />}
<div className="space-y-4 p-4">
{turns.map((t, i) => (
<TurnView key={t.id} turn={t} showQuestion={i !== turns.length - 1} />
))}
<div ref={endRef} />
</div>
</div>
)
}
@@ -0,0 +1,71 @@
'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 (
<div className="rounded-lg border border-gray-200 bg-white p-2.5">
<div className="flex items-start justify-between gap-2">
<div className="text-xs font-semibold text-gray-900">
{fig.label}
{fig.caption ? <span className="font-normal text-gray-600"> {fig.caption}</span> : null}
</div>
{canOpen && (
<a
href={fig.imageUrl}
target="_blank"
rel="noopener noreferrer"
className="flex flex-shrink-0 items-center gap-0.5 rounded px-1.5 py-0.5 text-[11px] font-medium text-indigo-600 hover:bg-indigo-50"
>
<ExternalLink className="h-3 w-3" />
Original anzeigen
</a>
)}
</div>
<div className="mt-0.5 text-[11px] text-gray-500">
Quelle: {fig.source.short}
{fig.section ? ` · ${fig.section}` : ''}
</div>
{canOpen ? (
<a href={fig.imageUrl} target="_blank" rel="noopener noreferrer" className="mt-1.5 block">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={fig.imageUrl}
alt={fig.caption || fig.label}
loading="lazy"
className="max-h-44 w-full rounded border border-gray-100 object-contain"
/>
</a>
) : (
<div className="mt-1.5 flex items-center justify-center rounded border border-dashed border-gray-200 bg-gray-50 px-3 py-5 text-[11px] text-gray-400">
Original-Abbildung folgt
</div>
)}
{fig.visionSummary && (
<p className="mt-1.5 text-[11px] italic text-gray-500">{fig.visionSummary}</p>
)}
</div>
)
}
/** Figures pane (C8) — original document figures, rendered only when present. */
export function FiguresPane({ figures }: { figures: FigureUnit[] }) {
if (figures.length === 0) return null
return (
<section>
<PaneHeader
icon={<ImageIcon className="h-3.5 w-3.5 text-gray-500" />}
title="Abbildungen & Diagramme"
count={figures.length}
/>
<div className="space-y-1.5">
{figures.map((f) => (
<FigureCard key={f.id} fig={f} />
))}
</div>
</section>
)
}
@@ -0,0 +1,28 @@
'use client'
import { Hash } from 'lucide-react'
import type { FootnoteUnit } from '@/lib/sdk/advisor/evidence'
import { PaneHeader } from './PaneHeader'
/** Footnotes pane (C-FN) — rendered only when present. */
export function FootnotesPane({ footnotes }: { footnotes: FootnoteUnit[] }) {
if (footnotes.length === 0) return null
return (
<section>
<PaneHeader icon={<Hash className="h-3.5 w-3.5 text-gray-500" />} title="Fußnoten" count={footnotes.length} />
<div className="space-y-1">
{footnotes.map((fn) => (
<div key={fn.id} className="rounded-md border border-gray-200 bg-white p-2 text-[11px]">
<span className="font-semibold text-gray-900">{fn.ref}</span>
<span className="text-gray-400">
{' · '}
{fn.source.short}
{fn.section ? ` / ${fn.section}` : ''}
</span>
{fn.text && <p className="mt-0.5 text-gray-600">{fn.text}</p>}
</div>
))}
</div>
</section>
)
}
@@ -0,0 +1,68 @@
'use client'
import { useState } from 'react'
import { ChevronDown, ChevronRight, ExternalLink } from 'lucide-react'
import type { KnowledgeUnit } from '@/lib/sdk/advisor/evidence'
/**
* 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.
*/
export function KnowledgeUnitCard({ unit }: { unit: KnowledgeUnit }) {
const [open, setOpen] = useState(false)
const crumbs = [unit.section, unit.subsection, unit.paragraph, unit.footnoteRef].filter(Boolean)
const href = unit.open?.originalUrl
const canOpen = href && /^https?:\/\//i.test(href)
return (
<div className="rounded-lg border border-gray-200 bg-white p-2.5">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="truncate text-xs font-semibold text-gray-900">{unit.regulation.short}</div>
{crumbs.length > 0 ? (
<div className="mt-0.5 flex flex-wrap items-center gap-x-1 text-[11px] text-gray-500">
{crumbs.map((c, i) => (
<span key={i} className="flex items-center gap-1">
{i > 0 && <span className="text-gray-300"></span>}
{c}
</span>
))}
</div>
) : (
unit.label && <div className="mt-0.5 text-[11px] text-gray-500">{unit.label}</div>
)}
</div>
{canOpen && (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="flex flex-shrink-0 items-center gap-0.5 rounded px-1.5 py-0.5 text-[11px] font-medium text-indigo-600 hover:bg-indigo-50"
>
<ExternalLink className="h-3 w-3" />
öffnen
</a>
)}
</div>
{unit.snippet && (
<div className="mt-1.5">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="flex items-center gap-0.5 text-[11px] text-gray-400 hover:text-gray-600"
>
{open ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
Textauszug
</button>
{open && (
<p className="mt-1 border-l-2 border-gray-200 pl-2 text-[11px] italic text-gray-500">
{unit.snippet}
</p>
)}
</div>
)}
</div>
)
}
@@ -0,0 +1,40 @@
import { describe, it, expect } from 'vitest'
import { render } from '@testing-library/react'
import { Markdown } from './Markdown'
describe('Markdown', () => {
it('renders headings, bold and bullet lists (not raw markdown markers)', () => {
const { container } = render(
<Markdown
content={'## Pflichten\n\nDer **Verantwortliche** muss:\n\n- ein Verzeichnis fuehren\n- Risiken bewerten'}
/>,
)
expect(container.querySelector('h4')?.textContent).toBe('Pflichten')
expect(container.querySelector('strong')?.textContent).toBe('Verantwortliche')
expect(container.querySelectorAll('li')).toHaveLength(2)
expect(container.textContent).not.toContain('##')
expect(container.textContent).not.toContain('**')
})
it('renders ordered lists and inline code', () => {
const { container } = render(<Markdown content={'1. Erst `init`\n2. Dann `build`'} />)
expect(container.querySelector('ol')).not.toBeNull()
expect(container.querySelectorAll('li')).toHaveLength(2)
expect(container.querySelectorAll('code')).toHaveLength(2)
})
it('renders fenced code blocks', () => {
const { container } = render(<Markdown content={'```\nconst x = 1\n```'} />)
expect(container.querySelector('pre')).not.toBeNull()
expect(container.textContent).toContain('const x = 1')
})
it('only allows http(s) links', () => {
const { container } = render(
<Markdown content={'[ok](https://example.test) and [bad](javascript:alert(1))'} />,
)
const links = container.querySelectorAll('a')
expect(links).toHaveLength(1)
expect(links[0].getAttribute('href')).toBe('https://example.test')
})
})
@@ -0,0 +1,153 @@
'use client'
// 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.)
const INLINE_RE = /(`[^`]+`|\*\*[^*]+\*\*|\*[^*\s][^*]*\*|_[^_]+_|\[[^\]]+\]\([^)]+\))/g
function renderInline(text: string, kp: string): React.ReactNode[] {
const nodes: React.ReactNode[] = []
let last = 0
let idx = 0
INLINE_RE.lastIndex = 0
let m: RegExpExecArray | null
while ((m = INLINE_RE.exec(text)) !== null) {
if (m.index > last) nodes.push(text.slice(last, m.index))
const tok = m[0]
const key = `${kp}-${idx++}`
if (tok.startsWith('`')) {
nodes.push(
<code key={key} className="rounded bg-gray-100 px-1 py-0.5 font-mono text-[0.85em]">
{tok.slice(1, -1)}
</code>,
)
} else if (tok.startsWith('**')) {
nodes.push(
<strong key={key} className="font-semibold text-gray-900">
{tok.slice(2, -2)}
</strong>,
)
} else if (tok.startsWith('*') || tok.startsWith('_')) {
nodes.push(<em key={key}>{tok.slice(1, -1)}</em>)
} else {
const mm = /^\[([^\]]+)\]\(([^)]+)\)$/.exec(tok)
if (mm && /^https?:\/\//i.test(mm[2])) {
nodes.push(
<a
key={key}
href={mm[2]}
target="_blank"
rel="noopener noreferrer"
className="text-indigo-600 underline hover:text-indigo-800"
>
{mm[1]}
</a>,
)
} else {
nodes.push(mm ? mm[1] : tok)
}
}
last = m.index + tok.length
}
if (last < text.length) nodes.push(text.slice(last))
return nodes
}
function Heading({ level, kp, text }: { level: number; kp: string; text: string }) {
const children = renderInline(text, kp)
if (level <= 1) return <h3 className="mb-1 mt-3 text-base font-bold text-gray-900">{children}</h3>
if (level === 2) return <h4 className="mb-1 mt-3 text-sm font-bold text-gray-900">{children}</h4>
return <h5 className="mb-1 mt-2 text-sm font-semibold text-gray-800">{children}</h5>
}
const UL_RE = /^\s*[-*]\s+/
const OL_RE = /^\s*\d+\.\s+/
const H_RE = /^(#{1,6})\s+(.*)$/
export function Markdown({ content }: { content: string }) {
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)
if (line.trim().startsWith('```')) {
const buf: string[] = []
i++
while (i < lines.length && !lines[i].trim().startsWith('```')) {
buf.push(lines[i])
i++
}
i++
blocks.push(
<pre
key={key}
className="my-2 overflow-x-auto rounded bg-gray-900 p-3 font-mono text-xs text-gray-100"
>
<code>{buf.join('\n')}</code>
</pre>,
)
continue
}
if (line.trim() === '') {
i++
continue
}
const h = H_RE.exec(line)
if (h) {
blocks.push(<Heading key={key} kp={key} level={h[1].length} text={h[2]} />)
i++
continue
}
if (UL_RE.test(line)) {
const items: string[] = []
while (i < lines.length && UL_RE.test(lines[i])) {
items.push(lines[i].replace(UL_RE, ''))
i++
}
blocks.push(
<ul key={key} className="my-1.5 ml-4 list-disc space-y-1 text-gray-700">
{items.map((it, k) => (
<li key={k}>{renderInline(it, `${key}-${k}`)}</li>
))}
</ul>,
)
continue
}
if (OL_RE.test(line)) {
const items: string[] = []
while (i < lines.length && OL_RE.test(lines[i])) {
items.push(lines[i].replace(OL_RE, ''))
i++
}
blocks.push(
<ol key={key} className="my-1.5 ml-5 list-decimal space-y-1 text-gray-700">
{items.map((it, k) => (
<li key={k}>{renderInline(it, `${key}-${k}`)}</li>
))}
</ol>,
)
continue
}
const para: string[] = []
while (
i < lines.length &&
lines[i].trim() !== '' &&
!H_RE.test(lines[i]) &&
!UL_RE.test(lines[i]) &&
!OL_RE.test(lines[i]) &&
!lines[i].trim().startsWith('```')
) {
para.push(lines[i])
i++
}
blocks.push(
<p key={key} className="my-1.5 leading-relaxed text-gray-700">
{renderInline(para.join(' '), key)}
</p>,
)
}
return <div className="advisor-markdown text-sm">{blocks}</div>
}
@@ -0,0 +1,24 @@
'use client'
/** Shared section header for evidence panes (icon + title + count badge). */
export function PaneHeader({
icon,
title,
count,
}: {
icon: React.ReactNode
title: string
count?: number
}) {
return (
<div className="mb-1.5 flex items-center gap-1.5 text-xs font-semibold text-gray-700">
{icon}
<span>{title}</span>
{count != null && (
<span className="rounded-full bg-gray-100 px-1.5 text-[10px] font-medium text-gray-500">
{count}
</span>
)}
</div>
)
}
@@ -0,0 +1,24 @@
'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>
)
}
@@ -0,0 +1,52 @@
'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>
)
}
@@ -0,0 +1,21 @@
'use client'
import { HelpCircle } from 'lucide-react'
/** The last question, pinned so it never scrolls out of view while the answer grows. */
export function StickyQuestion({ question }: { question: string }) {
if (!question) return null
return (
<div className="sticky top-0 z-10 border-b border-indigo-100 bg-indigo-50/95 px-4 py-2 backdrop-blur">
<div className="flex items-start gap-2">
<HelpCircle className="mt-0.5 h-4 w-4 flex-shrink-0 text-indigo-500" />
<div className="min-w-0">
<div className="text-[10px] font-semibold uppercase tracking-wide text-indigo-400">
Letzte Frage
</div>
<div className="text-sm font-medium text-gray-800">{question}</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,27 @@
'use client'
import type { AdvisorTurn } from './useAdvisorStream'
import { StatsBar } from './StatsBar'
import { AnswerPane } from './AnswerPane'
import { SourcesPane } from './SourcesPane'
import { FiguresPane } from './FiguresPane'
import { FootnotesPane } from './FootnotesPane'
/** One question/answer turn rendered as stacked evidence panels. */
export function TurnView({ turn, showQuestion }: { turn: AdvisorTurn; showQuestion?: boolean }) {
const streaming = turn.status === 'streaming'
return (
<div className="space-y-2 border-b border-gray-100 pb-4 last:border-0">
{showQuestion && (
<div className="text-xs text-gray-500">
<span className="font-medium text-gray-400">Frage:</span> {turn.question}
</div>
)}
<StatsBar stats={turn.meta.stats} />
<AnswerPane answer={turn.answer} streaming={streaming} error={turn.error} />
<SourcesPane sources={turn.meta.sources} />
<FiguresPane figures={turn.meta.figures} />
<FootnotesPane footnotes={turn.meta.footnotes} />
</div>
)
}
@@ -0,0 +1,71 @@
'use client'
import { useCallback, useState } from 'react'
import type { AdvisorTurn } from './useAdvisorStream'
function esc(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
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 `<li>${esc(s.regulation.short || '')}${hier ? `${esc(hier)}` : ''}</li>`
})
.join('')
return `<p style="color:#64748b;font-size:12px;margin:4px 0 0;">Quellen:</p><ul style="color:#64748b;font-size:12px;margin:2px 0;">${items}</ul>`
}
/** Sends the consultation transcript (question + answer + structured sources) as an email to the DSB. */
export function useAdvisorEmail(turns: AdvisorTurn[], country: string, currentStep: string) {
const [sending, setSending] = useState(false)
const [sent, setSent] = useState(false)
const send = useCallback(async () => {
if (turns.length === 0 || sending) return
setSending(true)
try {
const qaHtml = turns
.map(
(t) =>
`<div style="margin-bottom:16px;"><p style="font-weight:600;color:#1e293b;">Frage: ${esc(
t.question,
)}</p><p style="color:#475569;white-space:pre-wrap;">${esc(t.answer)}</p>${sourcesHtml(t)}</div>`,
)
.join('')
const bodyHtml = `
<h2 style="color:#1e293b;">Compliance Advisor — Beratungsprotokoll</h2>
<p style="color:#64748b;font-size:13px;">Datum: ${esc(new Date().toLocaleString('de-DE'))} | Land: ${esc(country)} | Kontext: ${esc(currentStep)}</p>
<hr style="border-color:#e2e8f0;margin:16px 0;">
${qaHtml}
<hr style="border-color:#e2e8f0;margin:16px 0;">
<p style="color:#94a3b8;font-size:11px;">Automatisch erstellt vom BreakPilot Compliance Advisor</p>`
await fetch('/api/sdk/v1/agent/notify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
recipient: 'dsb@breakpilot.local',
subject: `Compliance Advisor — ${turns.length} Fragen (${currentStep})`,
body_html: bodyHtml,
role: 'Datenschutzbeauftragter',
}),
})
setSent(true)
setTimeout(() => setSent(false), 3000)
} catch (e) {
console.error('Email send failed:', e)
} finally {
setSending(false)
}
}, [turns, sending, country, currentStep])
return { send, sending, sent }
}
@@ -0,0 +1,110 @@
'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<AdvisorTurn[]>([])
const [isStreaming, setIsStreaming] = useState(false)
const abortRef = useRef<AbortController | null>(null)
const patch = useCallback((id: string, p: Partial<AdvisorTurn>) => {
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 }
}