merge(admin): FE Evidence-Workspace → main (evidence-framed header + bindingness)
CI / detect-changes (push) Successful in 5s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / build-sha-integrity (push) Successful in 4s
CI / validate-canonical-controls (push) Successful in 4s
CI / loc-budget (push) Successful in 18s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Successful in 3m2s
CI / test-go (push) Successful in 1m0s
CI / iace-gt-coverage (push) Successful in 15s
CI / test-python-backend (push) Has been skipped
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped

This commit is contained in:
Claude
2026-07-01 17:01:42 +02:00
30 changed files with 2169 additions and 499 deletions
@@ -1,34 +1,29 @@
/**
* Compliance Advisor Chat API
* Compliance Advisor Chat API — Clarity-Gate orchestration.
*
* Verbindet das ComplianceAdvisorWidget mit:
* 1. Multi-Collection-RAG ueber die ai-compliance-sdk (bge-m3) — siehe advisor-rag
* 2. Strukturierten Controls zum erkannten Thema — buildControlsContext
* 3. LLM-Kaskade OVH (prod) -> Ollama (Dev) — siehe advisor-llm
*
* Laenderspezifische Filterung (DE, AT, CH, EU). Streamt die Antwort als Text.
* Consumes the SDK/RAG /retrieve (evidence/visual_evidence/footnotes/clarity) and returns the
* FE-facing contract (advisor-clarity-gate-contract):
* - clarify mode -> short L1 general answer (no RAG) + domain context chips
* - answer mode -> L2 answer over the scoped evidence with [n] citation markers
* Citations are generated here ([n] -> nth evidence unit). The FE renders ONLY this structured data.
*/
import { NextRequest, NextResponse } from 'next/server'
import { readSoulFile } from '@/lib/sdk/agents/soul-reader'
import { buildControlsContext } from '@/lib/sdk/agents/controls-augmentation'
import { queryAdvisorRAG } from '@/lib/sdk/agents/advisor-rag'
import { streamAdvisorAnswer, type ChatMessage } from '@/lib/sdk/agents/advisor-llm'
import { retrieveFull } from '@/lib/sdk/agents/advisor-rag'
import { completeAdvisorAnswer, streamAdvisorAnswer, type ChatMessage } from '@/lib/sdk/agents/advisor-llm'
import {
buildCitations,
isLegacyRequest,
mapClarity,
mapFootnotes,
numberedEvidenceForPrompt,
resolveMode,
} from '@/lib/sdk/advisor/retrieve-mapping'
import type { AdvisorResponse } from '@/lib/sdk/advisor/contract'
type Country = 'DE' | 'AT' | 'CH' | 'EU'
const FALLBACK_SYSTEM_PROMPT = `# Compliance Advisor Agent
## Identitaet
Du bist der BreakPilot Compliance-Berater. Du hilfst Nutzern des AI Compliance SDK,
Datenschutz- und Compliance-Fragen in verstaendlicher Sprache zu beantworten.
## Kernprinzipien
- Quellenbasiert: Verweise auf DSGVO-Artikel, BDSG-Paragraphen
- Verstaendlich: Einfache, praxisnahe Sprache
- Ehrlich: Bei Unsicherheit empfehle Rechtsberatung
- Deutsch als Hauptsprache`
const COUNTRY_LABELS: Record<Country, string> = {
DE: 'Deutschland',
AT: 'Oesterreich',
@@ -38,84 +33,135 @@ const COUNTRY_LABELS: Record<Country, string> = {
function countryBlock(c: Country): string {
const label = COUNTRY_LABELS[c]
const nationalLaws =
c === 'DE'
? 'BDSG, TDDDG, TKG, UWG'
: c === 'AT'
? 'AT DSG, ECG, TKG, KSchG, MedienG'
: 'CH DSG, DSV, OR, UWG, FMG'
const guidance =
c === 'EU'
? 'EU-weiten Fragen: Beziehe dich auf EU-Verordnungen und -Richtlinien'
: `${label}: Beziehe nationale Gesetze (${nationalLaws}) mit ein`
return `\n\n## Laenderspezifische Auskunft
Der Nutzer hat "${label} (${c})" gewaehlt.
- Beziehe dich AUSSCHLIESSLICH auf ${c}-Recht + anwendbares EU-Recht
- Nenne IMMER explizit das Land in deiner Antwort
- Verwende NIEMALS Gesetze eines anderen Landes
- Bei ${guidance}`
Der Nutzer hat "${label} (${c})" gewaehlt. Beziehe dich auf ${c}-Recht + anwendbares EU-Recht und nenne das Land.`
}
// L1: general knowledge, deliberately NOT grounded (the clarify step precedes the legal retrieval).
const L1_SYSTEM = `Du bist der BreakPilot Compliance-Berater. Gib eine KURZE, allgemeine Definition/Erklaerung
des gefragten Begriffs aus Allgemeinwissen — 2 bis 4 Saetze, Markdown, neutral. NENNE KEINE Rechtsquellen,
Paragraphen, Artikel oder Fundstellen; der Nutzer waehlt anschliessend einen konkreten Kontext, erst dann
folgen belegte Quellen. Wenn der Begriff in mehreren Bereichen vorkommt, erwaehne das in einem Halbsatz.`
const FALLBACK_SYSTEM = `Du bist der BreakPilot Compliance-Berater. Antworte quellenbasiert, verstaendlich und ehrlich auf Deutsch.`
// Optional audience/tonality guidance (e.g. the workspace's role hint). Kept out of the retrieval
// `question` on purpose — it only shapes the answer's tone, so it belongs in the system prompt.
function audienceBlock(audience: string): string {
return audience ? `\n\n## Ansprache / Zielgruppe\n${audience}` : ''
}
function answerSystem(
soul: string | null,
country: Country | undefined,
evidenceBlock: string,
withCitations = true,
audience = '',
): string {
let s = soul || FALLBACK_SYSTEM
if (country) s += countryBlock(country)
s += audienceBlock(audience)
s += `\n\n## Belegte Evidence (nummeriert — DEINE EINZIGEN Quellen)\n${evidenceBlock || '(keine Evidence gefunden)'}`
s += `\n\n## Antwortformat (WICHTIG)
- Beginne mit einer **Kurzzusammenfassung** (12 Saetze, "Kurz gesagt: …"), die den Kern direkt beantwortet.
- Danach gut gegliedertes Markdown: kurze ## Ueberschriften je THEMA/Aspekt (nicht je Rechtsquelle), Aufzaehlungen, **Fettung** fuer Kernbegriffe.`
if (withCitations) {
s += `\n- Belege Kernaussagen mit [n], wobei n die NUMMER der Evidence-Quelle oben ist (z. B. [1], [2]).
- Nenne KEINE Quellen-/Fundstellen-Liste im Fliesstext — die Quellen werden dem Nutzer separat angezeigt.`
} else {
s += `\n- Nenne Fundstellen nur, wo sie der Antwort dienen (natuerlich im Text, KEIN [n]-Markup).`
}
s += `\n- Triff KEINE Aussage, die nicht durch die nummerierte Evidence belegt ist; fehlt der Beleg, sage das offen.`
return s
}
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { message, history = [], currentStep = 'default', country } = body
if (!message || typeof message !== 'string') {
return NextResponse.json({ error: 'Message is required' }, { status: 400 })
}
const validCountry = (['DE', 'AT', 'CH', 'EU'] as const).includes(country)
? (country as Country)
const question = String(body.question ?? body.message ?? '').trim()
const context: string | null = body.context ?? null
const audience = typeof body.audience === 'string' ? body.audience.trim() : ''
const country = (['DE', 'AT', 'CH', 'EU'] as const).includes(body.country)
? (body.country as Country)
: undefined
// 1. RAG (ai-sdk, bge-m3) + strukturierte Controls zum Thema — beide parallel
const [ragContext, controlsContext] = await Promise.all([
queryAdvisorRAG(message),
buildControlsContext(message),
])
// 2. System-Prompt zusammenbauen
const soulPrompt = await readSoulFile('compliance-advisor')
let systemContent = soulPrompt || FALLBACK_SYSTEM_PROMPT
if (validCountry) systemContent += countryBlock(validCountry)
if (ragContext) {
systemContent += `\n\n## Relevanter Kontext aus dem RAG-System (deine EINZIGEN Rechtsquellen)\n\nDies sind deine einzigen zulaessigen Rechtsquellen. Triff keine konkrete Rechtsaussage (Zahl, Frist, Schwelle, Pflicht, Fundstelle), die nicht hier oder im Controls-Block belegt ist — sonst sage offen, dass du sie aus deinen Quellen nicht belegen kannst. Verweise in deiner Antwort auf die jeweilige Quelle:\n\n${ragContext}`
if (!question) {
return NextResponse.json({ error: 'Question is required' }, { status: 400 })
}
if (controlsContext) systemContent += `\n\n${controlsContext}`
systemContent += `\n\n## Aktueller SDK-Schritt\nDer Nutzer befindet sich im SDK-Schritt: ${currentStep}`
// 3. Nachrichten (History auf die letzten 6 begrenzen)
const retrieved = await retrieveFull(question, context)
// Backward-compat: legacy consumers (breakpilot-workspace) send {message} and read a plain-text
// stream. Serve the L2 answer streamed (clean prose, no [n]); no clarify gate, no JSON.
if (isLegacyRequest(body)) {
const legacyEvidence = retrieved.evidence ?? []
const legacySoul = await readSoulFile('compliance-advisor')
const legacyStream = await streamAdvisorAnswer([
{ role: 'system', content: answerSystem(legacySoul, country, numberedEvidenceForPrompt(legacyEvidence), false, audience) },
{ role: 'user', content: question },
])
if (!legacyStream) {
return NextResponse.json({ error: 'LLM nicht erreichbar.' }, { status: 502 })
}
return new NextResponse(legacyStream, {
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Cache-Control': 'no-cache',
'X-Advisor-Format': 'legacy-stream',
},
})
}
const mode = resolveMode(retrieved.clarity?.mode, !!context)
if (mode === 'clarify') {
const general = await completeAdvisorAnswer([
{ role: 'system', content: L1_SYSTEM + audienceBlock(audience) },
{ role: 'user', content: question },
])
if (general === null) {
return NextResponse.json({ error: 'LLM nicht erreichbar.' }, { status: 502 })
}
const resp: AdvisorResponse = {
mode: 'clarify',
question,
clarity: mapClarity(retrieved.clarity, 'clarify'),
general_answer: general,
answer: null,
scoped_query: null,
evidence: [],
citations: [],
visual_evidence: [],
footnotes: [],
}
return NextResponse.json(resp)
}
const evidence = retrieved.evidence ?? []
const soul = await readSoulFile('compliance-advisor')
const messages: ChatMessage[] = [
{ role: 'system', content: systemContent },
...history.slice(-6).map((h: { role: string; content: string }) => ({
role: h.role === 'user' ? 'user' : 'assistant',
content: h.content,
})),
{ role: 'user', content: message },
{ role: 'system', content: answerSystem(soul, country, numberedEvidenceForPrompt(evidence), true, audience) },
{ role: 'user', content: question },
]
// 4. LLM-Kaskade -> Plain-Text-Stream
const stream = await streamAdvisorAnswer(messages)
if (!stream) {
return NextResponse.json(
{ error: 'LLM nicht erreichbar. Weder OVH/LiteLLM noch Ollama haben geantwortet.' },
{ status: 502 },
)
const answer = await completeAdvisorAnswer(messages)
if (answer === null) {
return NextResponse.json({ error: 'LLM nicht erreichbar.' }, { status: 502 })
}
return new NextResponse(stream, {
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
},
})
const resp: AdvisorResponse = {
mode: 'answer',
question,
clarity: mapClarity(retrieved.clarity, 'answer'),
general_answer: null,
answer,
scoped_query: context,
evidence,
citations: buildCitations(evidence),
visual_evidence: retrieved.visual_evidence ?? [],
footnotes: mapFootnotes(retrieved.footnotes),
}
return NextResponse.json(resp)
} catch (error) {
console.error('Compliance advisor chat error:', error)
return NextResponse.json(
{ error: 'Verbindung zum LLM fehlgeschlagen.' },
{ status: 503 },
)
return NextResponse.json({ error: 'Verbindung zum Advisor fehlgeschlagen.' }, { status: 503 })
}
}
@@ -1,131 +0,0 @@
'use client'
// =============================================================================
// ComplianceAdvisorWidget — shared constants and sub-components
// =============================================================================
// =============================================================================
// EXAMPLE QUESTIONS
// =============================================================================
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?',
'Was ist der Unterschied zwischen 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?',
],
}
// =============================================================================
// TYPES
// =============================================================================
export interface Message {
id: string
role: 'user' | 'agent'
content: string
timestamp: Date
}
// =============================================================================
// EmptyState — shown when no messages yet
// =============================================================================
interface EmptyStateProps {
exampleQuestions: string[]
onExampleClick: (question: string) => void
}
export function AdvisorEmptyState({ exampleQuestions, onExampleClick }: EmptyStateProps) {
return (
<div className="text-center py-8">
<div className="w-16 h-16 bg-purple-100 rounded-full flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
</svg>
</div>
<h3 className="text-sm font-medium text-gray-900 mb-2">Willkommen beim Compliance Advisor</h3>
<p className="text-xs text-gray-500 mb-4">Stellen Sie Fragen zu DSGVO, KI-Verordnung und mehr.</p>
<div className="text-left space-y-2">
<p className="text-xs font-medium text-gray-700 mb-2">Beispielfragen:</p>
{exampleQuestions.map((question, idx) => (
<button
key={idx}
onClick={() => onExampleClick(question)}
className="w-full text-left px-3 py-2 text-xs bg-white hover:bg-purple-50 border border-gray-200 rounded-lg transition-colors text-gray-700"
>
{question}
</button>
))}
</div>
</div>
)
}
// =============================================================================
// MessageList — renders messages + typing indicator
// =============================================================================
interface MessageListProps {
messages: Message[]
isTyping: boolean
messagesEndRef: React.RefObject<HTMLDivElement | null>
}
export function AdvisorMessageList({ messages, isTyping, messagesEndRef }: MessageListProps) {
return (
<>
{messages.map((message) => (
<div key={message.id} className={`flex ${message.role === 'user' ? 'justify-end' : 'justify-start'}`}>
<div className={`max-w-[80%] rounded-lg px-3 py-2 ${message.role === 'user' ? 'bg-indigo-600 text-white' : 'bg-white border border-gray-200 text-gray-800'}`}>
<p className={`text-sm ${message.role === 'agent' ? 'whitespace-pre-wrap' : ''}`}>
{message.content || (message.role === 'agent' && isTyping ? '' : message.content)}
</p>
<p className={`text-xs mt-1 ${message.role === 'user' ? 'text-indigo-200' : 'text-gray-400'}`}>
{message.timestamp.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}
</p>
</div>
</div>
))}
{isTyping && (
<div className="flex justify-start">
<div className="bg-white border border-gray-200 rounded-lg px-3 py-2">
<div className="flex space-x-1">
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" />
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '0.1s' }} />
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '0.2s' }} />
</div>
</div>
</div>
)}
<div ref={messagesEndRef as React.RefObject<HTMLDivElement>} />
</>
)
}
@@ -1,198 +1,47 @@
'use client'
import { useState, useEffect, useRef, useCallback } from 'react'
import { EXAMPLE_QUESTIONS, AdvisorEmptyState, AdvisorMessageList, type Message } from './ComplianceAdvisorParts'
// =============================================================================
// TYPES
// =============================================================================
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 { useAdvisorCase } from './advisor/useAdvisorCase'
import { useAdvisorEmail } from './advisor/useAdvisorEmail'
interface ComplianceAdvisorWidgetProps {
currentStep?: string
}
type Country = 'DE' | 'AT' | 'CH' | 'EU'
const COUNTRIES: Country[] = ['DE', 'AT', 'CH', 'EU']
const COUNTRIES: { code: Country; label: string }[] = [
{ code: 'DE', label: 'DE' },
{ code: 'AT', label: 'AT' },
{ code: 'CH', label: 'CH' },
{ code: 'EU', label: 'EU' },
]
// =============================================================================
// COMPONENT
// =============================================================================
/**
* 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)
const [isExpanded, setIsExpanded] = useState(false)
const [messages, setMessages] = useState<Message[]>([])
const [inputValue, setInputValue] = useState('')
const [isTyping, setIsTyping] = useState(false)
const [selectedCountry, setSelectedCountry] = useState<Country>('DE')
const messagesEndRef = useRef<HTMLDivElement>(null)
const abortControllerRef = useRef<AbortController | null>(null)
const [country, setCountry] = useState<Country>('DE')
const { cases, busy, ask, selectContext, stop } = useAdvisorCase({ currentStep, country })
const email = useAdvisorEmail(cases, country, currentStep)
const exampleQuestions = EXAMPLE_QUESTIONS[currentStep] || EXAMPLE_QUESTIONS.default
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages])
useEffect(() => {
return () => {
abortControllerRef.current?.abort()
}
}, [])
const handleSendMessage = useCallback(
async (content: string) => {
if (!content.trim() || isTyping) return
const userMessage: Message = {
id: `msg-${Date.now()}`,
role: 'user',
content: content.trim(),
timestamp: new Date(),
}
setMessages((prev) => [...prev, userMessage])
const submit = useCallback(
(q: string) => {
if (!q.trim() || busy) return
setInputValue('')
setIsTyping(true)
const agentMessageId = `msg-${Date.now()}-agent`
abortControllerRef.current = new AbortController()
try {
const history = messages.map((m) => ({
role: m.role === 'user' ? 'user' : 'assistant',
content: m.content,
}))
const response = await fetch('/api/sdk/compliance-advisor/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message: content.trim(),
history,
currentStep,
country: selectedCountry,
}),
signal: abortControllerRef.current.signal,
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({ error: 'Unbekannter Fehler' }))
throw new Error(errorData.error || `Server-Fehler (${response.status})`)
}
setMessages((prev) => [
...prev,
{ id: agentMessageId, role: 'agent', content: '', timestamp: new Date() },
])
const reader = response.body!.getReader()
const decoder = new TextDecoder()
let accumulated = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
accumulated += decoder.decode(value, { stream: true })
const currentText = accumulated
setMessages((prev) =>
prev.map((m) => (m.id === agentMessageId ? { ...m, content: currentText } : m))
)
requestAnimationFrame(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
})
}
setIsTyping(false)
} catch (error) {
if ((error as Error).name === 'AbortError') {
setIsTyping(false)
return
}
const errorMessage = error instanceof Error ? error.message : 'Verbindung fehlgeschlagen'
setMessages((prev) => {
const hasAgent = prev.some((m) => m.id === agentMessageId)
if (hasAgent) {
return prev.map((m) =>
m.id === agentMessageId ? { ...m, content: `Fehler: ${errorMessage}` } : m
)
}
return [
...prev,
{ id: agentMessageId, role: 'agent' as const, content: `Fehler: ${errorMessage}`, timestamp: new Date() },
]
})
setIsTyping(false)
}
ask(q)
},
[isTyping, messages, currentStep, selectedCountry]
[busy, ask],
)
const handleStopGeneration = useCallback(() => {
abortControllerRef.current?.abort()
setIsTyping(false)
}, [])
const [emailSending, setEmailSending] = useState(false)
const [emailSent, setEmailSent] = useState(false)
const handleSendAsEmail = useCallback(async () => {
if (messages.length === 0 || emailSending) return
setEmailSending(true)
try {
// Build HTML from chat messages
const qaPairs = messages.reduce<{ q: string; a: string }[]>((acc, m, i) => {
if (m.role === 'user') {
const next = messages[i + 1]
acc.push({ q: m.content, a: next?.role === 'agent' ? next.content : '(keine Antwort)' })
}
return acc
}, [])
const qaHtml = qaPairs.map(({ q, a }) =>
`<div style="margin-bottom:16px;"><p style="font-weight:600;color:#1e293b;">Frage: ${q}</p><p style="color:#475569;white-space:pre-wrap;">${a}</p></div>`
).join('')
const bodyHtml = `
<h2 style="color:#1e293b;">Compliance Advisor — Beratungsprotokoll</h2>
<p style="color:#64748b;font-size:13px;">Datum: ${new Date().toLocaleString('de-DE')} | Land: ${selectedCountry} | Kontext: ${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 (Qwen)</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 — ${qaPairs.length} Fragen (${currentStep})`,
body_html: bodyHtml,
role: 'Datenschutzbeauftragter',
}),
})
setEmailSent(true)
setTimeout(() => setEmailSent(false), 3000)
} catch (e) {
console.error('Email send failed:', e)
} finally {
setEmailSending(false)
}
}, [messages, emailSending, selectedCountry, currentStep])
const handleKeyDown = (e: React.KeyboardEvent) => {
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSendMessage(inputValue)
submit(inputValue)
}
}
@@ -200,135 +49,102 @@ export function ComplianceAdvisorWidget({ currentStep = 'default' }: ComplianceA
return (
<button
onClick={() => setIsOpen(true)}
className="fixed bottom-6 right-[5.5rem] w-14 h-14 bg-indigo-600 hover:bg-indigo-700 text-white rounded-full shadow-lg flex items-center justify-center transition-all duration-200 hover:scale-110 z-50"
className="fixed bottom-6 right-[5.5rem] z-50 flex h-14 w-14 items-center justify-center rounded-full bg-indigo-600 text-white shadow-lg transition-all duration-200 hover:scale-110 hover:bg-indigo-700"
aria-label="Compliance Advisor oeffnen"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
</svg>
<MessagesSquare className="h-6 w-6" />
</button>
)
}
return (
<div className={`fixed bottom-6 right-6 ${isExpanded ? 'w-[700px] h-[80vh]' : 'w-[400px] h-[500px]'} max-h-screen bg-white rounded-2xl shadow-2xl flex flex-col z-50 border border-gray-200 transition-all duration-200`}>
{/* Header */}
<div className="bg-gradient-to-r from-purple-600 to-indigo-600 text-white px-4 py-3 rounded-t-2xl flex items-center justify-between">
<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 ${
isExpanded ? 'h-[85vh] w-[960px]' : 'h-[560px] w-[420px]'
}`}
>
<div className="flex items-center justify-between rounded-t-2xl bg-gradient-to-r from-purple-600 to-indigo-600 px-4 py-3 text-white">
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-white/20 rounded-full flex items-center justify-center">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-white/20">
<MessagesSquare className="h-5 w-5" />
</div>
<div>
<div className="font-semibold text-sm">Compliance Advisor</div>
<div className="flex items-center gap-1 mt-0.5">
{COUNTRIES.map(({ code, label }) => (
<div className="text-sm font-semibold">Compliance Advisor</div>
<div className="mt-0.5 flex items-center gap-1">
{COUNTRIES.map((c) => (
<button
key={code}
onClick={() => setSelectedCountry(code)}
className={`px-1.5 py-0.5 text-[10px] font-medium rounded transition-colors ${selectedCountry === code ? 'bg-white text-indigo-700' : 'bg-white/15 text-white/80 hover:bg-white/25'}`}
key={c}
onClick={() => setCountry(c)}
className={`rounded px-1.5 py-0.5 text-[10px] font-medium transition-colors ${
country === c ? 'bg-white text-indigo-700' : 'bg-white/15 text-white/80 hover:bg-white/25'
}`}
>
{label}
{c}
</button>
))}
</div>
</div>
</div>
<div className="flex items-center gap-1">
{/* Send as Email */}
{messages.length > 0 && (
{cases.length > 0 && (
<button
onClick={handleSendAsEmail}
disabled={emailSending}
className={`text-white/80 hover:text-white transition-colors ${emailSent ? 'text-green-300' : ''}`}
onClick={email.send}
disabled={email.sending}
className={`text-white/80 transition-colors hover:text-white ${email.sent ? 'text-green-300' : ''}`}
title={email.sent ? 'Email gesendet!' : 'Beratungsprotokoll als Email senden'}
aria-label="Als Email an DSB senden"
title={emailSent ? 'Email gesendet!' : 'Beratungsprotokoll als Email senden'}
>
{emailSent ? (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
) : emailSending ? (
<svg className="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
) : (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
)}
{email.sent ? <Check className="h-5 w-5" /> : email.sending ? <Loader2 className="h-5 w-5 animate-spin" /> : <Mail className="h-5 w-5" />}
</button>
)}
<button
onClick={() => setIsExpanded(!isExpanded)}
className="text-white/80 hover:text-white transition-colors"
onClick={() => setIsExpanded((v) => !v)}
className="text-white/80 transition-colors hover:text-white"
aria-label={isExpanded ? 'Verkleinern' : 'Vergroessern'}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{isExpanded ? (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 9L4 4m0 0v4m0-4h4m6 6l5 5m0 0v-4m0 4h-4" />
) : (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5v-4m0 4h-4m4 0l-5-5" />
)}
</svg>
{isExpanded ? <Minimize2 className="h-5 w-5" /> : <Maximize2 className="h-5 w-5" />}
</button>
<button onClick={() => setIsOpen(false)} className="text-white/80 hover:text-white transition-colors" aria-label="Schliessen">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
<button
onClick={() => setIsOpen(false)}
className="text-white/80 transition-colors hover:text-white"
aria-label="Schliessen"
>
<X className="h-5 w-5" />
</button>
</div>
</div>
{/* Messages Area */}
<div className="flex-1 overflow-y-auto p-4 space-y-4 bg-gray-50">
{messages.length === 0 ? (
<AdvisorEmptyState
exampleQuestions={exampleQuestions}
onExampleClick={(q) => handleSendMessage(q)}
/>
) : (
<AdvisorMessageList
messages={messages}
isTyping={isTyping}
messagesEndRef={messagesEndRef}
/>
)}
</div>
<EvidenceWorkspace
cases={cases}
expanded={isExpanded}
busy={busy}
exampleQuestions={exampleQuestions}
onExample={submit}
onSelectContext={selectContext}
/>
{/* Input Area */}
<div className="border-t border-gray-200 p-3 bg-white rounded-b-2xl">
<div className="rounded-b-2xl border-t border-gray-200 bg-white p-3">
<div className="flex gap-2">
<input
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
onKeyDown={onKeyDown}
placeholder="Frage eingeben..."
disabled={isTyping}
className="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent disabled:opacity-50"
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"
/>
{isTyping ? (
<button
onClick={handleStopGeneration}
className="px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors"
title="Generierung stoppen"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 6h12v12H6z" />
</svg>
{busy ? (
<button onClick={stop} className="rounded-lg bg-red-500 px-4 py-2 text-white transition-colors hover:bg-red-600" title="Abbrechen">
<Square className="h-5 w-5" />
</button>
) : (
<button
onClick={() => handleSendMessage(inputValue)}
onClick={() => submit(inputValue)}
disabled={!inputValue.trim()}
className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
className="rounded-lg bg-indigo-600 px-4 py-2 text-white transition-colors hover:bg-indigo-700 disabled:cursor-not-allowed disabled:opacity-50"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
</svg>
<Send className="h-5 w-5" />
</button>
)}
</div>
@@ -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(
<CaseView c={mk(clarify)} busy={false} onSelectContext={onSel} />,
)
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(<CaseView c={mk(answer)} busy={false} onSelectContext={() => {}} />)
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')
})
})
@@ -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 (
<div className="flex space-x-1 px-1 py-2" aria-label="Antwort wird erstellt">
<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>
)
}
export function ErrorBox({ msg }: { msg?: string }) {
return (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{msg || 'Verbindung fehlgeschlagen'}
</div>
)
}
/** 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 (
<div className="space-y-3">
<EvidenceSummary response={response} />
<div className="rounded-lg border border-gray-200 bg-white px-3 py-2">
<Markdown content={response.answer || ''} citations={cite} />
</div>
<EvidencePane evidence={response.evidence} highlightedId={highlightedId} />
<VisualEvidencePane items={response.visual_evidence} />
<FootnotesPane footnotes={response.footnotes} />
</div>
)
}
/** 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 (
<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> {c.question}
</div>
)}
{c.status === 'loading' && <LoadingDots />}
{c.status === 'error' && <ErrorBox msg={c.error} />}
{r && r.mode === 'clarify' && (
<ClarifyView response={r} onSelectContext={onSelectContext} busy={busy} />
)}
{r && r.mode === 'answer' && <AnswerBody response={r} />}
</div>
)
}
@@ -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 (
<div className="space-y-3">
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2">
<div className="mb-1 flex items-center gap-1 text-[11px] font-semibold text-amber-700">
<Info className="h-3.5 w-3.5" />
Allgemeine Definition (ohne Rechtsquelle)
</div>
<Markdown content={response.general_answer || ''} />
</div>
{chips.length > 0 && (
<div>
<div className="mb-1.5 text-xs font-medium text-gray-700">
Meintest du einen bestimmten Kontext?
</div>
<div className="flex flex-wrap gap-1.5">
{chips.map((c) => (
<button
key={c.id}
type="button"
disabled={busy}
onClick={() => onSelectContext(c.id)}
className="rounded-full border border-indigo-200 bg-white px-3 py-1 text-xs font-medium text-indigo-700 transition-colors hover:bg-indigo-50 disabled:opacity-50"
>
{c.label}
</button>
))}
</div>
</div>
)}
</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,76 @@
'use client'
import { useState } from 'react'
import { ChevronDown, ChevronRight, Library } from 'lucide-react'
import type { EvidenceUnit } from '@/lib/sdk/advisor/contract'
import { resolveRegulation } from '@/lib/sdk/advisor/regulation-display'
import { EvidenceUnitCard } from './EvidenceUnitCard'
import { PaneHeader } from './PaneHeader'
interface Group {
key: string
label: string
units: EvidenceUnit[]
}
function groupByFamily(units: EvidenceUnit[]): Group[] {
const map = new Map<string, Group>()
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)
}
return [...map.values()].sort((a, b) => b.units.length - a.units.length)
}
function EvidenceGroup({ group, highlightedId }: { group: Group; highlightedId?: string }) {
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) => (
<EvidenceUnitCard key={u.evidence_id} unit={u} compact highlighted={u.evidence_id === highlightedId} />
))}
</div>
)}
</div>
)
}
/** Evidence pane — units grouped by document/regulation family, count + expandable. */
export function EvidencePane({
evidence,
highlightedId,
}: {
evidence: EvidenceUnit[]
highlightedId?: string
}) {
const groups = groupByFamily(evidence)
return (
<section>
<PaneHeader icon={<Library className="h-3.5 w-3.5 text-gray-500" />} title="Evidence" count={evidence.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} highlightedId={highlightedId} />
))}
</div>
)}
</section>
)
}
@@ -0,0 +1,135 @@
'use client'
import { BookMarked, FileText, Hash, Image as ImageIcon, Library, Scale } from 'lucide-react'
import type { AdvisorResponse } from '@/lib/sdk/advisor/contract'
import {
provisionSummary,
summarizeEvidence,
type FamilyGroup,
} from '@/lib/sdk/advisor/evidence-grouping'
const plural = (n: number, one: string, many: string) => (n === 1 ? one : many)
function Count({
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>
)
}
function GroupRow({ group, icon }: { group: FamilyGroup; icon: React.ReactNode }) {
// A single-unit guidance doc needs no "1 Fundstelle" noise; norms always show their provisions.
const detail = group.sections.length === 0 && group.units <= 1 ? '' : provisionSummary(group)
return (
<div className="flex items-start gap-2 py-0.5">
<span className="mt-0.5 shrink-0 text-gray-400">{icon}</span>
<span className="min-w-0 flex-1 text-[12px] leading-snug text-gray-700">{group.label}</span>
{detail && <span className="shrink-0 text-[11px] font-medium text-gray-500">{detail}</span>}
</div>
)
}
function Section({
title,
groups,
icon,
}: {
title: string
groups: FamilyGroup[]
icon: React.ReactNode
}) {
if (groups.length === 0) return null
return (
<div className="mt-2 first:mt-0">
<div className="mb-0.5 text-[10px] font-semibold uppercase tracking-wide text-gray-400">{title}</div>
{groups.map((g) => (
<GroupRow key={g.key} group={g} icon={icon} />
))}
</div>
)
}
/**
* "Diese Antwort stützt sich auf" — describes the EVIDENCE (not the documents), objective counts
* only (no fabricated trust score). When the Legal-KG ships `bindingness`, binding Rechtsgrundlagen
* are split from Leitlinien (soft-law guidance); until then it shows a neutral evidence breakdown.
*/
export function EvidenceSummary({ response }: { response: AdvisorResponse }) {
const m = summarizeEvidence(response.evidence)
const figures = response.visual_evidence.length
const notes = response.footnotes.length
const cls = 'h-4 w-4'
const smallIcon = 'h-3.5 w-3.5'
return (
<div>
<div className="mb-1.5 text-[10px] font-semibold uppercase tracking-wide text-gray-400">
Diese Antwort stützt sich auf
</div>
<div className="grid grid-cols-2 gap-1.5">
{m.hasBindingness && (
<>
<Count
icon={<Scale className={cls} />}
value={m.normProvisions}
label={plural(m.normProvisions, 'Rechtsgrundlage', 'Rechtsgrundlagen')}
/>
<Count
icon={<BookMarked className={cls} />}
value={m.guidanceCount}
label={plural(m.guidanceCount, 'Leitlinie', 'Leitlinien')}
dim={m.guidanceCount === 0}
/>
</>
)}
<Count
icon={<ImageIcon className={cls} />}
value={figures}
label={plural(figures, 'Abbildung', 'Abbildungen')}
dim={figures === 0}
/>
<Count
icon={<Hash className={cls} />}
value={notes}
label={plural(notes, 'Fußnote', 'Fußnoten')}
dim={notes === 0}
/>
<Count icon={<FileText className={cls} />} value={m.unitCount} label="Evidence Units" />
</div>
{m.groups.length > 0 && (
<div className="mt-2.5 rounded-lg border border-gray-100 bg-gray-50/60 px-2.5 py-1.5">
{m.hasBindingness ? (
<>
<Section title="Rechtsgrundlagen" groups={m.norms} icon={<Scale className={smallIcon} />} />
<Section title="Leitlinien" groups={m.guidance} icon={<BookMarked className={smallIcon} />} />
<Section title="Weitere" groups={m.other} icon={<FileText className={smallIcon} />} />
</>
) : (
m.groups.map((g) => <GroupRow key={g.key} group={g} icon={<Library className={smallIcon} />} />)
)}
</div>
)}
</div>
)
}
@@ -0,0 +1,80 @@
'use client'
import { useState } from 'react'
import { ChevronDown, ChevronRight, ExternalLink } from 'lucide-react'
import type { EvidenceUnit } from '@/lib/sdk/advisor/contract'
import { resolveRegulation } from '@/lib/sdk/advisor/regulation-display'
/** 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({ 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)
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 (
<div
id={`ev-${unit.evidence_id}`}
className={`${
compact
? 'rounded-md border border-gray-100 bg-gray-50 p-2'
: 'rounded-lg border border-gray-200 bg-white p-2.5'
} ${highlighted ? 'ring-2 ring-indigo-400' : ''}`}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="truncate text-xs font-semibold text-gray-900">{header}</div>
{sub.length > 0 && (
<div className="mt-0.5 flex flex-wrap items-center gap-x-1 text-[11px] text-gray-500">
{sub.map((c, i) => (
<span key={i} className="flex items-center gap-1">
{i > 0 && <span className="text-gray-300"></span>}
{c}
</span>
))}
</div>
)}
</div>
{canOpen && (
<a
href={unit.url}
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,136 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import type { AdvisorCase } from './useAdvisorCase'
import { StickyQuestion } from './StickyQuestion'
import { AdvisorEmptyState } from './EmptyState'
import { CaseView, LoadingDots, ErrorBox } from './CaseView'
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'
/**
* Advisor body as a series of CASES.
* - Narrow: stacked cases with a pinned last question.
* - Wide: 3-column Case Workspace — question+summary (left) | answer/clarify (center) | evidence (right).
*/
export function EvidenceWorkspace({
cases,
expanded,
busy,
exampleQuestions,
onExample,
onSelectContext,
}: {
cases: AdvisorCase[]
expanded: boolean
busy: boolean
exampleQuestions: string[]
onExample: (q: string) => void
onSelectContext: (caseId: string, ctx: string) => void
}) {
const [activeId, setActiveId] = useState<string | null>(null)
const endRef = useRef<HTMLDivElement>(null)
const latest = cases[cases.length - 1]
const active = cases.find((c) => c.id === activeId) ?? latest
useEffect(() => {
setActiveId(null)
}, [cases.length])
useEffect(() => {
if (!expanded) endRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [cases.length, expanded])
const answer = active?.response?.mode === 'answer' ? active.response : null
const { highlightedId, cite } = useCitationHighlight(answer?.citations ?? [])
if (cases.length === 0) {
return (
<div className="flex-1 overflow-y-auto bg-gray-50">
<AdvisorEmptyState exampleQuestions={exampleQuestions} onExampleClick={onExample} />
</div>
)
}
if (!expanded) {
return (
<div className="min-h-0 flex-1 overflow-y-auto bg-gray-50">
{latest && <StickyQuestion question={latest.question} />}
<div className="space-y-4 p-4">
{cases.map((c, i) => (
<CaseView
key={c.id}
c={c}
busy={busy}
showQuestion={i !== cases.length - 1}
onSelectContext={(ctx) => onSelectContext(c.id, ctx)}
/>
))}
<div ref={endRef} />
</div>
</div>
)
}
const r = active?.response
return (
<div className="grid min-h-0 flex-1 grid-cols-[220px_1fr_320px] divide-x divide-gray-200 overflow-hidden">
<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>
{answer && <EvidenceSummary response={answer} />}
{cases.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">
{cases.map((c) => (
<button
key={c.id}
onClick={() => setActiveId(c.id)}
className={`block w-full truncate rounded px-2 py-1 text-left text-[11px] ${
c.id === active?.id ? 'bg-indigo-100 text-indigo-800' : 'text-gray-600 hover:bg-gray-100'
}`}
>
{c.question}
</button>
))}
</div>
</div>
)}
</aside>
<main className="min-h-0 overflow-y-auto bg-gray-50 p-4">
{active?.status === 'loading' && <LoadingDots />}
{active?.status === 'error' && <ErrorBox msg={active.error} />}
{r?.mode === 'clarify' && (
<ClarifyView
response={r}
busy={busy}
onSelectContext={(ctx) => active && onSelectContext(active.id, ctx)}
/>
)}
{r?.mode === 'answer' && (
<div className="rounded-lg border border-gray-200 bg-white px-3 py-2">
<Markdown content={r.answer || ''} citations={cite} />
</div>
)}
</main>
<aside className="min-h-0 space-y-3 overflow-y-auto bg-gray-50 p-3">
{answer ? (
<>
<EvidencePane evidence={answer.evidence} highlightedId={highlightedId} />
<VisualEvidencePane items={answer.visual_evidence} />
<FootnotesPane footnotes={answer.footnotes} />
</>
) : (
<p className="px-1 text-[11px] text-gray-400">Evidence erscheint nach Auswahl eines Kontexts.</p>
)}
</aside>
</div>
)
}
@@ -0,0 +1,30 @@
'use client'
import { Hash } from 'lucide-react'
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: Footnote[] }) {
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, i) => (
<div key={fn.footnote_id || i} className="rounded-md border border-gray-200 bg-white p-2 text-[11px]">
<span className="font-semibold text-gray-900">{fn.ref || `Fußnote ${i + 1}`}</span>
{(fn.document || fn.section) && (
<span className="text-gray-400">
{' · '}
{fn.document}
{fn.section ? ` / ${fn.section}` : ''}
</span>
)}
{fn.text && <p className="mt-0.5 text-gray-600">{fn.text}</p>}
</div>
))}
</div>
</section>
)
}
@@ -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,176 @@
'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.
// Plus deliberate [n] citation markers (mapped via `citations`, NOT parsed for structure).
export interface CiteHandler {
count: number
onSelect: (n: number) => void
}
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
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 if (/^\[\d+\]$/.test(tok)) {
const n = parseInt(tok.slice(1, -1), 10)
if (cite && n >= 1 && n <= cite.count) {
nodes.push(
<button
key={key}
type="button"
onClick={() => cite.onSelect(n)}
className="mx-0.5 align-super text-[10px] font-semibold text-indigo-600 hover:underline"
title={`Beleg ${n} anzeigen`}
>
[{n}]
</button>,
)
} else {
nodes.push(tok)
}
} 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, cite }: { level: number; kp: string; text: string; cite?: CiteHandler }) {
const children = renderInline(text, kp, cite)
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, 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}`
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]} cite={citations} />)
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}`, citations)}</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}`, citations)}</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, citations)}
</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,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,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 (
<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="text-xs font-semibold text-gray-900">{v.caption || v.visual_type}</div>
<div className="mt-0.5 flex flex-wrap items-center gap-1 text-[11px] text-gray-500">
<span className="rounded bg-gray-100 px-1 text-[10px] uppercase tracking-wide text-gray-500">
{v.visual_type}
</span>
<span>Quelle: {v.document}</span>
</div>
</div>
{canOpen && (
<a
href={v.image_ref}
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>
{canOpen ? (
<a href={v.image_ref} target="_blank" rel="noopener noreferrer" className="mt-1.5 block">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={v.image_ref}
alt={v.caption || v.visual_type}
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-Darstellung folgt
</div>
)}
{v.vision_summary && <p className="mt-1.5 text-[11px] italic text-gray-500">{v.vision_summary}</p>}
</div>
)
}
/** Visual evidence (C8) — diagrams/figures, rendered only when present. */
export function VisualEvidencePane({ items }: { items: VisualEvidence[] }) {
if (items.length === 0) return null
return (
<section>
<PaneHeader
icon={<ImageIcon className="h-3.5 w-3.5 text-gray-500" />}
title="Diagramme & Abbildungen"
count={items.length}
/>
<div className="space-y-1.5">
{items.map((v) => (
<VisualCard key={v.visual_id} v={v} />
))}
</div>
</section>
)
}
@@ -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<AdvisorCase[]>([])
const [busy, setBusy] = useState(false)
const abortRef = useRef<AbortController | null>(null)
const patch = useCallback((id: string, p: Partial<AdvisorCase>) => {
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 }
}
@@ -0,0 +1,72 @@
'use client'
import { useCallback, useState } from 'react'
import type { AdvisorCase } from './useAdvisorCase'
function esc(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
function evidenceHtml(c: AdvisorCase): string {
const ev = c.response?.evidence ?? []
if (ev.length === 0) return ''
const items = ev
.map(
(e) =>
`<li>${esc(e.document)}${e.section ? `${esc(e.section)}` : ''}${e.paragraph ? ` ${esc(e.paragraph)}` : ''}</li>`,
)
.join('')
return `<p style="color:#64748b;font-size:12px;margin:4px 0 0;">Evidence:</p><ul style="color:#64748b;font-size:12px;margin:2px 0;">${items}</ul>`
}
/** 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 (cases.length === 0 || sending) return
setSending(true)
try {
const qaHtml = cases
.map((c) => {
const a = c.response?.answer || c.response?.general_answer || '(keine Antwort)'
return `<div style="margin-bottom:16px;"><p style="font-weight:600;color:#1e293b;">Frage: ${esc(
c.question,
)}</p><p style="color:#475569;white-space:pre-wrap;">${esc(a)}</p>${evidenceHtml(c)}</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 — ${cases.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)
}
}, [cases, sending, country, currentStep])
return { send, sending, sent }
}
@@ -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-<evidence_id>).
*/
export function useCitationHighlight(citations: Citation[]): {
highlightedId?: string
cite?: CiteHandler
} {
const [highlightedId, setHighlightedId] = useState<string | undefined>()
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',
})
}
},
},
}
}
@@ -0,0 +1,102 @@
/**
* E2E: Compliance Advisor — Clarity Gate (v3 contract)
*
* Drives the floating advisor widget end-to-end against a stubbed /api/sdk/compliance-advisor/chat
* (contract fixtures), so the whole FE chain is exercised without the RAG/LLM backend:
* - underspecified question -> clarify mode (L1 general answer + domain context chips)
* - specific question -> answer mode (markdown + [n] citation coupling + evidence pane)
* - clarify -> pick a context -> scoped answer
* Runs on CI / macmini (needs the Next app on :3002).
*/
import { test, expect } from '../fixtures/sdk-fixtures'
const CHAT_ROUTE = '**/api/sdk/compliance-advisor/chat'
const openAdvisor = 'Compliance Advisor oeffnen'
const inputPlaceholder = 'Frage eingeben...'
const CLARIFY = {
mode: 'clarify',
question: 'Was ist PDCA?',
clarity: {
is_underspecified: true,
concentration: 0.3,
suggested_contexts: [
{ id: 'datenschutz', label: 'Datenschutz' },
{ id: 'cyber', label: 'Cybersecurity' },
],
},
general_answer: 'PDCA steht für **Plan-Do-Check-Act**.',
answer: null,
evidence: [],
citations: [],
visual_evidence: [],
footnotes: [],
}
const ANSWER = {
mode: 'answer',
question: 'CRA Meldefrist',
clarity: { is_underspecified: false, dominant_context: 'cyber', concentration: 0.88 },
answer: 'Die Meldung erfolgt unverzüglich [1].',
evidence: [
{ evidence_id: 'e1', document: 'CRA', section: 'Art. 14', paragraph: 'Abs. 1', snippet: 'unverzüglich melden', bindingness: 'binding' },
],
citations: [
{ citation_id: 'c1', number: 1, evidence_id: 'e1', document: 'CRA', section: 'Art. 14', paragraph: 'Abs. 1' },
],
visual_evidence: [],
footnotes: [],
}
async function ask(page: import('@playwright/test').Page, question: string) {
await page.getByRole('button', { name: openAdvisor }).click()
const input = page.getByPlaceholder(inputPlaceholder)
await input.fill(question)
await input.press('Enter')
}
test.describe('Compliance Advisor — Clarity Gate', () => {
test('underspecified question -> clarify (L1 definition + context chips, no evidence)', async ({ sdkPage }) => {
await sdkPage.route(CHAT_ROUTE, (r) =>
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(CLARIFY) }),
)
await ask(sdkPage, 'Was ist PDCA?')
await expect(sdkPage.getByText('Allgemeine Definition')).toBeVisible()
await expect(sdkPage.getByText('Plan-Do-Check-Act')).toBeVisible()
await expect(sdkPage.getByRole('button', { name: 'Datenschutz' })).toBeVisible()
await expect(sdkPage.getByRole('button', { name: 'Cybersecurity' })).toBeVisible()
})
test('specific question -> answer with [n] citation + evidence pane', async ({ sdkPage }) => {
await sdkPage.route(CHAT_ROUTE, (r) =>
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(ANSWER) }),
)
await ask(sdkPage, 'CRA Meldefrist')
await expect(sdkPage.getByText(/unverzüglich/)).toBeVisible()
await expect(sdkPage.getByTitle('Beleg 1 anzeigen')).toBeVisible()
// bindingness present -> header splits into Rechtsgrundlagen vs Leitlinien (evidence framing)
await expect(sdkPage.getByText('Rechtsgrundlagen').first()).toBeVisible()
// family name resolved for the user (shown both in the summary breakdown and the evidence card)
await expect(sdkPage.getByText('Cyber Resilience Act (CRA)').first()).toBeVisible()
})
test('clarify -> pick a context -> scoped answer', async ({ sdkPage }) => {
let calls = 0
await sdkPage.route(CHAT_ROUTE, (r) => {
calls += 1
r.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(calls === 1 ? CLARIFY : ANSWER),
})
})
await ask(sdkPage, 'Was ist PDCA?')
await sdkPage.getByRole('button', { name: 'Datenschutz' }).click()
await expect(sdkPage.getByText(/unverzüglich/)).toBeVisible()
await expect(sdkPage.getByTitle('Beleg 1 anzeigen')).toBeVisible()
})
})
@@ -0,0 +1,85 @@
import { describe, it, expect } from 'vitest'
import {
groupByFamily,
provisionSummary,
summarizeEvidence,
type FamilyGroup,
} from '../advisor/evidence-grouping'
import type { EvidenceUnit } from '../advisor/contract'
function u(p: Partial<EvidenceUnit> & { document: string }): EvidenceUnit {
return { evidence_id: Math.random().toString(36).slice(2), ...p }
}
// The Datenschutzerklärung scenario the user reviewed: 6 Kernnormen (5 DSGVO Artikel + § 25 TDDDG)
// + 2 Leitlinien (DSK, EDPB) across 8 evidence units.
const DSE: EvidenceUnit[] = [
u({ document: 'DSGVO', section: 'Art. 6', bindingness: 'binding' }),
u({ document: 'DSGVO', section: 'Art. 7', bindingness: 'binding' }),
u({ document: 'DSGVO', section: 'Art. 12', bindingness: 'binding' }),
u({ document: 'DSGVO', section: 'Art. 13', bindingness: 'binding' }),
u({ document: 'DSGVO', section: 'Art. 14', bindingness: 'binding' }),
u({ document: 'TDDDG', section: '§ 25', bindingness: 'binding' }),
u({ document: 'DSK', bindingness: 'guidance' }),
u({ document: 'EDPB WP 259', bindingness: 'guidance' }),
]
describe('groupByFamily', () => {
it('groups a family and collects distinct provisions in order', () => {
const groups = groupByFamily(DSE)
const dsgvo = groups.find((g) => g.key === 'dsgvo')!
expect(dsgvo.units).toBe(5)
expect(dsgvo.sections).toEqual(['Art. 6', 'Art. 7', 'Art. 12', 'Art. 13', 'Art. 14'])
expect(dsgvo.bindingness).toBe('binding')
})
it('does not duplicate a repeated section', () => {
const groups = groupByFamily([
u({ document: 'DSGVO', section: 'Art. 13', bindingness: 'binding' }),
u({ document: 'DSGVO', section: 'Art. 13', bindingness: 'binding' }),
])
expect(groups[0].sections).toEqual(['Art. 13'])
expect(groups[0].units).toBe(2)
})
})
describe('summarizeEvidence', () => {
it('splits binding norms from guidance with correct counts', () => {
const m = summarizeEvidence(DSE)
expect(m.hasBindingness).toBe(true)
expect(m.normProvisions).toBe(6) // 5 DSGVO Artikel + § 25 TDDDG
expect(m.guidanceCount).toBe(2) // DSK + EDPB
expect(m.unitCount).toBe(8)
expect(m.norms.map((g) => g.key).sort()).toEqual(['dsgvo', 'tddg'])
})
it('degrades to a neutral breakdown when bindingness is absent', () => {
const m = summarizeEvidence([
u({ document: 'DSGVO', section: 'Art. 30' }),
u({ document: 'CRA', section: 'Art. 14' }),
])
expect(m.hasBindingness).toBe(false)
expect(m.groups).toHaveLength(2)
expect(m.normProvisions).toBe(0)
expect(m.guidanceCount).toBe(0)
})
})
describe('provisionSummary', () => {
const g = (sections: string[], units = sections.length): FamilyGroup => ({
key: 'k',
label: 'L',
sections,
units,
bindingness: 'binding',
})
it('names Artikel, §§, single provisions and bare units', () => {
expect(provisionSummary(g(['Art. 6', 'Art. 7', 'Art. 13']))).toBe('3 Artikel')
expect(provisionSummary(g(['§ 25']))).toBe('§ 25')
expect(provisionSummary(g(['§ 25', '§ 26']))).toBe('2 §§')
expect(provisionSummary(g(['Art. 13', '§ 25', 'Anhang I']))).toBe('3 Fundstellen')
expect(provisionSummary(g([], 3))).toBe('3 Fundstellen')
expect(provisionSummary(g([], 1))).toBe('1 Fundstelle')
})
})
@@ -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,81 @@
import { describe, it, expect } from 'vitest'
import {
resolveMode,
mapClarity,
mapFootnotes,
buildCitations,
numberedEvidenceForPrompt,
isLegacyRequest,
} from '../advisor/retrieve-mapping'
import type { EvidenceUnit } from '../advisor/contract'
describe('resolveMode', () => {
it('a chosen context always forces answer', () => expect(resolveMode('clarify', true)).toBe('answer'))
it('clarify + no context -> clarify', () => expect(resolveMode('clarify', false)).toBe('clarify'))
it('answer -> answer', () => expect(resolveMode('answer', false)).toBe('answer'))
it('unknown/undefined -> answer', () => expect(resolveMode(undefined, false)).toBe('answer'))
})
describe('mapClarity', () => {
it('clarify maps candidate_contexts -> suggested_contexts', () => {
const c = mapClarity(
{ mode: 'clarify', concentration: 0.3, candidate_contexts: [{ id: 'ds', label: 'Datenschutz', hits: 5 }] },
'clarify',
)
expect(c.is_underspecified).toBe(true)
expect(c.suggested_contexts).toEqual([{ id: 'ds', label: 'Datenschutz' }])
})
it('answer keeps dominant_context, drops suggestions', () => {
const c = mapClarity({ mode: 'answer', concentration: 0.88, dominant_context: 'ds' }, 'answer')
expect(c.is_underspecified).toBe(false)
expect(c.dominant_context).toBe('ds')
expect(c.suggested_contexts).toBeUndefined()
})
})
const ev: EvidenceUnit[] = [
{ evidence_id: 'e1', document: 'DSGVO', section: 'Art. 30', paragraph: 'Abs. 1', snippet: 'x' },
{ evidence_id: 'e2', document: 'BDSG', section: '§ 38' },
]
describe('buildCitations', () => {
it('numbers citations 1..n mapped to evidence', () => {
const cs = buildCitations(ev)
expect(cs).toHaveLength(2)
expect(cs[0]).toMatchObject({ citation_id: 'c1', number: 1, evidence_id: 'e1' })
expect(cs[1].number).toBe(2)
})
})
describe('numberedEvidenceForPrompt', () => {
it('prefixes each unit with [n] + its location', () => {
const s = numberedEvidenceForPrompt(ev)
expect(s).toContain('[1] DSGVO Art. 30 Abs. 1')
expect(s).toContain('[2] BDSG § 38')
})
})
describe('mapFootnotes', () => {
it('remaps a /retrieve footnote to the contract footnote', () => {
const fns = mapFootnotes([
{ id: 'f1', number: 17, regulation_short: 'EDPB WP248', section: 'Kap III', text: 't' },
])
expect(fns[0]).toMatchObject({
footnote_id: 'f1',
ref: 'Fußnote 17',
document: 'EDPB WP248',
section: 'Kap III',
text: 't',
})
})
})
describe('isLegacyRequest', () => {
it('message-only (workspace) -> legacy stream', () => {
expect(isLegacyRequest({ message: 'Ist meine DSE ausreichend?' })).toBe(true)
})
it('question present -> contract (JSON)', () => {
expect(isLegacyRequest({ question: 'x', message: 'y' })).toBe(false)
expect(isLegacyRequest({ question: 'x' })).toBe(false)
})
})
@@ -0,0 +1,83 @@
// 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
regulation_code?: string // preferred key for family grouping (from /retrieve)
context?: string // knowledge space / domain
// Canonical Legal-KG fact (APEX rule): binding norm vs. soft-law guidance. Owned by the
// Legal-KG/RAG, not derived in the FE. Absent until /retrieve populates it (board request 2026-07-01);
// the FE degrades to a neutral per-regulation breakdown when it is missing.
bindingness?: 'binding' | 'guidance'
}
/** Numbered [n] <-> evidence coupling, produced by the SDK (not parsed from the answer). */
export interface Citation {
citation_id: string
number?: number // 1-based marker number ([n])
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
}
@@ -0,0 +1,80 @@
// Pure grouping/counting for the "Diese Antwort stützt sich auf" evidence header. No React, testable.
// Splits evidence into binding norms (Kernnormen) vs. soft-law guidance (Leitlinien) using the
// Legal-KG-owned `bindingness` fact (APEX rule) — the FE never derives bindingness itself. When the
// fact is absent it degrades to a neutral per-regulation breakdown (no norm/guidance labels, no
// fabricated legal classification).
import type { EvidenceUnit } from './contract'
import { resolveRegulation } from './regulation-display'
export type Bindingness = 'binding' | 'guidance' | 'unknown'
export interface FamilyGroup {
key: string // stable family key (grouping)
label: string // human-readable regulation name
sections: string[] // distinct provisions in first-seen order (e.g. "Art. 13", "§ 25")
units: number // raw evidence units in this family
bindingness: Bindingness
}
export interface EvidenceSummaryModel {
groups: FamilyGroup[]
norms: FamilyGroup[] // bindingness === 'binding'
guidance: FamilyGroup[] // bindingness === 'guidance'
other: FamilyGroup[] // bindingness unknown
hasBindingness: boolean // at least one unit carries the Legal-KG fact
normProvisions: number // distinct binding provisions (Kernnormen)
guidanceCount: number // distinct guidance documents (Leitlinien)
unitCount: number // total evidence units
}
export function groupByFamily(evidence: EvidenceUnit[]): FamilyGroup[] {
const byKey = new Map<string, FamilyGroup>()
for (const e of evidence) {
const { familyKey, familyLabel } = resolveRegulation({
code: e.regulation_code || e.document,
short: e.document,
})
let g = byKey.get(familyKey)
if (!g) {
g = { key: familyKey, label: familyLabel, sections: [], units: 0, bindingness: 'unknown' }
byKey.set(familyKey, g)
}
g.units += 1
if (e.section && !g.sections.includes(e.section)) g.sections.push(e.section)
if (e.bindingness && g.bindingness === 'unknown') g.bindingness = e.bindingness
}
return [...byKey.values()]
}
/** distinct provisions for a family; falls back to raw unit count when no section is known. */
export function provisionCount(g: FamilyGroup): number {
return g.sections.length || g.units
}
/** "5 Artikel" / "§ 25" / "3 Fundstellen" — the noun follows the family's own citation style. */
export function provisionSummary(g: FamilyGroup): string {
const n = g.sections.length
if (n === 0) return `${g.units} ${g.units === 1 ? 'Fundstelle' : 'Fundstellen'}`
if (n === 1) return g.sections[0]
if (g.sections.every((s) => /^\s*art/i.test(s))) return `${n} Artikel`
if (g.sections.every((s) => s.trim().startsWith('§'))) return `${n} §§`
return `${n} Fundstellen`
}
export function summarizeEvidence(evidence: EvidenceUnit[]): EvidenceSummaryModel {
const groups = groupByFamily(evidence)
const norms = groups.filter((g) => g.bindingness === 'binding')
const guidance = groups.filter((g) => g.bindingness === 'guidance')
const other = groups.filter((g) => g.bindingness === 'unknown')
return {
groups,
norms,
guidance,
other,
hasBindingness: norms.length > 0 || guidance.length > 0,
normProvisions: norms.reduce((n, g) => n + provisionCount(g), 0),
guidanceCount: guidance.length,
unitCount: evidence.length,
}
}
@@ -0,0 +1,57 @@
// 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.
export interface RegulationRef {
code?: string
name?: string
short?: string
}
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',
}
}
@@ -0,0 +1,91 @@
// Pure mappings from the Go /retrieve response (SDK/RAG-owned; board 2026-07-01 12:25)
// to the FE-facing advisor contract. Kept pure + testable; the orchestration (route.ts) wires them.
import type { Citation, ClarityInfo, EvidenceUnit, Footnote, VisualEvidence } from './contract'
export interface RetrieveClarity {
mode?: string // 'clarify' | 'answer'
reason?: string // e.g. 'middle_band_llm_needed'
concentration?: number
domain_count?: number
dominant_context?: string
candidate_contexts?: { id: string; label: string; hits?: number }[]
}
export interface RetrieveFootnote {
id?: string
ref?: string
number?: number
regulation_code?: string
regulation_short?: string
regulation_name?: string
section?: string
text?: string
}
export interface RetrieveResponse {
evidence?: EvidenceUnit[]
visual_evidence?: VisualEvidence[]
footnotes?: RetrieveFootnote[]
clarity?: RetrieveClarity
results?: unknown[]
tables?: unknown[] // C6 — not in the FE contract yet (future TablesPane)
}
/** clarify unless a context was chosen; /retrieve's clarity.mode decides for un-scoped queries. */
export function resolveMode(clarityMode: string | undefined, hasContext: boolean): 'clarify' | 'answer' {
if (hasContext) return 'answer'
return clarityMode === 'clarify' ? 'clarify' : 'answer'
}
export function mapClarity(c: RetrieveClarity | undefined, mode: 'clarify' | 'answer'): ClarityInfo {
return {
is_underspecified: mode === 'clarify',
concentration: c?.concentration ?? 0,
dominant_context: c?.dominant_context,
suggested_contexts:
mode === 'clarify' ? (c?.candidate_contexts ?? []).map((cc) => ({ id: cc.id, label: cc.label })) : undefined,
}
}
export function mapFootnotes(fns: RetrieveFootnote[] | undefined): Footnote[] {
return (fns ?? []).map((f) => ({
footnote_id: f.id,
ref: f.ref ?? (f.number != null ? `Fußnote ${f.number}` : undefined),
document: f.regulation_short || f.regulation_name || f.regulation_code,
section: f.section,
text: f.text,
}))
}
/** Citations are generated by the orchestration (not by /retrieve): [n] -> nth evidence unit. */
export function buildCitations(evidence: EvidenceUnit[]): Citation[] {
return evidence.map((e, i) => ({
citation_id: `c${i + 1}`,
number: i + 1,
evidence_id: e.evidence_id,
document: e.document,
section: e.section ?? null,
paragraph: e.paragraph ?? null,
footnote: null,
figure: null,
}))
}
/** Numbered evidence list injected into the L2 prompt so the LLM can cite [n]. */
export function numberedEvidenceForPrompt(evidence: EvidenceUnit[]): string {
return evidence
.map((e, i) => {
const loc = [e.document, e.section, e.paragraph].filter(Boolean).join(' ')
return `[${i + 1}] ${loc}\n${e.snippet ?? ''}`.trim()
})
.join('\n\n')
}
/**
* Backward-compat discriminator: legacy consumers (e.g. breakpilot-workspace) send `{message}`
* and read a plain-text stream; the new FE sends `{question}` and expects the JSON contract.
*/
export function isLegacyRequest(body: { question?: unknown; message?: unknown }): boolean {
return body.question == null && typeof body.message === 'string'
}
@@ -138,3 +138,26 @@ export async function streamAdvisorAnswer(
if (ollama) return textStream(ollama, parseOllamaLine)
return null
}
/**
* Nicht-streamende Variante: sammelt die vollstaendige LLM-Antwort als String (fuer die
* JSON-Contract-Antwort der Advisor-Orchestrierung). null = kein LLM erreichbar.
*/
export async function completeAdvisorAnswer(messages: ChatMessage[]): Promise<string | null> {
const stream = await streamAdvisorAnswer(messages)
if (!stream) return null
const reader = stream.getReader()
const decoder = new TextDecoder()
let out = ''
try {
for (;;) {
const { done, value } = await reader.read()
if (done) break
if (value) out += decoder.decode(value, { stream: true })
}
out += decoder.decode()
} finally {
reader.releaseLock()
}
return out
}
+80 -24
View File
@@ -7,10 +7,13 @@
* Advisor bleibt damit collection-agnostisch (Vertrag: Compiler -> Collections -> Retriever
* -> Advisor); die fruehere Multi-Collection-Logik liegt jetzt im Retriever.
*
* Fehler werden geschluckt (graceful: Antwort ohne RAG-Kontext).
* Fundstellen via article_label sind live ab dem Prod-Re-Ingest 2026-06.
* `retrieveAdvisorEvidence` liefert die STRUKTURIERTEN Treffer (fuer das Evidence-Workspace-
* Frontend, das nur strukturierte Daten rendert und nie den Antworttext parst) UND den
* vorformatierten Kontext-Block fuer den LLM-Prompt. Fehler werden geschluckt (graceful).
*/
import type { RetrieveResponse } from '@/lib/sdk/advisor/retrieve-mapping'
const SDK_URL =
process.env.SDK_API_URL || process.env.SDK_URL || 'http://ai-compliance-sdk:8090'
@@ -18,7 +21,7 @@ const DEFAULT_USER = '00000000-0000-0000-0000-000000000001'
const DEFAULT_TENANT =
process.env.DEFAULT_TENANT_ID || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
interface SdkRagResult {
export interface SdkRagResult {
text?: string
regulation_code?: string
regulation_name?: string
@@ -34,20 +37,27 @@ interface SdkRagResult {
score?: number
}
/** Raw RAG response. `figures`/`footnotes` (C8 / C-FN) are passed through untyped until the
* RAG-ingestion contract is finalized (board), then mapped in the evidence-adapter. */
interface SdkRagResponse {
results?: SdkRagResult[]
figures?: unknown[]
footnotes?: unknown[]
}
interface ScoredPassage {
content: string
source: string
score: number
}
/** Normalisiert eine ai-sdk-RAG-Antwort auf {content, source, score}. */
/** Normalisiert eine ai-sdk-RAG-Antwort auf {content, source, score} (fuer den Prompt-Kontext). */
export function mapSdkResults(results: SdkRagResult[] | undefined): ScoredPassage[] {
return (results || [])
.map((r) => ({
content: r.text || '',
// Fundstelle: article_label ist die fertig formatierte, druckbare Quelle aus der
// Ingestion ("BDSG § 38 Abs. 1"); Fallback baut sie aus den strukturierten Feldern
// (bzw. alt-ingestierte Chunks ohne Legal-Metadaten). Siehe rag_reingest_spec.md §2/§7.
// Ingestion ("BDSG § 38 Abs. 1"); Fallback baut sie aus den strukturierten Feldern.
source:
(r.article_label && r.article_label.trim()) ||
[r.regulation_short || r.regulation_name || r.regulation_code, r.article, r.paragraph, r.sub]
@@ -59,15 +69,16 @@ export function mapSdkResults(results: SdkRagResult[] | undefined): ScoredPassag
.filter((p) => p.content)
}
/**
* Authority Router: EIN collection-agnostischer Aufruf an die ai-sdk (`/sdk/v1/rag/retrieve`).
* Der Router waehlt die Collections (Broad-Authority-Base + KB-2026.1-Slice bei in-scope),
* merged + authority-ranked sie und liefert die Top-Passagen. Der Advisor weiss damit nichts
* mehr ueber einzelne Collections — die fruehere Multi-Collection-Logik liegt jetzt im Retriever.
* Fehler werden geschluckt (graceful: Antwort ohne RAG-Kontext).
*/
export async function queryAdvisorRAG(query: string): Promise<string> {
let passages: ScoredPassage[] = []
/** Formatiert die Top-Passagen als Kontext-Block fuer den System-Prompt. */
function formatContext(passages: ScoredPassage[]): string {
if (passages.length === 0) return ''
return passages
.map((r, i) => `[Quelle ${i + 1}: ${r.source}]\n${r.content}`)
.join('\n\n---\n\n')
}
/** EIN collection-agnostischer Aufruf an die ai-sdk. Fehler -> leeres Ergebnis (graceful). */
async function fetchRag(query: string): Promise<SdkRagResponse> {
try {
const res = await fetch(`${SDK_URL}/sdk/v1/rag/retrieve`, {
method: 'POST',
@@ -79,16 +90,61 @@ export async function queryAdvisorRAG(query: string): Promise<string> {
body: JSON.stringify({ query, top_k: 8 }),
signal: AbortSignal.timeout(15000),
})
if (res.ok) {
const data = await res.json()
passages = mapSdkResults(data.results)
}
if (res.ok) return ((await res.json()) as SdkRagResponse) || {}
} catch {
// graceful: keine Verbindung -> Antwort ohne RAG-Kontext
}
// Der Router liefert bereits authority-geordnete Top-K; Reihenfolge bewahren.
if (passages.length === 0) return ''
return passages
.map((r, i) => `[Quelle ${i + 1}: ${r.source}]\n${r.content}`)
.join('\n\n---\n\n')
return {}
}
export interface AdvisorEvidenceRaw {
contextText: string
results: SdkRagResult[]
figures?: unknown[]
footnotes?: unknown[]
}
/**
* Strukturierte Evidence + Prompt-Kontext aus EINEM Retrieval. Das Frontend bekommt die
* `results` (und kuenftig `figures`/`footnotes`) als Daten; der `contextText` geht in den
* LLM-Prompt. Reihenfolge der authority-geordneten Top-K bleibt erhalten.
*/
export async function retrieveAdvisorEvidence(query: string): Promise<AdvisorEvidenceRaw> {
const data = await fetchRag(query)
const results = data.results || []
return {
contextText: formatContext(mapSdkResults(results)),
results,
figures: Array.isArray(data.figures) ? data.figures : undefined,
footnotes: Array.isArray(data.footnotes) ? data.footnotes : undefined,
}
}
/** Abwaertskompatibel: nur der Prompt-Kontext als String. */
export async function queryAdvisorRAG(query: string): Promise<string> {
return (await retrieveAdvisorEvidence(query)).contextText
}
/**
* Voller `/retrieve`-Aufruf fuer die Clarity-Gate-Orchestrierung: liefert die strukturierte
* SDK/RAG-Response (evidence/visual_evidence/footnotes/tables/clarity/results). `context` scopet
* den 2. Aufruf auf die gewaehlte Domaene. Fehler -> leeres Ergebnis (graceful).
*/
export async function retrieveFull(query: string, context?: string | null): Promise<RetrieveResponse> {
try {
const res = await fetch(`${SDK_URL}/sdk/v1/rag/retrieve`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-User-ID': DEFAULT_USER,
'X-Tenant-ID': DEFAULT_TENANT,
},
body: JSON.stringify({ query, top_k: 8, ...(context ? { context } : {}) }),
signal: AbortSignal.timeout(15000),
})
if (res.ok) return ((await res.json()) as RetrieveResponse) || {}
} catch {
// graceful: keine Verbindung -> leeres Ergebnis
}
return {}
}