From 49171e841f260905c06aaeb88fced8dd500b0829 Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Wed, 1 Jul 2026 07:46:37 +0200 Subject: [PATCH 1/8] =?UTF-8?q?feat(advisor):=20Evidence=20Workspace=20?= =?UTF-8?q?=E2=80=94=20structured=20panes,=20markdown,=20sources=20as=20kn?= =?UTF-8?q?owledge=20units?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebuilds the Compliance Advisor floating widget from a plain chat into an Evidence Workspace: pinned last question, markdown-rendered answer (clean prose), and separate panes for Sources (hierarchical Knowledge Units), Figures (C8, conditional) and Footnotes (C-FN), plus a stats bar (Quellen/Regelwerke/Diagramme/Fußnoten). Scrollable turn history; stays a floating icon on every SDK page. Architecture (user direction): the frontend renders ONLY structured evidence and NEVER parses the answer text. The proxy now returns a JSON AdvisorEvidenceMeta line followed by the streamed markdown answer; advisor-rag exposes structured results; an adapter maps RAG/compiler output to the frontend envelope. Figures/footnotes wire in once the RAG-ingestion contract lands (requested on the board) — figures pane is conditional. - lib/sdk/advisor/{evidence,evidence-adapter}.ts (+ adapter test, 7 cases) - components/sdk/advisor/* panes + in-house safe Markdown (no new dep, no dangerouslySetInnerHTML) + test - useAdvisorStream (meta-line parse + streamed answer) + useAdvisorEmail (escaped) - proxy: evidence-meta-v1 envelope + clean-prose prompt (no inline citations) - tsc clean, 11 vitest pass, check-loc 0. ESLint not installed in this node_modules -> CI lints on push. Co-Authored-By: Claude Opus 4.7 --- .../api/sdk/compliance-advisor/chat/route.ts | 71 +++- .../components/sdk/ComplianceAdvisorParts.tsx | 131 ------- .../sdk/ComplianceAdvisorWidget.tsx | 326 ++++-------------- .../components/sdk/advisor/AnswerPane.tsx | 37 ++ .../components/sdk/advisor/EmptyState.tsx | 64 ++++ .../sdk/advisor/EvidenceWorkspace.tsx | 51 +++ .../components/sdk/advisor/FiguresPane.tsx | 71 ++++ .../components/sdk/advisor/FootnotesPane.tsx | 28 ++ .../sdk/advisor/KnowledgeUnitCard.tsx | 68 ++++ .../components/sdk/advisor/Markdown.test.tsx | 40 +++ .../components/sdk/advisor/Markdown.tsx | 153 ++++++++ .../components/sdk/advisor/PaneHeader.tsx | 24 ++ .../components/sdk/advisor/SourcesPane.tsx | 24 ++ .../components/sdk/advisor/StatsBar.tsx | 52 +++ .../components/sdk/advisor/StickyQuestion.tsx | 21 ++ .../components/sdk/advisor/TurnView.tsx | 27 ++ .../components/sdk/advisor/useAdvisorEmail.ts | 71 ++++ .../sdk/advisor/useAdvisorStream.ts | 110 ++++++ .../advisor-evidence-adapter.test.ts | 105 ++++++ .../lib/sdk/advisor/evidence-adapter.ts | 145 ++++++++ admin-compliance/lib/sdk/advisor/evidence.ts | 103 ++++++ .../lib/sdk/agents/advisor-rag.ts | 78 +++-- 22 files changed, 1379 insertions(+), 421 deletions(-) delete mode 100644 admin-compliance/components/sdk/ComplianceAdvisorParts.tsx create mode 100644 admin-compliance/components/sdk/advisor/AnswerPane.tsx create mode 100644 admin-compliance/components/sdk/advisor/EmptyState.tsx create mode 100644 admin-compliance/components/sdk/advisor/EvidenceWorkspace.tsx create mode 100644 admin-compliance/components/sdk/advisor/FiguresPane.tsx create mode 100644 admin-compliance/components/sdk/advisor/FootnotesPane.tsx create mode 100644 admin-compliance/components/sdk/advisor/KnowledgeUnitCard.tsx create mode 100644 admin-compliance/components/sdk/advisor/Markdown.test.tsx create mode 100644 admin-compliance/components/sdk/advisor/Markdown.tsx create mode 100644 admin-compliance/components/sdk/advisor/PaneHeader.tsx create mode 100644 admin-compliance/components/sdk/advisor/SourcesPane.tsx create mode 100644 admin-compliance/components/sdk/advisor/StatsBar.tsx create mode 100644 admin-compliance/components/sdk/advisor/StickyQuestion.tsx create mode 100644 admin-compliance/components/sdk/advisor/TurnView.tsx create mode 100644 admin-compliance/components/sdk/advisor/useAdvisorEmail.ts create mode 100644 admin-compliance/components/sdk/advisor/useAdvisorStream.ts create mode 100644 admin-compliance/lib/sdk/__tests__/advisor-evidence-adapter.test.ts create mode 100644 admin-compliance/lib/sdk/advisor/evidence-adapter.ts create mode 100644 admin-compliance/lib/sdk/advisor/evidence.ts diff --git a/admin-compliance/app/api/sdk/compliance-advisor/chat/route.ts b/admin-compliance/app/api/sdk/compliance-advisor/chat/route.ts index b02a82b1..2d61c13c 100644 --- a/admin-compliance/app/api/sdk/compliance-advisor/chat/route.ts +++ b/admin-compliance/app/api/sdk/compliance-advisor/chat/route.ts @@ -1,18 +1,21 @@ /** - * Compliance Advisor Chat API + * Compliance Advisor Chat API — Evidence Workspace envelope. * * Verbindet das ComplianceAdvisorWidget mit: - * 1. Multi-Collection-RAG ueber die ai-compliance-sdk (bge-m3) — siehe advisor-rag + * 1. Strukturierter RAG-Evidence ueber die ai-compliance-sdk — 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. + * Antwort-Format (evidence-meta-v1): ERSTE Zeile = JSON `AdvisorEvidenceMeta` + * (Quellen/Abbildungen/Fussnoten/Stats), danach streamt die Antwort als Markdown-Text. + * Das Frontend rendert NUR diese strukturierten Daten und parst NIE den Antworttext. */ 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 { retrieveAdvisorEvidence } from '@/lib/sdk/agents/advisor-rag' +import { adaptEvidence, type RawFigure, type RawFootnote } from '@/lib/sdk/advisor/evidence-adapter' import { streamAdvisorAnswer, type ChatMessage } from '@/lib/sdk/agents/advisor-llm' type Country = 'DE' | 'AT' | 'CH' | 'EU' @@ -24,11 +27,19 @@ Du bist der BreakPilot Compliance-Berater. Du hilfst Nutzern des AI Compliance S Datenschutz- und Compliance-Fragen in verstaendlicher Sprache zu beantworten. ## Kernprinzipien -- Quellenbasiert: Verweise auf DSGVO-Artikel, BDSG-Paragraphen +- Quellenbasiert: Stuetze dich auf die bereitgestellten Rechtsquellen - Verstaendlich: Einfache, praxisnahe Sprache - Ehrlich: Bei Unsicherheit empfehle Rechtsberatung - Deutsch als Hauptsprache` +// Antwort = saubere Prosa OHNE Inline-Fundstellen; die Quellen zeigt das Frontend separat an. +const FORMAT_GUIDANCE = `\n\n## Antwortformat (WICHTIG) +- Schreibe gut strukturiertes **Markdown**: kurze Abschnittsueberschriften (##), Aufzaehlungen (-), + nummerierte Schritte und **Fettung** fuer Schluesselbegriffe. Halte Absaetze kurz. +- Nenne Fundstellen/Quellen NICHT im Fliesstext (kein "(Art. 30 DSGVO)", keine "[Quelle 1]"). + Die Quellen werden dem Nutzer in einem EIGENEN Bereich neben der Antwort angezeigt. +- Schreibe so, dass die Antwort auch ohne eingebettete Zitate vollstaendig verstaendlich ist.` + const COUNTRY_LABELS: Record = { DE: 'Deutschland', AT: 'Oesterreich', @@ -56,6 +67,29 @@ Der Nutzer hat "${label} (${c})" gewaehlt. - Bei ${guidance}` } +/** Stellt der gestreamten Antwort eine JSON-Meta-Zeile voran (evidence-meta-v1). */ +function withEvidenceMeta(meta: unknown, answer: ReadableStream): ReadableStream { + const encoder = new TextEncoder() + const metaLine = JSON.stringify(meta) + '\n' + return new ReadableStream({ + async start(controller) { + controller.enqueue(encoder.encode(metaLine)) + const reader = answer.getReader() + try { + for (;;) { + const { done, value } = await reader.read() + if (done) break + if (value) controller.enqueue(value) + } + } catch (e) { + controller.error(e) + return + } + controller.close() + }, + }) +} + export async function POST(request: NextRequest) { try { const body = await request.json() @@ -69,23 +103,31 @@ export async function POST(request: NextRequest) { ? (country as Country) : undefined - // 1. RAG (ai-sdk, bge-m3) + strukturierte Controls zum Thema — beide parallel - const [ragContext, controlsContext] = await Promise.all([ - queryAdvisorRAG(message), + // 1. Strukturierte RAG-Evidence + Controls zum Thema — parallel + const [evidence, controlsContext] = await Promise.all([ + retrieveAdvisorEvidence(message), buildControlsContext(message), ]) - // 2. System-Prompt zusammenbauen + // 2. Evidence-Meta fuer das Frontend (strukturiert, nicht geparst) + const meta = adaptEvidence({ + results: evidence.results, + figures: evidence.figures as RawFigure[] | undefined, + footnotes: evidence.footnotes as RawFootnote[] | undefined, + }) + + // 3. System-Prompt 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 (evidence.contextText) { + 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.\n\n${evidence.contextText}` } if (controlsContext) systemContent += `\n\n${controlsContext}` + systemContent += FORMAT_GUIDANCE systemContent += `\n\n## Aktueller SDK-Schritt\nDer Nutzer befindet sich im SDK-Schritt: ${currentStep}` - // 3. Nachrichten (History auf die letzten 6 begrenzen) + // 4. Nachrichten (History auf die letzten 6 begrenzen) const messages: ChatMessage[] = [ { role: 'system', content: systemContent }, ...history.slice(-6).map((h: { role: string; content: string }) => ({ @@ -95,7 +137,7 @@ export async function POST(request: NextRequest) { { role: 'user', content: message }, ] - // 4. LLM-Kaskade -> Plain-Text-Stream + // 5. LLM-Kaskade -> Meta-Zeile + Text-Stream const stream = await streamAdvisorAnswer(messages) if (!stream) { return NextResponse.json( @@ -104,11 +146,12 @@ export async function POST(request: NextRequest) { ) } - return new NextResponse(stream, { + return new NextResponse(withEvidenceMeta(meta, stream), { headers: { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-cache', Connection: 'keep-alive', + 'X-Advisor-Format': 'evidence-meta-v1', }, }) } catch (error) { diff --git a/admin-compliance/components/sdk/ComplianceAdvisorParts.tsx b/admin-compliance/components/sdk/ComplianceAdvisorParts.tsx deleted file mode 100644 index a58bd253..00000000 --- a/admin-compliance/components/sdk/ComplianceAdvisorParts.tsx +++ /dev/null @@ -1,131 +0,0 @@ -'use client' - -// ============================================================================= -// ComplianceAdvisorWidget — shared constants and sub-components -// ============================================================================= - -// ============================================================================= -// EXAMPLE QUESTIONS -// ============================================================================= - -export const EXAMPLE_QUESTIONS: Record = { - 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 ( -
-
- - - -
-

Willkommen beim Compliance Advisor

-

Stellen Sie Fragen zu DSGVO, KI-Verordnung und mehr.

-
-

Beispielfragen:

- {exampleQuestions.map((question, idx) => ( - - ))} -
-
- ) -} - -// ============================================================================= -// MessageList — renders messages + typing indicator -// ============================================================================= - -interface MessageListProps { - messages: Message[] - isTyping: boolean - messagesEndRef: React.RefObject -} - -export function AdvisorMessageList({ messages, isTyping, messagesEndRef }: MessageListProps) { - return ( - <> - {messages.map((message) => ( -
-
-

- {message.content || (message.role === 'agent' && isTyping ? '' : message.content)} -

-

- {message.timestamp.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })} -

-
-
- ))} - - {isTyping && ( -
-
-
-
-
-
-
-
-
- )} - -
} /> - - ) -} diff --git a/admin-compliance/components/sdk/ComplianceAdvisorWidget.tsx b/admin-compliance/components/sdk/ComplianceAdvisorWidget.tsx index 22b6208f..1bd84cc2 100644 --- a/admin-compliance/components/sdk/ComplianceAdvisorWidget.tsx +++ b/admin-compliance/components/sdk/ComplianceAdvisorWidget.tsx @@ -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 { useAdvisorStream } from './advisor/useAdvisorStream' +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 — Evidence Workspace as a floating widget on every SDK page. + * Renders ONLY structured evidence from the SDK (answer + sources + figures + footnotes); + * it never parses the answer text. See memory: advisor-evidence-workspace-no-parse. + */ export function ComplianceAdvisorWidget({ currentStep = 'default' }: ComplianceAdvisorWidgetProps) { const [isOpen, setIsOpen] = useState(false) const [isExpanded, setIsExpanded] = useState(false) - const [messages, setMessages] = useState([]) const [inputValue, setInputValue] = useState('') - const [isTyping, setIsTyping] = useState(false) - const [selectedCountry, setSelectedCountry] = useState('DE') - const messagesEndRef = useRef(null) - const abortControllerRef = useRef(null) + const [country, setCountry] = useState('DE') + const { turns, isStreaming, send, stop } = useAdvisorStream({ currentStep, country }) + const email = useAdvisorEmail(turns, 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() || isStreaming) 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) - } + void send(q) }, - [isTyping, messages, currentStep, selectedCountry] + [isStreaming, send], ) - 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 }) => - `

Frage: ${q}

${a}

` - ).join('') - - const bodyHtml = ` -

Compliance Advisor — Beratungsprotokoll

-

Datum: ${new Date().toLocaleString('de-DE')} | Land: ${selectedCountry} | Kontext: ${currentStep}

-
- ${qaHtml} -
-

Automatisch erstellt vom BreakPilot Compliance Advisor (Qwen)

- ` - - 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,108 @@ export function ComplianceAdvisorWidget({ currentStep = 'default' }: ComplianceA return ( ) } return ( -
+
{/* Header */} -
+
-
- - - +
+
-
Compliance Advisor
-
- {COUNTRIES.map(({ code, label }) => ( +
Compliance Advisor
+
+ {COUNTRIES.map((c) => ( ))}
- {/* Send as Email */} - {messages.length > 0 && ( + {turns.length > 0 && ( )} -
- {/* Messages Area */} -
- {messages.length === 0 ? ( - handleSendMessage(q)} - /> - ) : ( - - )} -
+ {/* Evidence Workspace */} + - {/* Input Area */} -
+ {/* Input */} +
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={isStreaming} + 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 ? ( + {isStreaming ? ( ) : ( )}
diff --git a/admin-compliance/components/sdk/advisor/AnswerPane.tsx b/admin-compliance/components/sdk/advisor/AnswerPane.tsx new file mode 100644 index 00000000..8eb8fa9c --- /dev/null +++ b/admin-compliance/components/sdk/advisor/AnswerPane.tsx @@ -0,0 +1,37 @@ +'use client' + +import { Markdown } from './Markdown' + +/** The answer panel — rendered markdown (clean prose, no inline citations). */ +export function AnswerPane({ + answer, + streaming, + error, +}: { + answer: string + streaming?: boolean + error?: string +}) { + if (error) { + return ( +
+ {error} +
+ ) + } + if (!answer && streaming) { + return ( +
+ + + +
+ ) + } + return ( +
+ + {streaming && } +
+ ) +} diff --git a/admin-compliance/components/sdk/advisor/EmptyState.tsx b/admin-compliance/components/sdk/advisor/EmptyState.tsx new file mode 100644 index 00000000..305e162d --- /dev/null +++ b/admin-compliance/components/sdk/advisor/EmptyState.tsx @@ -0,0 +1,64 @@ +'use client' + +import { ShieldCheck } from 'lucide-react' + +export const EXAMPLE_QUESTIONS: Record = { + 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 ( +
+
+ +
+

Compliance Advisor

+

+ Antworten mit nachvollziehbaren Quellen, Fundstellen und — wo vorhanden — Original-Abbildungen. +

+
+

Beispielfragen

+ {exampleQuestions.map((q, i) => ( + + ))} +
+
+ ) +} diff --git a/admin-compliance/components/sdk/advisor/EvidenceWorkspace.tsx b/admin-compliance/components/sdk/advisor/EvidenceWorkspace.tsx new file mode 100644 index 00000000..71d7eef3 --- /dev/null +++ b/admin-compliance/components/sdk/advisor/EvidenceWorkspace.tsx @@ -0,0 +1,51 @@ +'use client' + +import { useEffect, useRef } from 'react' +import type { AdvisorTurn } from './useAdvisorStream' +import { StickyQuestion } from './StickyQuestion' +import { TurnView } from './TurnView' +import { AdvisorEmptyState } from './EmptyState' + +/** + * The Evidence Workspace body: a pinned "last question" + a scrollable history of turns, each + * showing the answer alongside its sources / figures / footnotes. Scroll up to revisit a past + * answer with its full evidence. + */ +export function EvidenceWorkspace({ + turns, + exampleQuestions, + onExample, +}: { + turns: AdvisorTurn[] + exampleQuestions: string[] + onExample: (q: string) => void +}) { + const endRef = useRef(null) + const latest = turns[turns.length - 1] + + // Scroll to the newest turn when a question is added (not on every streamed token, + // so the user can scroll up to review history while the answer streams). + useEffect(() => { + endRef.current?.scrollIntoView({ behavior: 'smooth' }) + }, [turns.length]) + + if (turns.length === 0) { + return ( +
+ +
+ ) + } + + return ( +
+ {latest && } +
+ {turns.map((t, i) => ( + + ))} +
+
+
+ ) +} diff --git a/admin-compliance/components/sdk/advisor/FiguresPane.tsx b/admin-compliance/components/sdk/advisor/FiguresPane.tsx new file mode 100644 index 00000000..2ea88a68 --- /dev/null +++ b/admin-compliance/components/sdk/advisor/FiguresPane.tsx @@ -0,0 +1,71 @@ +'use client' + +import { Image as ImageIcon, ExternalLink } from 'lucide-react' +import type { FigureUnit } from '@/lib/sdk/advisor/evidence' +import { PaneHeader } from './PaneHeader' + +function FigureCard({ fig }: { fig: FigureUnit }) { + const canOpen = !!fig.imageUrl && /^https?:\/\//i.test(fig.imageUrl) + return ( +
+
+
+ {fig.label} + {fig.caption ? — {fig.caption} : null} +
+ {canOpen && ( + + + Original anzeigen + + )} +
+
+ Quelle: {fig.source.short} + {fig.section ? ` · ${fig.section}` : ''} +
+ {canOpen ? ( + + {/* eslint-disable-next-line @next/next/no-img-element */} + {fig.caption + + ) : ( +
+ Original-Abbildung folgt +
+ )} + {fig.visionSummary && ( +

{fig.visionSummary}

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

{fn.text}

} +
+ ))} +
+
+ ) +} diff --git a/admin-compliance/components/sdk/advisor/KnowledgeUnitCard.tsx b/admin-compliance/components/sdk/advisor/KnowledgeUnitCard.tsx new file mode 100644 index 00000000..f0aeaf7a --- /dev/null +++ b/admin-compliance/components/sdk/advisor/KnowledgeUnitCard.tsx @@ -0,0 +1,68 @@ +'use client' + +import { useState } from 'react' +import { ChevronDown, ChevronRight, ExternalLink } from 'lucide-react' +import type { KnowledgeUnit } from '@/lib/sdk/advisor/evidence' + +/** + * A source rendered as a hierarchical Knowledge Unit (Regelwerk → Section → Paragraph → Footnote), + * not a text-list line. [öffnen] resolves to the original source when available; the optional + * snippet lets the user peek the cited text. + */ +export function KnowledgeUnitCard({ unit }: { unit: KnowledgeUnit }) { + const [open, setOpen] = useState(false) + const crumbs = [unit.section, unit.subsection, unit.paragraph, unit.footnoteRef].filter(Boolean) + const href = unit.open?.originalUrl + const canOpen = href && /^https?:\/\//i.test(href) + + return ( +
+
+
+
{unit.regulation.short}
+ {crumbs.length > 0 ? ( +
+ {crumbs.map((c, i) => ( + + {i > 0 && } + {c} + + ))} +
+ ) : ( + unit.label &&
{unit.label}
+ )} +
+ {canOpen && ( + + + öffnen + + )} +
+ + {unit.snippet && ( +
+ + {open && ( +

+ {unit.snippet} +

+ )} +
+ )} +
+ ) +} diff --git a/admin-compliance/components/sdk/advisor/Markdown.test.tsx b/admin-compliance/components/sdk/advisor/Markdown.test.tsx new file mode 100644 index 00000000..272d3b27 --- /dev/null +++ b/admin-compliance/components/sdk/advisor/Markdown.test.tsx @@ -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( + , + ) + 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() + 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() + expect(container.querySelector('pre')).not.toBeNull() + expect(container.textContent).toContain('const x = 1') + }) + + it('only allows http(s) links', () => { + const { container } = render( + , + ) + const links = container.querySelectorAll('a') + expect(links).toHaveLength(1) + expect(links[0].getAttribute('href')).toBe('https://example.test') + }) +}) diff --git a/admin-compliance/components/sdk/advisor/Markdown.tsx b/admin-compliance/components/sdk/advisor/Markdown.tsx new file mode 100644 index 00000000..deb3315f --- /dev/null +++ b/admin-compliance/components/sdk/advisor/Markdown.tsx @@ -0,0 +1,153 @@ +'use client' + +// Minimal, SAFE markdown -> React renderer. No dangerouslySetInnerHTML, no dependency. +// Covers the subset LLMs emit: headings, bold, italic, inline code, fenced code, ul/ol, links. +// (The Evidence Workspace renders citations in a separate pane, so links are rarely needed.) + +const INLINE_RE = /(`[^`]+`|\*\*[^*]+\*\*|\*[^*\s][^*]*\*|_[^_]+_|\[[^\]]+\]\([^)]+\))/g + +function renderInline(text: string, kp: string): React.ReactNode[] { + const nodes: React.ReactNode[] = [] + let last = 0 + let idx = 0 + INLINE_RE.lastIndex = 0 + let m: RegExpExecArray | null + while ((m = INLINE_RE.exec(text)) !== null) { + if (m.index > last) nodes.push(text.slice(last, m.index)) + const tok = m[0] + const key = `${kp}-${idx++}` + if (tok.startsWith('`')) { + nodes.push( + + {tok.slice(1, -1)} + , + ) + } else if (tok.startsWith('**')) { + nodes.push( + + {tok.slice(2, -2)} + , + ) + } else if (tok.startsWith('*') || tok.startsWith('_')) { + nodes.push({tok.slice(1, -1)}) + } else { + const mm = /^\[([^\]]+)\]\(([^)]+)\)$/.exec(tok) + if (mm && /^https?:\/\//i.test(mm[2])) { + nodes.push( + + {mm[1]} + , + ) + } else { + nodes.push(mm ? mm[1] : tok) + } + } + last = m.index + tok.length + } + if (last < text.length) nodes.push(text.slice(last)) + return nodes +} + +function Heading({ level, kp, text }: { level: number; kp: string; text: string }) { + const children = renderInline(text, kp) + if (level <= 1) return

{children}

+ if (level === 2) return

{children}

+ return
{children}
+} + +const UL_RE = /^\s*[-*]\s+/ +const OL_RE = /^\s*\d+\.\s+/ +const H_RE = /^(#{1,6})\s+(.*)$/ + +export function Markdown({ content }: { content: string }) { + const lines = (content || '').replace(/\r\n/g, '\n').split('\n') + const blocks: React.ReactNode[] = [] + let i = 0 + while (i < lines.length) { + const line = lines[i] + const key = `b${blocks.length}` // unique per pushed block (blocks.length is the next index) + + if (line.trim().startsWith('```')) { + const buf: string[] = [] + i++ + while (i < lines.length && !lines[i].trim().startsWith('```')) { + buf.push(lines[i]) + i++ + } + i++ + blocks.push( +
+          {buf.join('\n')}
+        
, + ) + continue + } + if (line.trim() === '') { + i++ + continue + } + const h = H_RE.exec(line) + if (h) { + blocks.push() + 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( +
    + {items.map((it, k) => ( +
  • {renderInline(it, `${key}-${k}`)}
  • + ))} +
, + ) + 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( +
    + {items.map((it, k) => ( +
  1. {renderInline(it, `${key}-${k}`)}
  2. + ))} +
, + ) + 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( +

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

, + ) + } + return
{blocks}
+} diff --git a/admin-compliance/components/sdk/advisor/PaneHeader.tsx b/admin-compliance/components/sdk/advisor/PaneHeader.tsx new file mode 100644 index 00000000..094a1b8d --- /dev/null +++ b/admin-compliance/components/sdk/advisor/PaneHeader.tsx @@ -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 ( +
+ {icon} + {title} + {count != null && ( + + {count} + + )} +
+ ) +} diff --git a/admin-compliance/components/sdk/advisor/SourcesPane.tsx b/admin-compliance/components/sdk/advisor/SourcesPane.tsx new file mode 100644 index 00000000..3ba823b4 --- /dev/null +++ b/admin-compliance/components/sdk/advisor/SourcesPane.tsx @@ -0,0 +1,24 @@ +'use client' + +import { Library } from 'lucide-react' +import type { KnowledgeUnit } from '@/lib/sdk/advisor/evidence' +import { KnowledgeUnitCard } from './KnowledgeUnitCard' +import { PaneHeader } from './PaneHeader' + +/** Sources pane — the answer's evidence as hierarchical Knowledge Units, separate from the prose. */ +export function SourcesPane({ sources }: { sources: KnowledgeUnit[] }) { + return ( +
+ } title="Quellen" count={sources.length} /> + {sources.length === 0 ? ( +

Keine strukturierten Quellen zu dieser Antwort.

+ ) : ( +
+ {sources.map((s) => ( + + ))} +
+ )} +
+ ) +} diff --git a/admin-compliance/components/sdk/advisor/StatsBar.tsx b/admin-compliance/components/sdk/advisor/StatsBar.tsx new file mode 100644 index 00000000..02d72fc3 --- /dev/null +++ b/admin-compliance/components/sdk/advisor/StatsBar.tsx @@ -0,0 +1,52 @@ +'use client' + +import { FileText, Library, Image as ImageIcon, Hash } from 'lucide-react' +import type { AdvisorStats } from '@/lib/sdk/advisor/evidence' + +function Chip({ + icon, + label, + value, + dim, +}: { + icon: React.ReactNode + label: string + value: number + dim?: boolean +}) { + return ( +
+ {icon} + {value} + {label} +
+ ) +} + +/** Compact evidence summary: "Diese Antwort basiert auf N Quellen / M Regelwerken ...". */ +export function StatsBar({ stats }: { stats: AdvisorStats }) { + const cls = 'h-3 w-3' + return ( +
+ } label="Quellen" value={stats.sources} /> + } label="Regelwerke" value={stats.regulations} /> + } + label="Diagramme" + value={stats.figures} + dim={stats.figures === 0} + /> + } + label="Fußnoten" + value={stats.footnotes} + dim={stats.footnotes === 0} + /> +
+ ) +} diff --git a/admin-compliance/components/sdk/advisor/StickyQuestion.tsx b/admin-compliance/components/sdk/advisor/StickyQuestion.tsx new file mode 100644 index 00000000..ee87e8d1 --- /dev/null +++ b/admin-compliance/components/sdk/advisor/StickyQuestion.tsx @@ -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 ( +
+
+ +
+
+ Letzte Frage +
+
{question}
+
+
+
+ ) +} diff --git a/admin-compliance/components/sdk/advisor/TurnView.tsx b/admin-compliance/components/sdk/advisor/TurnView.tsx new file mode 100644 index 00000000..4095e3a8 --- /dev/null +++ b/admin-compliance/components/sdk/advisor/TurnView.tsx @@ -0,0 +1,27 @@ +'use client' + +import type { AdvisorTurn } from './useAdvisorStream' +import { StatsBar } from './StatsBar' +import { AnswerPane } from './AnswerPane' +import { SourcesPane } from './SourcesPane' +import { FiguresPane } from './FiguresPane' +import { FootnotesPane } from './FootnotesPane' + +/** One question/answer turn rendered as stacked evidence panels. */ +export function TurnView({ turn, showQuestion }: { turn: AdvisorTurn; showQuestion?: boolean }) { + const streaming = turn.status === 'streaming' + return ( +
+ {showQuestion && ( +
+ Frage: {turn.question} +
+ )} + + + + + +
+ ) +} diff --git a/admin-compliance/components/sdk/advisor/useAdvisorEmail.ts b/admin-compliance/components/sdk/advisor/useAdvisorEmail.ts new file mode 100644 index 00000000..f27bd7b3 --- /dev/null +++ b/admin-compliance/components/sdk/advisor/useAdvisorEmail.ts @@ -0,0 +1,71 @@ +'use client' + +import { useCallback, useState } from 'react' +import type { AdvisorTurn } from './useAdvisorStream' + +function esc(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') +} + +function sourcesHtml(turn: AdvisorTurn): string { + if (turn.meta.sources.length === 0) return '' + const items = turn.meta.sources + .map((s) => { + const hier = [s.section, s.subsection, s.paragraph, s.footnoteRef].filter(Boolean).join(' › ') + return `
  • ${esc(s.regulation.short || '')}${hier ? ` — ${esc(hier)}` : ''}
  • ` + }) + .join('') + return `

    Quellen:

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

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

    ${esc(t.answer)}

    ${sourcesHtml(t)}
    `, + ) + .join('') + + const bodyHtml = ` +

    Compliance Advisor — Beratungsprotokoll

    +

    Datum: ${esc(new Date().toLocaleString('de-DE'))} | Land: ${esc(country)} | Kontext: ${esc(currentStep)}

    +
    + ${qaHtml} +
    +

    Automatisch erstellt vom BreakPilot Compliance Advisor

    ` + + await fetch('/api/sdk/v1/agent/notify', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + recipient: 'dsb@breakpilot.local', + subject: `Compliance Advisor — ${turns.length} Fragen (${currentStep})`, + body_html: bodyHtml, + role: 'Datenschutzbeauftragter', + }), + }) + setSent(true) + setTimeout(() => setSent(false), 3000) + } catch (e) { + console.error('Email send failed:', e) + } finally { + setSending(false) + } + }, [turns, sending, country, currentStep]) + + return { send, sending, sent } +} diff --git a/admin-compliance/components/sdk/advisor/useAdvisorStream.ts b/admin-compliance/components/sdk/advisor/useAdvisorStream.ts new file mode 100644 index 00000000..f6269372 --- /dev/null +++ b/admin-compliance/components/sdk/advisor/useAdvisorStream.ts @@ -0,0 +1,110 @@ +'use client' + +import { useCallback, useRef, useState } from 'react' +import type { AdvisorEvidenceMeta } from '@/lib/sdk/advisor/evidence' +import { emptyStats } from '@/lib/sdk/advisor/evidence' + +export interface AdvisorTurn { + id: string + question: string + answer: string + meta: AdvisorEvidenceMeta + status: 'streaming' | 'done' | 'error' + error?: string +} + +function emptyMeta(): AdvisorEvidenceMeta { + return { stats: emptyStats(), sources: [], figures: [], footnotes: [] } +} + +interface UseAdvisorStreamArgs { + currentStep: string + country: string +} + +/** + * Drives the Evidence Workspace: posts a question, parses the FIRST line of the response as + * structured `AdvisorEvidenceMeta`, then streams the remaining bytes as the markdown answer. + * The answer text is NEVER parsed for structure — sources/figures/footnotes come from the meta. + */ +export function useAdvisorStream({ currentStep, country }: UseAdvisorStreamArgs) { + const [turns, setTurns] = useState([]) + const [isStreaming, setIsStreaming] = useState(false) + const abortRef = useRef(null) + + const patch = useCallback((id: string, p: Partial) => { + setTurns((prev) => prev.map((t) => (t.id === id ? { ...t, ...p } : t))) + }, []) + + const stop = useCallback(() => { + abortRef.current?.abort() + setIsStreaming(false) + }, []) + + const send = useCallback( + async (question: string) => { + const q = question.trim() + if (!q || isStreaming) return + + const id = `turn-${Date.now()}` + const history = turns.flatMap((t) => [ + { role: 'user', content: t.question }, + { role: 'assistant', content: t.answer }, + ]) + setTurns((prev) => [...prev, { id, question: q, answer: '', meta: emptyMeta(), status: 'streaming' }]) + setIsStreaming(true) + abortRef.current = new AbortController() + + try { + const res = await fetch('/api/sdk/compliance-advisor/chat', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message: q, history, currentStep, country }), + signal: abortRef.current.signal, + }) + if (!res.ok || !res.body) { + const e = await res.json().catch(() => ({ error: 'Unbekannter Fehler' })) + throw new Error(e.error || `Server-Fehler (${res.status})`) + } + + const reader = res.body.getReader() + const decoder = new TextDecoder() + let buf = '' + let metaEnd = -1 + let meta: AdvisorEvidenceMeta | null = null + + for (;;) { + const { done, value } = await reader.read() + if (done) break + buf += decoder.decode(value, { stream: true }) + if (metaEnd === -1) { + const nl = buf.indexOf('\n') + if (nl === -1) continue + metaEnd = nl + 1 + try { + meta = JSON.parse(buf.slice(0, nl)) as AdvisorEvidenceMeta + } catch { + meta = null // no valid meta -> treat whole stream as answer + metaEnd = 0 + } + } + patch(id, { answer: buf.slice(metaEnd), ...(meta ? { meta } : {}) }) + } + + buf += decoder.decode() + patch(id, { answer: buf.slice(metaEnd === -1 ? 0 : metaEnd), status: 'done', ...(meta ? { meta } : {}) }) + setIsStreaming(false) + } catch (err) { + setIsStreaming(false) + if ((err as Error).name === 'AbortError') { + patch(id, { status: 'done' }) + return + } + patch(id, { status: 'error', error: err instanceof Error ? err.message : 'Verbindung fehlgeschlagen' }) + } + }, + [isStreaming, turns, currentStep, country, patch], + ) + + return { turns, isStreaming, send, stop } +} diff --git a/admin-compliance/lib/sdk/__tests__/advisor-evidence-adapter.test.ts b/admin-compliance/lib/sdk/__tests__/advisor-evidence-adapter.test.ts new file mode 100644 index 00000000..5a903083 --- /dev/null +++ b/admin-compliance/lib/sdk/__tests__/advisor-evidence-adapter.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect } from 'vitest' +import { adaptEvidence } from '../advisor/evidence-adapter' +import type { SdkRagResult } from '../agents/advisor-rag' + +describe('adaptEvidence', () => { + it('maps a structured RAG result to a hierarchical Knowledge Unit', () => { + const results: SdkRagResult[] = [ + { + text: 'Der Verantwortliche fuehrt ein Verzeichnis ...', + regulation_code: 'DSGVO', + regulation_short: 'DSGVO', + article_label: 'Art. 30 DSGVO', + article: 'Art. 30', + paragraph: 'Abs. 1', + source_url: 'https://example.test/dsgvo-30', + score: 0.9, + }, + ] + const { sources, stats } = adaptEvidence({ results }) + expect(sources).toHaveLength(1) + expect(sources[0].label).toBe('Art. 30 DSGVO') + expect(sources[0].section).toBe('Art. 30') + expect(sources[0].paragraph).toBe('Abs. 1') + expect(sources[0].open?.originalUrl).toBe('https://example.test/dsgvo-30') + expect(sources[0].snippet).toContain('Verzeichnis') + expect(stats.sources).toBe(1) + expect(stats.regulations).toBe(1) + }) + + it('dedupes the same citation and keeps the highest score', () => { + const base: SdkRagResult = { + text: 'x', + regulation_code: 'CRA', + regulation_short: 'CRA', + article_label: 'Annex I', + article: 'Annex I', + } + const { sources } = adaptEvidence({ + results: [ + { ...base, score: 0.4 }, + { ...base, score: 0.8 }, + ], + }) + expect(sources).toHaveLength(1) + expect(sources[0].score).toBe(0.8) + }) + + it('counts distinct regulations in stats', () => { + const { stats } = adaptEvidence({ + results: [ + { text: 'a', regulation_code: 'DSGVO', article_label: 'Art. 5' }, + { text: 'b', regulation_code: 'DSGVO', article_label: 'Art. 6' }, + { text: 'c', regulation_code: 'BDSG', article_label: '§ 38' }, + ], + }) + expect(stats.sources).toBe(3) + expect(stats.regulations).toBe(2) + }) + + it('labels recitals as Erwaegungsgrund', () => { + const { sources } = adaptEvidence({ + results: [{ text: 'r', regulation_code: 'DSGVO', is_recital: true, article: '47' }], + }) + expect(sources[0].section).toBe('Erwägungsgrund 47') + }) + + it('maps figures (C8) to figure units and counts them', () => { + const { figures, stats } = adaptEvidence({ + results: [], + figures: [ + { + figure_id: 'fig-pdca', + label: 'Abbildung 3', + caption: 'PDCA-Zyklus', + regulation_short: 'EDPB WP248', + vision_summary: 'Kreislauf Plan-Do-Check-Act', + image_url: 'https://example.test/abb3.png', + }, + ], + }) + expect(figures).toHaveLength(1) + expect(figures[0].label).toBe('Abbildung 3') + expect(figures[0].caption).toBe('PDCA-Zyklus') + expect(figures[0].imageUrl).toBe('https://example.test/abb3.png') + expect(stats.figures).toBe(1) + }) + + it('maps footnotes (C-FN) and counts them', () => { + const { footnotes, stats } = adaptEvidence({ + results: [], + footnotes: [{ number: 17, regulation_short: 'EDPB WP248', section: 'Kapitel III.B', text: 'siehe ...' }], + }) + expect(footnotes).toHaveLength(1) + expect(footnotes[0].ref).toBe('Fußnote 17') + expect(stats.footnotes).toBe(1) + }) + + it('returns empty evidence for empty input', () => { + const meta = adaptEvidence({}) + expect(meta.sources).toEqual([]) + expect(meta.figures).toEqual([]) + expect(meta.footnotes).toEqual([]) + expect(meta.stats).toEqual({ sources: 0, regulations: 0, figures: 0, footnotes: 0 }) + }) +}) diff --git a/admin-compliance/lib/sdk/advisor/evidence-adapter.ts b/admin-compliance/lib/sdk/advisor/evidence-adapter.ts new file mode 100644 index 00000000..31880696 --- /dev/null +++ b/admin-compliance/lib/sdk/advisor/evidence-adapter.ts @@ -0,0 +1,145 @@ +// Adapter: RAG/compiler output -> the structured AdvisorEvidenceMeta the Evidence Workspace renders. +// This is the ONLY place that maps backend shapes to the frontend envelope. The frontend never +// parses the answer text — all structure originates here from structured fields. + +import type { + AdvisorEvidenceMeta, + FigureUnit, + FootnoteUnit, + KnowledgeUnit, + RegulationRef, +} from './evidence' +import { deriveStats } from './evidence' +import type { SdkRagResult } from '../agents/advisor-rag' + +/** Provisional raw figure (C8) shape — reconcile with the RAG-ingestion contract (board). */ +export interface RawFigure { + figure_id?: string + id?: string + label?: string // "Abbildung 3" + caption?: string + topic?: string + regulation_code?: string + regulation_short?: string + regulation_name?: string + section?: string + vision_summary?: string + description?: string + image_url?: string + url?: string +} + +/** Provisional raw footnote (C-FN) shape — reconcile with the RAG-ingestion contract (board). */ +export interface RawFootnote { + id?: string + ref?: string + number?: string | number + regulation_code?: string + regulation_short?: string + regulation_name?: string + section?: string + text?: string +} + +export interface RawEvidenceInput { + results?: SdkRagResult[] + figures?: RawFigure[] + footnotes?: RawFootnote[] +} + +function regulationRef( + code?: string, + name?: string, + short?: string, +): RegulationRef { + return { + code: (code || short || name || 'unknown').toLowerCase().replace(/\s+/g, '_'), + name: name || undefined, + short: short || name || code || 'Quelle', + } +} + +function truncate(text: string, max = 240): string { + const t = text.trim().replace(/\s+/g, ' ') + return t.length > max ? `${t.slice(0, max - 1)}…` : t +} + +function toKnowledgeUnit(r: SdkRagResult, idx: number): KnowledgeUnit | null { + const regulation = regulationRef(r.regulation_code, r.regulation_name, r.regulation_short) + const section = r.is_recital + ? `Erwägungsgrund ${r.article ?? ''}`.trim() + : r.article || undefined + const label = r.article_label?.trim() || undefined + // Drop empty placeholders: a unit needs at least a label or a section to be meaningful. + if (!label && !section && !regulation.name && regulation.short === 'Quelle') return null + return { + id: `src-${idx}`, + regulation, + section, + paragraph: r.paragraph || undefined, + subsection: r.sub || undefined, + label, + score: typeof r.score === 'number' ? r.score : undefined, + snippet: r.text ? truncate(r.text) : undefined, + open: r.source_url ? { originalUrl: r.source_url } : undefined, + } +} + +function dedupeKey(u: KnowledgeUnit): string { + return [u.regulation.code, u.section, u.paragraph, u.subsection, u.label] + .map((x) => x || '') + .join('|') +} + +function toFigureUnit(f: RawFigure, idx: number): FigureUnit | null { + const id = f.figure_id || f.id + const imageUrl = f.image_url || f.url + if (!id && !imageUrl && !f.label) return null + return { + id: id || `fig-${idx}`, + label: f.label || `Abbildung ${idx + 1}`, + caption: f.caption || undefined, + topic: f.topic || undefined, + source: regulationRef(f.regulation_code, f.regulation_name, f.regulation_short), + section: f.section || undefined, + visionSummary: f.vision_summary || f.description || undefined, + imageUrl: imageUrl || undefined, + } +} + +function toFootnoteUnit(f: RawFootnote, idx: number): FootnoteUnit | null { + const ref = f.ref || (f.number != null ? `Fußnote ${f.number}` : undefined) + if (!ref && !f.text) return null + return { + id: f.id || `fn-${idx}`, + ref: ref || `Fußnote ${idx + 1}`, + source: regulationRef(f.regulation_code, f.regulation_name, f.regulation_short), + section: f.section || undefined, + text: f.text || undefined, + } +} + +/** + * Build the structured evidence meta. Sources are deduped (same citation retrieved multiple + * times collapses to one, keeping the highest score) and order is preserved by score. + */ +export function adaptEvidence(input: RawEvidenceInput): AdvisorEvidenceMeta { + const seen = new Map() + ;(input.results || []).forEach((r, i) => { + const unit = toKnowledgeUnit(r, i) + if (!unit) return + const key = dedupeKey(unit) + const existing = seen.get(key) + if (!existing || (unit.score ?? 0) > (existing.score ?? 0)) seen.set(key, unit) + }) + const sources = [...seen.values()].sort((a, b) => (b.score ?? 0) - (a.score ?? 0)) + const figures = (input.figures || []) + .map(toFigureUnit) + .filter((x): x is FigureUnit => x !== null) + const footnotes = (input.footnotes || []) + .map(toFootnoteUnit) + .filter((x): x is FootnoteUnit => x !== null) + + const meta = { sources, figures, footnotes } + return { ...meta, stats: deriveStats(meta) } +} diff --git a/admin-compliance/lib/sdk/advisor/evidence.ts b/admin-compliance/lib/sdk/advisor/evidence.ts new file mode 100644 index 00000000..38923084 --- /dev/null +++ b/admin-compliance/lib/sdk/advisor/evidence.ts @@ -0,0 +1,103 @@ +// Structured evidence contract for the Compliance Advisor "Evidence Workspace". +// +// HARD RULE (architecture): the frontend renders ONLY these structured fields and +// NEVER parses the answer text. All structure (sources, figures, footnotes) is owned +// by the SDK/compiler (C-stages) and surfaced as data. The proxy is the adapter that +// fills this envelope from RAG/compiler output. See memory: advisor-evidence-workspace-no-parse. + +/** A regulation / document reference (CRA, EDPB WP248, MaschinenVO, ...). */ +export interface RegulationRef { + code: string // canonical id, e.g. "cra", "edpb_wp248", "maschinenvo" + name?: string // full name + short?: string // short label shown in the card header +} + +/** Openable targets for an evidence item — present only when the SDK can resolve them. */ +export interface OpenTargets { + originalUrl?: string // original text / source_url + chunkId?: string // retrieved chunk + footnoteId?: string // C-FN + figureId?: string // C8 +} + +/** + * A retrieved source as a hierarchical Knowledge Unit, mirroring the compiler: + * Regelwerk -> Section (C1/C2) -> Paragraph -> Footnote (C-FN). + * Rendered as a card, not a text-list line. E.g. "EDPB WP248 / Kapitel III.B / Fußnote 17". + */ +export interface KnowledgeUnit { + id: string + regulation: RegulationRef + section?: string // "Annex I" / "Kapitel III.B" / "Anhang III" + subsection?: string // "Abschnitt 2.3" + paragraph?: string // Absatz / paragraph + footnoteRef?: string // "Fußnote 17" when this unit IS a footnote-backed source + label?: string // pre-formatted citation fallback, e.g. "BDSG § 38 Abs. 1" + score?: number // retrieval score (optional) + snippet?: string // short passage preview (optional) — lets the user peek the cited text + open?: OpenTargets +} + +/** A figure (C8) as a Knowledge Unit — never a bare image. Only present when figures exist. */ +export interface FigureUnit { + id: string // figure_id + label: string // "Abbildung 3" + caption?: string // "PDCA-Zyklus" + topic?: string + source: RegulationRef // "EDPB ..." + section?: string + visionSummary?: string // vision/LLM description of the figure + imageUrl?: string // Playwright PNG; undefined until the RAG-ingestion contract delivers it +} + +/** A footnote (C-FN) as a first-class evidence item. */ +export interface FootnoteUnit { + id: string + ref: string // "Fußnote 17" + source: RegulationRef + section?: string + text?: string +} + +/** Counts for the stats bar above the answer ("Diese Antwort basiert auf N Quellen"). */ +export interface AdvisorStats { + sources: number + regulations: number // distinct Regelwerke + figures: number + footnotes: number +} + +/** + * Meta sent by the proxy FIRST (one JSON line), then the answer streams as tokens. + * RAG runs before the LLM, so all evidence is known up front and the panes render + * immediately while the answer streams in. + */ +export interface AdvisorEvidenceMeta { + stats: AdvisorStats + sources: KnowledgeUnit[] + figures: FigureUnit[] + footnotes: FootnoteUnit[] + relatedDocs?: KnowledgeUnit[] +} + +/** The full evidence a single answer turn holds (meta + the streamed answer markdown). */ +export interface AdvisorEvidence extends AdvisorEvidenceMeta { + answer: string // markdown prose, NO inline citations (sources live in the pane) +} + +export function emptyStats(): AdvisorStats { + return { sources: 0, regulations: 0, figures: 0, footnotes: 0 } +} + +/** Pure derivation of the stats bar from the evidence items (no parsing of answer text). */ +export function deriveStats( + e: Pick, +): AdvisorStats { + const regulations = new Set(e.sources.map((s) => s.regulation.code)) + return { + sources: e.sources.length, + regulations: regulations.size, + figures: e.figures.length, + footnotes: e.footnotes.length, + } +} diff --git a/admin-compliance/lib/sdk/agents/advisor-rag.ts b/admin-compliance/lib/sdk/agents/advisor-rag.ts index b42d1cd2..1bd3c938 100644 --- a/admin-compliance/lib/sdk/agents/advisor-rag.ts +++ b/admin-compliance/lib/sdk/agents/advisor-rag.ts @@ -7,8 +7,9 @@ * 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). */ const SDK_URL = @@ -18,7 +19,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 +35,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 +67,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 { - 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 { try { const res = await fetch(`${SDK_URL}/sdk/v1/rag/retrieve`, { method: 'POST', @@ -79,16 +88,37 @@ export async function queryAdvisorRAG(query: string): Promise { 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 { + 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 { + return (await retrieveAdvisorEvidence(query)).contextText } From 3884038b06dc63185dde9fdc5f09e29e2853018e Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Wed, 1 Jul 2026 10:13:58 +0200 Subject: [PATCH 2/8] =?UTF-8?q?fix(advisor):=20generic=20=E2=80=94=20drop?= =?UTF-8?q?=20trailing=20source=20list=20in=20answer=20+=20de-duplicate=20?= =?UTF-8?q?source=20card?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two structural fixes (not query-specific): - Proxy prompt: forbid ANY trailing "Quellen:"/"Quellen im RAG-System" list and make it the LAST instruction so it overrides the soul file's answer-structure + example that teach a closing sources section. Applies to every answer. - KnowledgeUnitCard: render the label only when it differs from regulation.short, so a source whose label == short name no longer prints twice. Applies to every source. Answer text is still never parsed in the FE (sources live in the pane). + card test. Co-Authored-By: Claude Opus 4.7 --- .../api/sdk/compliance-advisor/chat/route.ts | 6 +++- .../sdk/advisor/KnowledgeUnitCard.test.tsx | 28 +++++++++++++++++++ .../sdk/advisor/KnowledgeUnitCard.tsx | 5 +++- 3 files changed, 37 insertions(+), 2 deletions(-) create mode 100644 admin-compliance/components/sdk/advisor/KnowledgeUnitCard.test.tsx diff --git a/admin-compliance/app/api/sdk/compliance-advisor/chat/route.ts b/admin-compliance/app/api/sdk/compliance-advisor/chat/route.ts index 2d61c13c..16eb284a 100644 --- a/admin-compliance/app/api/sdk/compliance-advisor/chat/route.ts +++ b/admin-compliance/app/api/sdk/compliance-advisor/chat/route.ts @@ -38,6 +38,10 @@ const FORMAT_GUIDANCE = `\n\n## Antwortformat (WICHTIG) nummerierte Schritte und **Fettung** fuer Schluesselbegriffe. Halte Absaetze kurz. - Nenne Fundstellen/Quellen NICHT im Fliesstext (kein "(Art. 30 DSGVO)", keine "[Quelle 1]"). Die Quellen werden dem Nutzer in einem EIGENEN Bereich neben der Antwort angezeigt. +- Beende die Antwort NIEMALS mit einer Quellen-/Fundstellen-Liste (kein "Quellen:", kein + "--- Quellen im RAG-System: ...", kein "Quellen im RAG-System"). KEINE Quellenaufzaehlung im + Antworttext. Dies UEBERSCHREIBT jede anderslautende Struktur-/Beispielvorgabe weiter oben im + System-Prompt (auch eine dort gezeigte "Quellen:"-Abschlusssektion gilt hier NICHT). - Schreibe so, dass die Antwort auch ohne eingebettete Zitate vollstaendig verstaendlich ist.` const COUNTRY_LABELS: Record = { @@ -124,8 +128,8 @@ export async function POST(request: NextRequest) { 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.\n\n${evidence.contextText}` } if (controlsContext) systemContent += `\n\n${controlsContext}` - systemContent += FORMAT_GUIDANCE systemContent += `\n\n## Aktueller SDK-Schritt\nDer Nutzer befindet sich im SDK-Schritt: ${currentStep}` + systemContent += FORMAT_GUIDANCE // LAST instruction: overrides the soul's trailing "Quellen" structure/example // 4. Nachrichten (History auf die letzten 6 begrenzen) const messages: ChatMessage[] = [ diff --git a/admin-compliance/components/sdk/advisor/KnowledgeUnitCard.test.tsx b/admin-compliance/components/sdk/advisor/KnowledgeUnitCard.test.tsx new file mode 100644 index 00000000..b0ab152e --- /dev/null +++ b/admin-compliance/components/sdk/advisor/KnowledgeUnitCard.test.tsx @@ -0,0 +1,28 @@ +import { describe, it, expect } from 'vitest' +import { render } from '@testing-library/react' +import { KnowledgeUnitCard } from './KnowledgeUnitCard' +import type { KnowledgeUnit } from '@/lib/sdk/advisor/evidence' + +const base: KnowledgeUnit = { id: 's1', regulation: { code: 'dsk', short: 'DSK Sdm B51' } } + +describe('KnowledgeUnitCard', () => { + it('does not duplicate the regulation when label equals the short name', () => { + const { container } = render() + const occurrences = (container.textContent?.match(/DSK Sdm B51/g) || []).length + expect(occurrences).toBe(1) + }) + + it('shows the label when it differs from the short name (no breadcrumb)', () => { + const { container } = render() + expect(container.textContent).toContain('DSK Sdm B51') + expect(container.textContent).toContain('Art. 30 DSGVO') + }) + + it('renders the section/paragraph breadcrumb when present', () => { + const { container } = render( + , + ) + expect(container.textContent).toContain('Art. 5') + expect(container.textContent).toContain('Abs. 2') + }) +}) diff --git a/admin-compliance/components/sdk/advisor/KnowledgeUnitCard.tsx b/admin-compliance/components/sdk/advisor/KnowledgeUnitCard.tsx index f0aeaf7a..a43369be 100644 --- a/admin-compliance/components/sdk/advisor/KnowledgeUnitCard.tsx +++ b/admin-compliance/components/sdk/advisor/KnowledgeUnitCard.tsx @@ -30,7 +30,10 @@ export function KnowledgeUnitCard({ unit }: { unit: KnowledgeUnit }) { ))}
    ) : ( - unit.label &&
    {unit.label}
    + unit.label && + unit.label !== unit.regulation.short && ( +
    {unit.label}
    + ) )}
    {canOpen && ( From 591cae5ebc5f7e0256d9163c09739ee000aa6479 Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Wed, 1 Jul 2026 10:38:06 +0200 Subject: [PATCH 3/8] =?UTF-8?q?feat(advisor):=20Case=20Workspace=20v2=20?= =?UTF-8?q?=E2=80=94=20Evidence=20grouping,=20human=20names,=203-column,?= =?UTF-8?q?=20summary?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reworks the advisor toward a Compliance Case Workspace (review feedback): - Rename user-facing "Quellen" -> "Evidence". - Evidence grouped by document/regulation family (count + expandable) — no more unsorted DSK/DSK/DPF/... jumble. - Human-readable regulation names via a display registry (DSK Sdm B51 -> "DSK Standard-Datenschutzmodell (SDM)" / Kapitel B51); generic, bridges G2. - Evidence summary "Antwort basiert auf" with meaningful counts; Regelwerke = distinct FAMILIES (fixes the inflated count). NO fabricated trust score (needs a defined basis). - Expanded mode = 3-column workspace (question+summary | answer | evidence, independent scroll) + history switcher; narrow mode stays stacked. - Prompt: push aggressive markdown structure (## per aspect, numbered phases). Deferred/coordinated on board: C8 diagrams (RAG contract), answer<->evidence coupling [1] (needs LLM citation anchors — phase 2), G1 retrieval relevance + G2 metadata (RAG). tsc clean, 17 vitest, check-loc 0. Co-Authored-By: Claude Opus 4.7 --- .../api/sdk/compliance-advisor/chat/route.ts | 3 + .../sdk/ComplianceAdvisorWidget.tsx | 9 +- .../components/sdk/advisor/EvidencePane.tsx | 74 ++++++++++++++ .../sdk/advisor/EvidenceSummary.tsx | 54 +++++++++++ .../sdk/advisor/EvidenceWorkspace.tsx | 96 ++++++++++++++++--- .../sdk/advisor/KnowledgeUnitCard.test.tsx | 35 +++---- .../sdk/advisor/KnowledgeUnitCard.tsx | 46 ++++++--- .../components/sdk/advisor/SourcesPane.tsx | 24 ----- .../components/sdk/advisor/StatsBar.tsx | 52 ---------- .../components/sdk/advisor/TurnView.tsx | 10 +- .../advisor-regulation-display.test.ts | 31 ++++++ .../lib/sdk/advisor/regulation-display.ts | 53 ++++++++++ 12 files changed, 362 insertions(+), 125 deletions(-) create mode 100644 admin-compliance/components/sdk/advisor/EvidencePane.tsx create mode 100644 admin-compliance/components/sdk/advisor/EvidenceSummary.tsx delete mode 100644 admin-compliance/components/sdk/advisor/SourcesPane.tsx delete mode 100644 admin-compliance/components/sdk/advisor/StatsBar.tsx create mode 100644 admin-compliance/lib/sdk/__tests__/advisor-regulation-display.test.ts create mode 100644 admin-compliance/lib/sdk/advisor/regulation-display.ts diff --git a/admin-compliance/app/api/sdk/compliance-advisor/chat/route.ts b/admin-compliance/app/api/sdk/compliance-advisor/chat/route.ts index 16eb284a..dd1afe7d 100644 --- a/admin-compliance/app/api/sdk/compliance-advisor/chat/route.ts +++ b/admin-compliance/app/api/sdk/compliance-advisor/chat/route.ts @@ -36,6 +36,9 @@ Datenschutz- und Compliance-Fragen in verstaendlicher Sprache zu beantworten. const FORMAT_GUIDANCE = `\n\n## Antwortformat (WICHTIG) - Schreibe gut strukturiertes **Markdown**: kurze Abschnittsueberschriften (##), Aufzaehlungen (-), nummerierte Schritte und **Fettung** fuer Schluesselbegriffe. Halte Absaetze kurz. +- GLIEDERE erklaerende Antworten aktiv statt langem Fliesstext: eine eigene ## Ueberschrift je + Aspekt (z.B. "Definition", "Ablauf/Phasen", "Rechtsbezug", "Praktische Bedeutung"), nummerierte + Schritte fuer Ablaeufe/Phasen, Bullet-Points fuer Aufzaehlungen. Lieber klar gegliedert als ein Block. - Nenne Fundstellen/Quellen NICHT im Fliesstext (kein "(Art. 30 DSGVO)", keine "[Quelle 1]"). Die Quellen werden dem Nutzer in einem EIGENEN Bereich neben der Antwort angezeigt. - Beende die Antwort NIEMALS mit einer Quellen-/Fundstellen-Liste (kein "Quellen:", kein diff --git a/admin-compliance/components/sdk/ComplianceAdvisorWidget.tsx b/admin-compliance/components/sdk/ComplianceAdvisorWidget.tsx index 1bd84cc2..5f09e85e 100644 --- a/admin-compliance/components/sdk/ComplianceAdvisorWidget.tsx +++ b/admin-compliance/components/sdk/ComplianceAdvisorWidget.tsx @@ -60,7 +60,7 @@ export function ComplianceAdvisorWidget({ currentStep = 'default' }: ComplianceA return (
    {/* Header */} @@ -122,7 +122,12 @@ export function ComplianceAdvisorWidget({ currentStep = 'default' }: ComplianceA
    {/* Evidence Workspace */} - + {/* Input */}
    diff --git a/admin-compliance/components/sdk/advisor/EvidencePane.tsx b/admin-compliance/components/sdk/advisor/EvidencePane.tsx new file mode 100644 index 00000000..3f97cdc4 --- /dev/null +++ b/admin-compliance/components/sdk/advisor/EvidencePane.tsx @@ -0,0 +1,74 @@ +'use client' + +import { useState } from 'react' +import { ChevronDown, ChevronRight, Library } from 'lucide-react' +import type { KnowledgeUnit } from '@/lib/sdk/advisor/evidence' +import { resolveRegulation } from '@/lib/sdk/advisor/regulation-display' +import { KnowledgeUnitCard } from './KnowledgeUnitCard' +import { PaneHeader } from './PaneHeader' + +interface EvidenceGroupData { + key: string + label: string + units: KnowledgeUnit[] +} + +function groupByFamily(sources: KnowledgeUnit[]): EvidenceGroupData[] { + const map = new Map() + for (const u of sources) { + const d = resolveRegulation(u.regulation) + const g = map.get(d.familyKey) ?? { key: d.familyKey, label: d.familyLabel, units: [] } + g.units.push(u) + map.set(d.familyKey, g) + } + return [...map.values()].sort((a, b) => b.units.length - a.units.length) +} + +function EvidenceGroup({ group }: { group: EvidenceGroupData }) { + const [open, setOpen] = useState(group.units.length <= 3) + return ( +
    + + {open && ( +
    + {group.units.map((u) => ( + + ))} +
    + )} +
    + ) +} + +/** Evidence pane — retrieved units grouped by document/regulation family, count + expandable. */ +export function EvidencePane({ sources }: { sources: KnowledgeUnit[] }) { + const groups = groupByFamily(sources) + return ( +
    + } + title="Evidence" + count={sources.length} + /> + {groups.length === 0 ? ( +

    Keine strukturierte Evidence zu dieser Antwort.

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

    Keine strukturierten Quellen zu dieser Antwort.

    - ) : ( -
    - {sources.map((s) => ( - - ))} -
    - )} -
    - ) -} diff --git a/admin-compliance/components/sdk/advisor/StatsBar.tsx b/admin-compliance/components/sdk/advisor/StatsBar.tsx deleted file mode 100644 index 02d72fc3..00000000 --- a/admin-compliance/components/sdk/advisor/StatsBar.tsx +++ /dev/null @@ -1,52 +0,0 @@ -'use client' - -import { FileText, Library, Image as ImageIcon, Hash } from 'lucide-react' -import type { AdvisorStats } from '@/lib/sdk/advisor/evidence' - -function Chip({ - icon, - label, - value, - dim, -}: { - icon: React.ReactNode - label: string - value: number - dim?: boolean -}) { - return ( -
    - {icon} - {value} - {label} -
    - ) -} - -/** Compact evidence summary: "Diese Antwort basiert auf N Quellen / M Regelwerken ...". */ -export function StatsBar({ stats }: { stats: AdvisorStats }) { - const cls = 'h-3 w-3' - return ( -
    - } label="Quellen" value={stats.sources} /> - } label="Regelwerke" value={stats.regulations} /> - } - label="Diagramme" - value={stats.figures} - dim={stats.figures === 0} - /> - } - label="Fußnoten" - value={stats.footnotes} - dim={stats.footnotes === 0} - /> -
    - ) -} diff --git a/admin-compliance/components/sdk/advisor/TurnView.tsx b/admin-compliance/components/sdk/advisor/TurnView.tsx index 4095e3a8..a959b89b 100644 --- a/admin-compliance/components/sdk/advisor/TurnView.tsx +++ b/admin-compliance/components/sdk/advisor/TurnView.tsx @@ -1,13 +1,13 @@ 'use client' import type { AdvisorTurn } from './useAdvisorStream' -import { StatsBar } from './StatsBar' +import { EvidenceSummary } from './EvidenceSummary' import { AnswerPane } from './AnswerPane' -import { SourcesPane } from './SourcesPane' +import { EvidencePane } from './EvidencePane' import { FiguresPane } from './FiguresPane' import { FootnotesPane } from './FootnotesPane' -/** One question/answer turn rendered as stacked evidence panels. */ +/** One question/answer turn as stacked panels (collapsed / narrow layout). */ export function TurnView({ turn, showQuestion }: { turn: AdvisorTurn; showQuestion?: boolean }) { const streaming = turn.status === 'streaming' return ( @@ -17,9 +17,9 @@ export function TurnView({ turn, showQuestion }: { turn: AdvisorTurn; showQuesti Frage: {turn.question}
    )} - + - +
    diff --git a/admin-compliance/lib/sdk/__tests__/advisor-regulation-display.test.ts b/admin-compliance/lib/sdk/__tests__/advisor-regulation-display.test.ts new file mode 100644 index 00000000..208eb3cc --- /dev/null +++ b/admin-compliance/lib/sdk/__tests__/advisor-regulation-display.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from 'vitest' +import { resolveRegulation } from '../advisor/regulation-display' + +describe('resolveRegulation', () => { + it('groups DSK SDM building blocks under one family + extracts the chapter', () => { + const b51 = resolveRegulation({ code: 'dsk_sdm_b51', short: 'DSK Sdm B51' }) + const b41 = resolveRegulation({ code: 'dsk_sdm_b41', short: 'DSK Sdm B41' }) + const v31 = resolveRegulation({ code: 'dsk_sdm_v31', short: 'DSK Sdm V31' }) + expect(b51.familyKey).toBe('dsk_sdm') + expect(b41.familyKey).toBe('dsk_sdm') + expect(v31.familyKey).toBe('dsk_sdm') + expect(b51.familyLabel).toContain('Standard-Datenschutzmodell') + expect(b51.chapter).toBe('B51') + expect(v31.chapter).toBe('V31') + }) + + it('maps known regulations to friendly family keys', () => { + expect(resolveRegulation({ code: 'cra', short: 'CRA' }).familyKey).toBe('cra') + expect(resolveRegulation({ code: 'nis2', short: 'NIS2' }).familyKey).toBe('nis2') + expect(resolveRegulation({ code: 'dpf', short: 'DPF' }).familyKey).toBe('dpf') + expect(resolveRegulation({ code: 'dsgvo', short: 'DS-GVO' }).familyKey).toBe('dsgvo') + expect(resolveRegulation({ code: 'bdsg', short: 'BDSG' }).familyKey).toBe('bdsg') + }) + + it('falls back to code as family + short as label for unknown regulations', () => { + const r = resolveRegulation({ code: 'xyz_reg', short: 'XYZ' }) + expect(r.familyKey).toBe('xyz_reg') + expect(r.familyLabel).toBe('XYZ') + expect(r.chapter).toBeUndefined() + }) +}) diff --git a/admin-compliance/lib/sdk/advisor/regulation-display.ts b/admin-compliance/lib/sdk/advisor/regulation-display.ts new file mode 100644 index 00000000..2a125981 --- /dev/null +++ b/admin-compliance/lib/sdk/advisor/regulation-display.ts @@ -0,0 +1,53 @@ +// Human-readable display for regulations. Maps messy codes/short-names to a stable FAMILY key + +// friendly label (+ chapter for multi-part works like the DSK SDM). Presentation layer only: +// it bridges G2 (clean RAG metadata) and keeps working once codes are clean. Extend the table freely. + +import type { RegulationRef } from './evidence' + +export interface RegulationDisplay { + familyKey: string // stable key used to GROUP evidence + familyLabel: string // human-readable regulation name + chapter?: string // e.g. "B51" for a DSK SDM building block +} + +interface Rule { + test: RegExp + key: string + label: string + chapter?: RegExp +} + +// Order matters: more specific patterns first. +const RULES: Rule[] = [ + { + test: /dsk.?sdm|standard.?datenschutzmodell|(^|[^a-z])sdm([^a-z]|$)/i, + key: 'dsk_sdm', + label: 'DSK Standard-Datenschutzmodell (SDM)', + chapter: /\b([A-Z]\d{1,3})\b/, + }, + { test: /cyber.?resilience|(^|[^a-z])cra([^a-z]|$)/i, key: 'cra', label: 'Cyber Resilience Act (CRA)' }, + { test: /(^|[^a-z])nis.?2([^a-z]|$)/i, key: 'nis2', label: 'NIS2-Richtlinie' }, + { test: /data.?privacy.?framework|(^|[^a-z])dpf([^a-z]|$)/i, key: 'dpf', label: 'EU-US Data Privacy Framework' }, + { test: /maschinen|2023.?1230/i, key: 'maschinenvo', label: 'Maschinenverordnung (EU) 2023/1230' }, + { test: /ds.?gvo|gdpr/i, key: 'dsgvo', label: 'DSGVO – Datenschutz-Grundverordnung' }, + { test: /(^|[^a-z])bdsg([^a-z]|$)/i, key: 'bdsg', label: 'BDSG – Bundesdatenschutzgesetz' }, + { test: /tdddg|ttdsg/i, key: 'tddg', label: 'TDDDG (Digitale-Dienste-Datenschutz)' }, + { test: /edpb|edsa|(^|[^a-z])wp\s?\d+/i, key: 'edpb', label: 'EDPB / DSK Leitlinien' }, + { test: /(^|[^a-z])bsi([^a-z]|$)/i, key: 'bsi', label: 'BSI' }, +] + +export function resolveRegulation(reg: RegulationRef): RegulationDisplay { + const hay = `${reg.code || ''} ${reg.short || ''} ${reg.name || ''}` + for (const r of RULES) { + if (r.test.test(hay)) { + const chapter = r.chapter + ? r.chapter.exec(reg.short || reg.code || '')?.[1] || undefined + : undefined + return { familyKey: r.key, familyLabel: r.label, chapter } + } + } + return { + familyKey: reg.code || reg.short || 'unknown', + familyLabel: reg.short || reg.name || reg.code || 'Regelwerk', + } +} From f9b7ba2424717516e9e3d290cf344affbe522f1b Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Wed, 1 Jul 2026 11:31:28 +0200 Subject: [PATCH 4/8] =?UTF-8?q?feat(advisor):=20v3=20Clarity=20Gate=20?= =?UTF-8?q?=E2=80=94=20Case=20model=20+=20clarify/answer=20contract,=20[n]?= =?UTF-8?q?=20citations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds the FE against the SDK<->FE Clarity-Gate contract (board 2026-07-01 / advisor-clarity-gate-contract). The advisor is now a CASE, not a chat: - Request {question, context?}; response {mode: clarify|answer, clarity, general_answer, answer, evidence, citations, visual_evidence, footnotes}. - clarify mode: short L1 general answer (marked "allgemeine Definition, ohne Rechtsquelle") + domain context chips; picking a chip re-runs the case scoped (-> answer). - answer mode: markdown answer with clickable [n] citation markers coupled to evidence cards (highlight + scroll), evidence grouped by document family, visual_evidence (visual_type), footnotes, honest summary counts (no trust score). - FE never parses the answer for structure — only the deliberate [n] markers, mapped via citations[]. New: contract.ts, useAdvisorCase, useCitationHighlight, ClarifyView, EvidenceUnitCard, VisualEvidencePane, CaseView. Removed the v2 stream/chat components. NOT deployed: FE shape-switch (JSON modes) must deploy TOGETHER with the SDK endpoint delivering the contract (board deploy-coupling). Proxy/route.ts unchanged (SDK-owned). tsc clean, 16 vitest (incl. clarify+answer fixtures), check-loc 0. Co-Authored-By: Claude Opus 4.7 --- .../sdk/ComplianceAdvisorWidget.tsx | 45 +++---- .../components/sdk/advisor/AnswerPane.tsx | 37 ------ .../components/sdk/advisor/CaseView.test.tsx | 68 +++++++++++ .../components/sdk/advisor/CaseView.tsx | 75 ++++++++++++ .../components/sdk/advisor/ClarifyView.tsx | 52 +++++++++ .../components/sdk/advisor/EvidencePane.tsx | 40 ++++--- .../sdk/advisor/EvidenceSummary.tsx | 19 +-- ...ledgeUnitCard.tsx => EvidenceUnitCard.tsx} | 57 ++++----- .../sdk/advisor/EvidenceWorkspace.tsx | 105 ++++++++++------- .../components/sdk/advisor/FiguresPane.tsx | 71 ----------- .../components/sdk/advisor/FootnotesPane.tsx | 22 ++-- .../sdk/advisor/KnowledgeUnitCard.test.tsx | 31 ----- .../components/sdk/advisor/Markdown.tsx | 45 +++++-- .../components/sdk/advisor/TurnView.tsx | 27 ----- .../sdk/advisor/VisualEvidencePane.tsx | 70 +++++++++++ .../components/sdk/advisor/useAdvisorCase.ts | 97 +++++++++++++++ .../components/sdk/advisor/useAdvisorEmail.ts | 43 +++---- .../sdk/advisor/useAdvisorStream.ts | 110 ------------------ .../sdk/advisor/useCitationHighlight.ts | 34 ++++++ admin-compliance/lib/sdk/advisor/contract.ts | 76 ++++++++++++ 20 files changed, 671 insertions(+), 453 deletions(-) delete mode 100644 admin-compliance/components/sdk/advisor/AnswerPane.tsx create mode 100644 admin-compliance/components/sdk/advisor/CaseView.test.tsx create mode 100644 admin-compliance/components/sdk/advisor/CaseView.tsx create mode 100644 admin-compliance/components/sdk/advisor/ClarifyView.tsx rename admin-compliance/components/sdk/advisor/{KnowledgeUnitCard.tsx => EvidenceUnitCard.tsx} (60%) delete mode 100644 admin-compliance/components/sdk/advisor/FiguresPane.tsx delete mode 100644 admin-compliance/components/sdk/advisor/KnowledgeUnitCard.test.tsx delete mode 100644 admin-compliance/components/sdk/advisor/TurnView.tsx create mode 100644 admin-compliance/components/sdk/advisor/VisualEvidencePane.tsx create mode 100644 admin-compliance/components/sdk/advisor/useAdvisorCase.ts delete mode 100644 admin-compliance/components/sdk/advisor/useAdvisorStream.ts create mode 100644 admin-compliance/components/sdk/advisor/useCitationHighlight.ts create mode 100644 admin-compliance/lib/sdk/advisor/contract.ts diff --git a/admin-compliance/components/sdk/ComplianceAdvisorWidget.tsx b/admin-compliance/components/sdk/ComplianceAdvisorWidget.tsx index 5f09e85e..90b41e8c 100644 --- a/admin-compliance/components/sdk/ComplianceAdvisorWidget.tsx +++ b/admin-compliance/components/sdk/ComplianceAdvisorWidget.tsx @@ -4,7 +4,7 @@ import { useCallback, useState } from 'react' import { Check, Loader2, Mail, Maximize2, MessagesSquare, Minimize2, Send, Square, X } from 'lucide-react' import { EXAMPLE_QUESTIONS } from './advisor/EmptyState' import { EvidenceWorkspace } from './advisor/EvidenceWorkspace' -import { useAdvisorStream } from './advisor/useAdvisorStream' +import { useAdvisorCase } from './advisor/useAdvisorCase' import { useAdvisorEmail } from './advisor/useAdvisorEmail' interface ComplianceAdvisorWidgetProps { @@ -15,9 +15,9 @@ type Country = 'DE' | 'AT' | 'CH' | 'EU' const COUNTRIES: Country[] = ['DE', 'AT', 'CH', 'EU'] /** - * Compliance Advisor — Evidence Workspace as a floating widget on every SDK page. - * Renders ONLY structured evidence from the SDK (answer + sources + figures + footnotes); - * it never parses the answer text. See memory: advisor-evidence-workspace-no-parse. + * Compliance Advisor — a floating Case Workspace on every SDK page. + * Renders ONLY structured SDK data (clarify/answer contract); it never parses the answer text. + * See memory: advisor-evidence-workspace-no-parse, advisor-clarity-gate-contract. */ export function ComplianceAdvisorWidget({ currentStep = 'default' }: ComplianceAdvisorWidgetProps) { const [isOpen, setIsOpen] = useState(false) @@ -25,17 +25,17 @@ export function ComplianceAdvisorWidget({ currentStep = 'default' }: ComplianceA const [inputValue, setInputValue] = useState('') const [country, setCountry] = useState('DE') - const { turns, isStreaming, send, stop } = useAdvisorStream({ currentStep, country }) - const email = useAdvisorEmail(turns, country, currentStep) + const { cases, busy, ask, selectContext, stop } = useAdvisorCase({ currentStep, country }) + const email = useAdvisorEmail(cases, country, currentStep) const exampleQuestions = EXAMPLE_QUESTIONS[currentStep] || EXAMPLE_QUESTIONS.default const submit = useCallback( (q: string) => { - if (!q.trim() || isStreaming) return + if (!q.trim() || busy) return setInputValue('') - void send(q) + ask(q) }, - [isStreaming, send], + [busy, ask], ) const onKeyDown = (e: React.KeyboardEvent) => { @@ -63,7 +63,6 @@ export function ComplianceAdvisorWidget({ currentStep = 'default' }: ComplianceA isExpanded ? 'h-[85vh] w-[960px]' : 'h-[560px] w-[420px]' }`} > - {/* Header */}
    @@ -87,7 +86,7 @@ export function ComplianceAdvisorWidget({ currentStep = 'default' }: ComplianceA
    - {turns.length > 0 && ( + {cases.length > 0 && ( )}
    - {/* Evidence Workspace */} - {/* Input */}
    setInputValue(e.target.value)} onKeyDown={onKeyDown} placeholder="Frage eingeben..." - disabled={isStreaming} + disabled={busy} className="flex-1 rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-transparent focus:outline-none focus:ring-2 focus:ring-purple-500 disabled:opacity-50" /> - {isStreaming ? ( - ) : ( diff --git a/admin-compliance/components/sdk/advisor/AnswerPane.tsx b/admin-compliance/components/sdk/advisor/AnswerPane.tsx deleted file mode 100644 index 8eb8fa9c..00000000 --- a/admin-compliance/components/sdk/advisor/AnswerPane.tsx +++ /dev/null @@ -1,37 +0,0 @@ -'use client' - -import { Markdown } from './Markdown' - -/** The answer panel — rendered markdown (clean prose, no inline citations). */ -export function AnswerPane({ - answer, - streaming, - error, -}: { - answer: string - streaming?: boolean - error?: string -}) { - if (error) { - return ( -
    - {error} -
    - ) - } - if (!answer && streaming) { - return ( -
    - - - -
    - ) - } - return ( -
    - - {streaming && } -
    - ) -} diff --git a/admin-compliance/components/sdk/advisor/CaseView.test.tsx b/admin-compliance/components/sdk/advisor/CaseView.test.tsx new file mode 100644 index 00000000..acc4f8ed --- /dev/null +++ b/admin-compliance/components/sdk/advisor/CaseView.test.tsx @@ -0,0 +1,68 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, fireEvent } from '@testing-library/react' +import { CaseView } from './CaseView' +import type { AdvisorCase } from './useAdvisorCase' +import type { AdvisorResponse } from '@/lib/sdk/advisor/contract' + +const clarify: AdvisorResponse = { + mode: 'clarify', + question: 'Was ist PDCA?', + clarity: { + is_underspecified: true, + concentration: 0.38, + suggested_contexts: [ + { id: 'datenschutz', label: 'Datenschutz' }, + { id: 'qm', label: 'Qualitätsmanagement' }, + ], + }, + general_answer: 'PDCA steht für **Plan-Do-Check-Act**.', + answer: null, + evidence: [], + citations: [], + visual_evidence: [], + footnotes: [], +} + +const answer: AdvisorResponse = { + mode: 'answer', + question: 'PDCA im Datenschutz?', + clarity: { is_underspecified: false, dominant_context: 'datenschutz', concentration: 0.88 }, + answer: 'Der DSM-Zyklus [1] beschreibt den Ablauf.', + evidence: [ + { evidence_id: 'e1', document: 'DSK Sdm B41', section: 'Art. 5', paragraph: 'Abs. 2', snippet: 'x' }, + ], + citations: [ + { citation_id: 'c1', evidence_id: 'e1', document: 'DSK Sdm B41', section: 'Art. 5', paragraph: 'Abs. 2' }, + ], + visual_evidence: [ + { visual_id: 'v1', visual_type: 'flowchart', caption: 'PDCA-Zyklus', document: 'DSK SDM', vision_summary: 's' }, + ], + footnotes: [], +} + +function mk(response: AdvisorResponse): AdvisorCase { + return { id: 'case1', question: response.question, response, selectedContext: null, status: 'done' } +} + +describe('CaseView — clarify mode', () => { + it('renders the L1 general answer + context chips and fires onSelectContext', () => { + const onSel = vi.fn() + const { container, getByText } = render( + , + ) + expect(container.textContent).toContain('Plan-Do-Check-Act') + expect(container.textContent).toContain('Allgemeine Definition') + fireEvent.click(getByText('Datenschutz')) + expect(onSel).toHaveBeenCalledWith('datenschutz') + }) +}) + +describe('CaseView — answer mode', () => { + it('renders answer with a clickable [n] citation, grouped evidence (friendly name), and visual', () => { + const { container } = render( {}} />) + expect(container.textContent).toContain('DSM-Zyklus') + expect(container.querySelector('button[title="Beleg 1 anzeigen"]')).not.toBeNull() + expect(container.textContent).toContain('DSK Standard-Datenschutzmodell') + expect(container.textContent).toContain('PDCA-Zyklus') + }) +}) diff --git a/admin-compliance/components/sdk/advisor/CaseView.tsx b/admin-compliance/components/sdk/advisor/CaseView.tsx new file mode 100644 index 00000000..4c7c0d8f --- /dev/null +++ b/admin-compliance/components/sdk/advisor/CaseView.tsx @@ -0,0 +1,75 @@ +'use client' + +import type { AdvisorResponse } from '@/lib/sdk/advisor/contract' +import type { AdvisorCase } from './useAdvisorCase' +import { ClarifyView } from './ClarifyView' +import { EvidenceSummary } from './EvidenceSummary' +import { EvidencePane } from './EvidencePane' +import { VisualEvidencePane } from './VisualEvidencePane' +import { FootnotesPane } from './FootnotesPane' +import { Markdown } from './Markdown' +import { useCitationHighlight } from './useCitationHighlight' + +export function LoadingDots() { + return ( +
    + + + +
    + ) +} + +export function ErrorBox({ msg }: { msg?: string }) { + return ( +
    + {msg || 'Verbindung fehlgeschlagen'} +
    + ) +} + +/** Answer mode body (stacked): summary + answer (with [n] coupling) + evidence/visual/footnotes. */ +export function AnswerBody({ response }: { response: AdvisorResponse }) { + const { highlightedId, cite } = useCitationHighlight(response.citations) + return ( +
    + +
    + +
    + + + +
    + ) +} + +/** One case rendered stacked (narrow mode). Clarify -> L1 + chips; answer -> full evidence body. */ +export function CaseView({ + c, + onSelectContext, + busy, + showQuestion, +}: { + c: AdvisorCase + onSelectContext: (ctx: string) => void + busy: boolean + showQuestion?: boolean +}) { + const r = c.response + return ( +
    + {showQuestion && ( +
    + Frage: {c.question} +
    + )} + {c.status === 'loading' && } + {c.status === 'error' && } + {r && r.mode === 'clarify' && ( + + )} + {r && r.mode === 'answer' && } +
    + ) +} diff --git a/admin-compliance/components/sdk/advisor/ClarifyView.tsx b/admin-compliance/components/sdk/advisor/ClarifyView.tsx new file mode 100644 index 00000000..7d27a215 --- /dev/null +++ b/admin-compliance/components/sdk/advisor/ClarifyView.tsx @@ -0,0 +1,52 @@ +'use client' + +import { Info } from 'lucide-react' +import type { AdvisorResponse } from '@/lib/sdk/advisor/contract' +import { Markdown } from './Markdown' + +/** + * Clarify mode: a short general (L1) definition — explicitly marked as general, no legal source — + * plus domain context chips. Picking a chip re-runs the case scoped to that domain (-> L2). + */ +export function ClarifyView({ + response, + onSelectContext, + busy, +}: { + response: AdvisorResponse + onSelectContext: (id: string) => void + busy: boolean +}) { + const chips = response.clarity.suggested_contexts ?? [] + return ( +
    +
    +
    + + Allgemeine Definition (ohne Rechtsquelle) +
    + +
    + {chips.length > 0 && ( +
    +
    + Meintest du einen bestimmten Kontext? +
    +
    + {chips.map((c) => ( + + ))} +
    +
    + )} +
    + ) +} diff --git a/admin-compliance/components/sdk/advisor/EvidencePane.tsx b/admin-compliance/components/sdk/advisor/EvidencePane.tsx index 3f97cdc4..871a71bb 100644 --- a/admin-compliance/components/sdk/advisor/EvidencePane.tsx +++ b/admin-compliance/components/sdk/advisor/EvidencePane.tsx @@ -2,21 +2,21 @@ import { useState } from 'react' import { ChevronDown, ChevronRight, Library } from 'lucide-react' -import type { KnowledgeUnit } from '@/lib/sdk/advisor/evidence' +import type { EvidenceUnit } from '@/lib/sdk/advisor/contract' import { resolveRegulation } from '@/lib/sdk/advisor/regulation-display' -import { KnowledgeUnitCard } from './KnowledgeUnitCard' +import { EvidenceUnitCard } from './EvidenceUnitCard' import { PaneHeader } from './PaneHeader' -interface EvidenceGroupData { +interface Group { key: string label: string - units: KnowledgeUnit[] + units: EvidenceUnit[] } -function groupByFamily(sources: KnowledgeUnit[]): EvidenceGroupData[] { - const map = new Map() - for (const u of sources) { - const d = resolveRegulation(u.regulation) +function groupByFamily(units: EvidenceUnit[]): Group[] { + const map = new Map() + for (const u of units) { + const d = resolveRegulation({ code: u.document, short: u.document }) const g = map.get(d.familyKey) ?? { key: d.familyKey, label: d.familyLabel, units: [] } g.units.push(u) map.set(d.familyKey, g) @@ -24,7 +24,7 @@ function groupByFamily(sources: KnowledgeUnit[]): EvidenceGroupData[] { return [...map.values()].sort((a, b) => b.units.length - a.units.length) } -function EvidenceGroup({ group }: { group: EvidenceGroupData }) { +function EvidenceGroup({ group, highlightedId }: { group: Group; highlightedId?: string }) { const [open, setOpen] = useState(group.units.length <= 3) return (
    @@ -42,7 +42,7 @@ function EvidenceGroup({ group }: { group: EvidenceGroupData }) { {open && (
    {group.units.map((u) => ( - + ))}
    )} @@ -50,22 +50,24 @@ function EvidenceGroup({ group }: { group: EvidenceGroupData }) { ) } -/** Evidence pane — retrieved units grouped by document/regulation family, count + expandable. */ -export function EvidencePane({ sources }: { sources: KnowledgeUnit[] }) { - const groups = groupByFamily(sources) +/** Evidence pane — units grouped by document/regulation family, count + expandable. */ +export function EvidencePane({ + evidence, + highlightedId, +}: { + evidence: EvidenceUnit[] + highlightedId?: string +}) { + const groups = groupByFamily(evidence) return (
    - } - title="Evidence" - count={sources.length} - /> + } title="Evidence" count={evidence.length} /> {groups.length === 0 ? (

    Keine strukturierte Evidence zu dieser Antwort.

    ) : (
    {groups.map((g) => ( - + ))}
    )} diff --git a/admin-compliance/components/sdk/advisor/EvidenceSummary.tsx b/admin-compliance/components/sdk/advisor/EvidenceSummary.tsx index 072d27bc..b4e313e2 100644 --- a/admin-compliance/components/sdk/advisor/EvidenceSummary.tsx +++ b/admin-compliance/components/sdk/advisor/EvidenceSummary.tsx @@ -1,7 +1,7 @@ 'use client' import { FileText, Hash, Image as ImageIcon, Library } from 'lucide-react' -import type { AdvisorEvidenceMeta } from '@/lib/sdk/advisor/evidence' +import type { AdvisorResponse } from '@/lib/sdk/advisor/contract' import { resolveRegulation } from '@/lib/sdk/advisor/regulation-display' function Card({ @@ -31,12 +31,13 @@ function Card({ } /** - * "Antwort basiert auf" — honest, meaningful counts (not bare badges). Regelwerke = distinct - * document FAMILIES (via resolveRegulation), so multi-part works like the DSK SDM count once. - * No fabricated trust score — a real trust signal needs a defined basis (bindingness/coverage). + * "Antwort basiert auf" — objective counts only (no fabricated trust score). Regelwerke = distinct + * document families. Leitlinien deliberately omitted until bindingness exists in the Legal-KG. */ -export function EvidenceSummary({ meta }: { meta: AdvisorEvidenceMeta }) { - const families = new Set(meta.sources.map((s) => resolveRegulation(s.regulation).familyKey)).size +export function EvidenceSummary({ response }: { response: AdvisorResponse }) { + const families = new Set( + response.evidence.map((e) => resolveRegulation({ code: e.document, short: e.document }).familyKey), + ).size const cls = 'h-4 w-4' return (
    @@ -45,9 +46,9 @@ export function EvidenceSummary({ meta }: { meta: AdvisorEvidenceMeta }) {
    } value={families} label="Regelwerke" /> - } value={meta.sources.length} label="Evidence Units" /> - } value={meta.figures.length} label="Abbildungen" dim={meta.figures.length === 0} /> - } value={meta.footnotes.length} label="Fußnoten" dim={meta.footnotes.length === 0} /> + } value={response.evidence.length} label="Evidence Units" /> + } value={response.visual_evidence.length} label="Diagramme" dim={response.visual_evidence.length === 0} /> + } value={response.footnotes.length} label="Fußnoten" dim={response.footnotes.length === 0} />
    ) diff --git a/admin-compliance/components/sdk/advisor/KnowledgeUnitCard.tsx b/admin-compliance/components/sdk/advisor/EvidenceUnitCard.tsx similarity index 60% rename from admin-compliance/components/sdk/advisor/KnowledgeUnitCard.tsx rename to admin-compliance/components/sdk/advisor/EvidenceUnitCard.tsx index 11e49172..f89a2bbd 100644 --- a/admin-compliance/components/sdk/advisor/KnowledgeUnitCard.tsx +++ b/admin-compliance/components/sdk/advisor/EvidenceUnitCard.tsx @@ -2,48 +2,40 @@ import { useState } from 'react' import { ChevronDown, ChevronRight, ExternalLink } from 'lucide-react' -import type { KnowledgeUnit } from '@/lib/sdk/advisor/evidence' +import type { EvidenceUnit } from '@/lib/sdk/advisor/contract' import { resolveRegulation } from '@/lib/sdk/advisor/regulation-display' -/** - * A single evidence unit. Standalone: friendly regulation name + hierarchy. Compact (inside a - * document group): chapter/section only (the group already names the regulation). [öffnen] opens - * the original source; the optional snippet lets the user peek the cited text. - */ -export function KnowledgeUnitCard({ unit, compact }: { unit: KnowledgeUnit; compact?: boolean }) { +/** One evidence unit (contract shape). Compact inside a document group: chapter/section only. */ +export function EvidenceUnitCard({ + unit, + compact, + highlighted, +}: { + unit: EvidenceUnit + compact?: boolean + highlighted?: boolean +}) { const [open, setOpen] = useState(false) - const d = resolveRegulation(unit.regulation) - const crumbs = [unit.section, unit.subsection, unit.paragraph, unit.footnoteRef].filter( - (x): x is string => Boolean(x), - ) - const href = unit.open?.originalUrl - const canOpen = !!href && /^https?:\/\//i.test(href) + const d = resolveRegulation({ code: unit.document, short: unit.document }) + const crumbs = [unit.section, unit.paragraph].filter((x): x is string => Boolean(x)) + const canOpen = !!unit.url && /^https?:\/\//i.test(unit.url) - let header: string - let sub: string[] - if (!compact) { - header = d.familyLabel - sub = crumbs - } else if (d.chapter) { - header = `Kapitel ${d.chapter}` - sub = crumbs - } else { - header = crumbs[0] || unit.label || d.familyLabel - sub = crumbs.slice(1) - } + const header = compact ? (d.chapter ? `Kapitel ${d.chapter}` : crumbs[0] || d.familyLabel) : d.familyLabel + const sub = compact && !d.chapter && crumbs.length ? crumbs.slice(1) : crumbs return (
    {header}
    - {sub.length > 0 ? ( + {sub.length > 0 && (
    {sub.map((c, i) => ( @@ -52,17 +44,11 @@ export function KnowledgeUnitCard({ unit, compact }: { unit: KnowledgeUnit; comp ))}
    - ) : ( - !compact && - unit.label && - unit.label !== header && ( -
    {unit.label}
    - ) )}
    {canOpen && ( )}
    - {unit.snippet && (
    ))}
    @@ -95,21 +103,32 @@ export function EvidenceWorkspace({ )} - {/* Center: answer */}
    - {active && ( - + {active?.status === 'loading' && } + {active?.status === 'error' && } + {r?.mode === 'clarify' && ( + active && onSelectContext(active.id, ctx)} + /> + )} + {r?.mode === 'answer' && ( +
    + +
    )}
    - {/* Right: evidence */}
    diff --git a/admin-compliance/components/sdk/advisor/FiguresPane.tsx b/admin-compliance/components/sdk/advisor/FiguresPane.tsx deleted file mode 100644 index 2ea88a68..00000000 --- a/admin-compliance/components/sdk/advisor/FiguresPane.tsx +++ /dev/null @@ -1,71 +0,0 @@ -'use client' - -import { Image as ImageIcon, ExternalLink } from 'lucide-react' -import type { FigureUnit } from '@/lib/sdk/advisor/evidence' -import { PaneHeader } from './PaneHeader' - -function FigureCard({ fig }: { fig: FigureUnit }) { - const canOpen = !!fig.imageUrl && /^https?:\/\//i.test(fig.imageUrl) - return ( -
    - -
    - Quelle: {fig.source.short} - {fig.section ? ` · ${fig.section}` : ''} -
    - {canOpen ? ( - - {/* eslint-disable-next-line @next/next/no-img-element */} - {fig.caption - - ) : ( -
    - Original-Abbildung folgt -
    - )} - {fig.visionSummary && ( -

    {fig.visionSummary}

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

    {fn.text}

    }
    ))} diff --git a/admin-compliance/components/sdk/advisor/KnowledgeUnitCard.test.tsx b/admin-compliance/components/sdk/advisor/KnowledgeUnitCard.test.tsx deleted file mode 100644 index 3c3ff43f..00000000 --- a/admin-compliance/components/sdk/advisor/KnowledgeUnitCard.test.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { render } from '@testing-library/react' -import { KnowledgeUnitCard } from './KnowledgeUnitCard' -import type { KnowledgeUnit } from '@/lib/sdk/advisor/evidence' - -describe('KnowledgeUnitCard', () => { - it('shows the friendly regulation name (not the raw code) when standalone', () => { - const unit: KnowledgeUnit = { id: 's1', regulation: { code: 'cra', short: 'CRA' } } - const { container } = render() - expect(container.textContent).toContain('Cyber Resilience Act (CRA)') - }) - - it('renders the section/paragraph breadcrumb', () => { - const unit: KnowledgeUnit = { - id: 's2', - regulation: { code: 'dsgvo', short: 'DSGVO' }, - section: 'Art. 5', - paragraph: 'Abs. 2', - } - const { container } = render() - expect(container.textContent).toContain('Art. 5') - expect(container.textContent).toContain('Abs. 2') - }) - - it('compact mode shows the chapter and omits the family name (group provides it)', () => { - const unit: KnowledgeUnit = { id: 's3', regulation: { code: 'dsk_sdm_b51', short: 'DSK Sdm B51' } } - const { container } = render() - expect(container.textContent).toContain('Kapitel B51') - expect(container.textContent).not.toContain('Standard-Datenschutzmodell') - }) -}) diff --git a/admin-compliance/components/sdk/advisor/Markdown.tsx b/admin-compliance/components/sdk/advisor/Markdown.tsx index deb3315f..a1bdd2df 100644 --- a/admin-compliance/components/sdk/advisor/Markdown.tsx +++ b/admin-compliance/components/sdk/advisor/Markdown.tsx @@ -2,11 +2,17 @@ // Minimal, SAFE markdown -> React renderer. No dangerouslySetInnerHTML, no dependency. // Covers the subset LLMs emit: headings, bold, italic, inline code, fenced code, ul/ol, links. -// (The Evidence Workspace renders citations in a separate pane, so links are rarely needed.) +// Plus deliberate [n] citation markers (mapped via `citations`, NOT parsed for structure). -const INLINE_RE = /(`[^`]+`|\*\*[^*]+\*\*|\*[^*\s][^*]*\*|_[^_]+_|\[[^\]]+\]\([^)]+\))/g +export interface CiteHandler { + count: number + onSelect: (n: number) => void +} -function renderInline(text: string, kp: string): React.ReactNode[] { +const INLINE_RE = + /(`[^`]+`|\*\*[^*]+\*\*|\*[^*\s][^*]*\*|_[^_]+_|\[[^\]]+\]\([^)]+\)|\[\d+\])/g + +function renderInline(text: string, kp: string, cite?: CiteHandler): React.ReactNode[] { const nodes: React.ReactNode[] = [] let last = 0 let idx = 0 @@ -30,6 +36,23 @@ function renderInline(text: string, kp: string): React.ReactNode[] { ) } else if (tok.startsWith('*') || tok.startsWith('_')) { nodes.push({tok.slice(1, -1)}) + } else if (/^\[\d+\]$/.test(tok)) { + const n = parseInt(tok.slice(1, -1), 10) + if (cite && n >= 1 && n <= cite.count) { + nodes.push( + , + ) + } else { + nodes.push(tok) + } } else { const mm = /^\[([^\]]+)\]\(([^)]+)\)$/.exec(tok) if (mm && /^https?:\/\//i.test(mm[2])) { @@ -54,8 +77,8 @@ function renderInline(text: string, kp: string): React.ReactNode[] { return nodes } -function Heading({ level, kp, text }: { level: number; kp: string; text: string }) { - const children = renderInline(text, kp) +function Heading({ level, kp, text, cite }: { level: number; kp: string; text: string; cite?: CiteHandler }) { + const children = renderInline(text, kp, cite) if (level <= 1) return

    {children}

    if (level === 2) return

    {children}

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

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

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

    {v.vision_summary}

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

    Quellen:

      ${items}
    ` + return `

    Evidence:

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

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

    ${esc(t.answer)}

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

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

    ${esc(a)}

    ${evidenceHtml(c)}
    ` + }) .join('') const bodyHtml = ` @@ -53,7 +54,7 @@ export function useAdvisorEmail(turns: AdvisorTurn[], country: string, currentSt headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ recipient: 'dsb@breakpilot.local', - subject: `Compliance Advisor — ${turns.length} Fragen (${currentStep})`, + subject: `Compliance Advisor — ${cases.length} Fragen (${currentStep})`, body_html: bodyHtml, role: 'Datenschutzbeauftragter', }), @@ -65,7 +66,7 @@ export function useAdvisorEmail(turns: AdvisorTurn[], country: string, currentSt } finally { setSending(false) } - }, [turns, sending, country, currentStep]) + }, [cases, sending, country, currentStep]) return { send, sending, sent } } diff --git a/admin-compliance/components/sdk/advisor/useAdvisorStream.ts b/admin-compliance/components/sdk/advisor/useAdvisorStream.ts deleted file mode 100644 index f6269372..00000000 --- a/admin-compliance/components/sdk/advisor/useAdvisorStream.ts +++ /dev/null @@ -1,110 +0,0 @@ -'use client' - -import { useCallback, useRef, useState } from 'react' -import type { AdvisorEvidenceMeta } from '@/lib/sdk/advisor/evidence' -import { emptyStats } from '@/lib/sdk/advisor/evidence' - -export interface AdvisorTurn { - id: string - question: string - answer: string - meta: AdvisorEvidenceMeta - status: 'streaming' | 'done' | 'error' - error?: string -} - -function emptyMeta(): AdvisorEvidenceMeta { - return { stats: emptyStats(), sources: [], figures: [], footnotes: [] } -} - -interface UseAdvisorStreamArgs { - currentStep: string - country: string -} - -/** - * Drives the Evidence Workspace: posts a question, parses the FIRST line of the response as - * structured `AdvisorEvidenceMeta`, then streams the remaining bytes as the markdown answer. - * The answer text is NEVER parsed for structure — sources/figures/footnotes come from the meta. - */ -export function useAdvisorStream({ currentStep, country }: UseAdvisorStreamArgs) { - const [turns, setTurns] = useState([]) - const [isStreaming, setIsStreaming] = useState(false) - const abortRef = useRef(null) - - const patch = useCallback((id: string, p: Partial) => { - setTurns((prev) => prev.map((t) => (t.id === id ? { ...t, ...p } : t))) - }, []) - - const stop = useCallback(() => { - abortRef.current?.abort() - setIsStreaming(false) - }, []) - - const send = useCallback( - async (question: string) => { - const q = question.trim() - if (!q || isStreaming) return - - const id = `turn-${Date.now()}` - const history = turns.flatMap((t) => [ - { role: 'user', content: t.question }, - { role: 'assistant', content: t.answer }, - ]) - setTurns((prev) => [...prev, { id, question: q, answer: '', meta: emptyMeta(), status: 'streaming' }]) - setIsStreaming(true) - abortRef.current = new AbortController() - - try { - const res = await fetch('/api/sdk/compliance-advisor/chat', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ message: q, history, currentStep, country }), - signal: abortRef.current.signal, - }) - if (!res.ok || !res.body) { - const e = await res.json().catch(() => ({ error: 'Unbekannter Fehler' })) - throw new Error(e.error || `Server-Fehler (${res.status})`) - } - - const reader = res.body.getReader() - const decoder = new TextDecoder() - let buf = '' - let metaEnd = -1 - let meta: AdvisorEvidenceMeta | null = null - - for (;;) { - const { done, value } = await reader.read() - if (done) break - buf += decoder.decode(value, { stream: true }) - if (metaEnd === -1) { - const nl = buf.indexOf('\n') - if (nl === -1) continue - metaEnd = nl + 1 - try { - meta = JSON.parse(buf.slice(0, nl)) as AdvisorEvidenceMeta - } catch { - meta = null // no valid meta -> treat whole stream as answer - metaEnd = 0 - } - } - patch(id, { answer: buf.slice(metaEnd), ...(meta ? { meta } : {}) }) - } - - buf += decoder.decode() - patch(id, { answer: buf.slice(metaEnd === -1 ? 0 : metaEnd), status: 'done', ...(meta ? { meta } : {}) }) - setIsStreaming(false) - } catch (err) { - setIsStreaming(false) - if ((err as Error).name === 'AbortError') { - patch(id, { status: 'done' }) - return - } - patch(id, { status: 'error', error: err instanceof Error ? err.message : 'Verbindung fehlgeschlagen' }) - } - }, - [isStreaming, turns, currentStep, country, patch], - ) - - return { turns, isStreaming, send, stop } -} diff --git a/admin-compliance/components/sdk/advisor/useCitationHighlight.ts b/admin-compliance/components/sdk/advisor/useCitationHighlight.ts new file mode 100644 index 00000000..5d1c3e68 --- /dev/null +++ b/admin-compliance/components/sdk/advisor/useCitationHighlight.ts @@ -0,0 +1,34 @@ +'use client' + +import { useState } from 'react' +import type { Citation } from '@/lib/sdk/advisor/contract' +import type { CiteHandler } from './Markdown' + +/** + * Couples answer [n] markers to evidence cards: clicking [n] highlights + scrolls to the referenced + * evidence unit. Works across layout columns via the card's DOM id (ev-). + */ +export function useCitationHighlight(citations: Citation[]): { + highlightedId?: string + cite?: CiteHandler +} { + const [highlightedId, setHighlightedId] = useState() + if (citations.length === 0) return { highlightedId } + return { + highlightedId, + cite: { + count: citations.length, + onSelect: (n: number) => { + const c = citations[n - 1] + if (!c) return + setHighlightedId(c.evidence_id) + if (typeof document !== 'undefined') { + document.getElementById(`ev-${c.evidence_id}`)?.scrollIntoView({ + behavior: 'smooth', + block: 'center', + }) + } + }, + }, + } +} diff --git a/admin-compliance/lib/sdk/advisor/contract.ts b/admin-compliance/lib/sdk/advisor/contract.ts new file mode 100644 index 00000000..77f41c60 --- /dev/null +++ b/admin-compliance/lib/sdk/advisor/contract.ts @@ -0,0 +1,76 @@ +// FE-facing contract for the Compliance Advisor "Case" (Clarity Gate). +// Matches the SDK<->FE contract (board 2026-07-01 / memory advisor-clarity-gate-contract). +// The FE renders ONLY these structured fields; it never extracts structure from the answer text. +// The only exception is rendering the deliberate [n] citation markers, mapped via `citations`. + +export interface SuggestedContext { + id: string // e.g. "datenschutz" + label: string // e.g. "Datenschutz" +} + +export interface ClarityInfo { + is_underspecified: boolean + concentration: number + suggested_contexts?: SuggestedContext[] // clarify mode + dominant_context?: string // answer mode +} + +/** A retrieved evidence unit. (`evidence[]` item shape — confirm with SDK; see board rückfrage.) */ +export interface EvidenceUnit { + evidence_id: string + document: string + section?: string + paragraph?: string + snippet?: string + url?: string +} + +/** Numbered [n] <-> evidence coupling, produced by the SDK (not parsed from the answer). */ +export interface Citation { + citation_id: string + evidence_id: string + document: string + section?: string | null + paragraph?: string | null + footnote?: string | null + figure?: string | null +} + +/** C8 / visual evidence — `visual_type` generalizes beyond figures (flowchart/bpmn/state_machine/...). */ +export interface VisualEvidence { + visual_id: string + visual_type: string + caption?: string + document: string + context?: string + image_ref?: string + vision_summary?: string +} + +export interface Footnote { + footnote_id?: string + ref?: string + document?: string + section?: string + text?: string +} + +export type AdvisorMode = 'clarify' | 'answer' + +export interface AdvisorResponse { + mode: AdvisorMode + question: string + clarity: ClarityInfo + general_answer?: string | null // L1 (clarify mode) + answer?: string | null // L2 (answer mode) + scoped_query?: string | null + evidence: EvidenceUnit[] + citations: Citation[] + visual_evidence: VisualEvidence[] + footnotes: Footnote[] +} + +export interface AdvisorRequest { + question: string + context?: string | null +} From 5a513181cc53e24528ac6c85ab475da579cee9ee Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Wed, 1 Jul 2026 12:39:47 +0200 Subject: [PATCH 5/8] feat(advisor): Clarity-Gate orchestration in route.ts (consumes /retrieve) Completes the advisor stack (FE + orchestration; /retrieve is SDK/RAG-owned). The route now returns the FE contract instead of a text stream: - retrieveFull() calls /retrieve with {query, context}; consumes clarity/evidence/ visual_evidence/footnotes (exact shape per board 2026-07-01 12:25). - mode-routing (resolveMode): clarify unless a context was chosen and /retrieve's clarity.mode says so. clarify -> L1 general answer (completeAdvisorAnswer, ungrounded, no sources). answer -> L2 answer over numbered evidence with [n] markers. - citations generated here ([n] -> nth evidence unit); footnotes remapped; evidence / visual_evidence passed through. - advisor-llm: non-streaming completeAdvisorAnswer(). Pure mappings in retrieve-mapping.ts (+ tests). Removed the dead v2 evidence.ts/evidence-adapter (RegulationRef moved to regulation-display). controls-augmentation kept (tested; re-integrable later). NOT deployed: joint deploy with the SDK /retrieve endpoint (deploy-coupling). tsc clean, 25 vitest (mapping/clarify/answer/markdown/registry/rag), check-loc 0. Co-Authored-By: Claude Opus 4.7 --- .../api/sdk/compliance-advisor/chat/route.ts | 222 +++++++----------- .../advisor-evidence-adapter.test.ts | 105 --------- .../advisor-retrieve-mapping.test.ts | 70 ++++++ admin-compliance/lib/sdk/advisor/contract.ts | 3 + .../lib/sdk/advisor/evidence-adapter.ts | 145 ------------ admin-compliance/lib/sdk/advisor/evidence.ts | 103 -------- .../lib/sdk/advisor/regulation-display.ts | 6 +- .../lib/sdk/advisor/retrieve-mapping.ts | 83 +++++++ .../lib/sdk/agents/advisor-llm.ts | 23 ++ .../lib/sdk/agents/advisor-rag.ts | 26 ++ 10 files changed, 298 insertions(+), 488 deletions(-) delete mode 100644 admin-compliance/lib/sdk/__tests__/advisor-evidence-adapter.test.ts create mode 100644 admin-compliance/lib/sdk/__tests__/advisor-retrieve-mapping.test.ts delete mode 100644 admin-compliance/lib/sdk/advisor/evidence-adapter.ts delete mode 100644 admin-compliance/lib/sdk/advisor/evidence.ts create mode 100644 admin-compliance/lib/sdk/advisor/retrieve-mapping.ts diff --git a/admin-compliance/app/api/sdk/compliance-advisor/chat/route.ts b/admin-compliance/app/api/sdk/compliance-advisor/chat/route.ts index dd1afe7d..516b9f43 100644 --- a/admin-compliance/app/api/sdk/compliance-advisor/chat/route.ts +++ b/admin-compliance/app/api/sdk/compliance-advisor/chat/route.ts @@ -1,52 +1,28 @@ /** - * Compliance Advisor Chat API — Evidence Workspace envelope. + * Compliance Advisor Chat API — Clarity-Gate orchestration. * - * Verbindet das ComplianceAdvisorWidget mit: - * 1. Strukturierter RAG-Evidence ueber die ai-compliance-sdk — siehe advisor-rag - * 2. Strukturierten Controls zum erkannten Thema — buildControlsContext - * 3. LLM-Kaskade OVH (prod) -> Ollama (Dev) — siehe advisor-llm - * - * Antwort-Format (evidence-meta-v1): ERSTE Zeile = JSON `AdvisorEvidenceMeta` - * (Quellen/Abbildungen/Fussnoten/Stats), danach streamt die Antwort als Markdown-Text. - * Das Frontend rendert NUR diese strukturierten Daten und parst NIE den Antworttext. + * 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 { retrieveAdvisorEvidence } from '@/lib/sdk/agents/advisor-rag' -import { adaptEvidence, type RawFigure, type RawFootnote } from '@/lib/sdk/advisor/evidence-adapter' -import { streamAdvisorAnswer, type ChatMessage } from '@/lib/sdk/agents/advisor-llm' +import { retrieveFull } from '@/lib/sdk/agents/advisor-rag' +import { completeAdvisorAnswer, type ChatMessage } from '@/lib/sdk/agents/advisor-llm' +import { + buildCitations, + 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: Stuetze dich auf die bereitgestellten Rechtsquellen -- Verstaendlich: Einfache, praxisnahe Sprache -- Ehrlich: Bei Unsicherheit empfehle Rechtsberatung -- Deutsch als Hauptsprache` - -// Antwort = saubere Prosa OHNE Inline-Fundstellen; die Quellen zeigt das Frontend separat an. -const FORMAT_GUIDANCE = `\n\n## Antwortformat (WICHTIG) -- Schreibe gut strukturiertes **Markdown**: kurze Abschnittsueberschriften (##), Aufzaehlungen (-), - nummerierte Schritte und **Fettung** fuer Schluesselbegriffe. Halte Absaetze kurz. -- GLIEDERE erklaerende Antworten aktiv statt langem Fliesstext: eine eigene ## Ueberschrift je - Aspekt (z.B. "Definition", "Ablauf/Phasen", "Rechtsbezug", "Praktische Bedeutung"), nummerierte - Schritte fuer Ablaeufe/Phasen, Bullet-Points fuer Aufzaehlungen. Lieber klar gegliedert als ein Block. -- Nenne Fundstellen/Quellen NICHT im Fliesstext (kein "(Art. 30 DSGVO)", keine "[Quelle 1]"). - Die Quellen werden dem Nutzer in einem EIGENEN Bereich neben der Antwort angezeigt. -- Beende die Antwort NIEMALS mit einer Quellen-/Fundstellen-Liste (kein "Quellen:", kein - "--- Quellen im RAG-System: ...", kein "Quellen im RAG-System"). KEINE Quellenaufzaehlung im - Antworttext. Dies UEBERSCHREIBT jede anderslautende Struktur-/Beispielvorgabe weiter oben im - System-Prompt (auch eine dort gezeigte "Quellen:"-Abschlusssektion gilt hier NICHT). -- Schreibe so, dass die Antwort auch ohne eingebettete Zitate vollstaendig verstaendlich ist.` - const COUNTRY_LABELS: Record = { DE: 'Deutschland', AT: 'Oesterreich', @@ -56,116 +32,94 @@ const COUNTRY_LABELS: Record = { 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.` } -/** Stellt der gestreamten Antwort eine JSON-Meta-Zeile voran (evidence-meta-v1). */ -function withEvidenceMeta(meta: unknown, answer: ReadableStream): ReadableStream { - const encoder = new TextEncoder() - const metaLine = JSON.stringify(meta) + '\n' - return new ReadableStream({ - async start(controller) { - controller.enqueue(encoder.encode(metaLine)) - const reader = answer.getReader() - try { - for (;;) { - const { done, value } = await reader.read() - if (done) break - if (value) controller.enqueue(value) - } - } catch (e) { - controller.error(e) - return - } - controller.close() - }, - }) +// 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.` + +function answerSystem(soul: string | null, country: Country | undefined, evidenceBlock: string): string { + let s = soul || FALLBACK_SYSTEM + if (country) s += countryBlock(country) + s += `\n\n## Belegte Evidence (nummeriert — DEINE EINZIGEN Quellen)\n${evidenceBlock || '(keine Evidence gefunden)'}` + s += `\n\n## Antwortformat (WICHTIG) +- Gut gegliedertes Markdown: kurze ## Ueberschriften je Aspekt, Aufzaehlungen, **Fettung** fuer Kernbegriffe. +- 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. +- 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 country = (['DE', 'AT', 'CH', 'EU'] as const).includes(body.country) + ? (body.country as Country) : undefined - // 1. Strukturierte RAG-Evidence + Controls zum Thema — parallel - const [evidence, controlsContext] = await Promise.all([ - retrieveAdvisorEvidence(message), - buildControlsContext(message), - ]) - - // 2. Evidence-Meta fuer das Frontend (strukturiert, nicht geparst) - const meta = adaptEvidence({ - results: evidence.results, - figures: evidence.figures as RawFigure[] | undefined, - footnotes: evidence.footnotes as RawFootnote[] | undefined, - }) - - // 3. System-Prompt - const soulPrompt = await readSoulFile('compliance-advisor') - let systemContent = soulPrompt || FALLBACK_SYSTEM_PROMPT - if (validCountry) systemContent += countryBlock(validCountry) - if (evidence.contextText) { - 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.\n\n${evidence.contextText}` + 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}` - systemContent += FORMAT_GUIDANCE // LAST instruction: overrides the soul's trailing "Quellen" structure/example - // 4. Nachrichten (History auf die letzten 6 begrenzen) + const retrieved = await retrieveFull(question, context) + const mode = resolveMode(retrieved.clarity?.mode, !!context) + + if (mode === 'clarify') { + const general = await completeAdvisorAnswer([ + { role: 'system', content: L1_SYSTEM }, + { 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)) }, + { role: 'user', content: question }, ] - - // 5. LLM-Kaskade -> Meta-Zeile + 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(withEvidenceMeta(meta, stream), { - headers: { - 'Content-Type': 'text/plain; charset=utf-8', - 'Cache-Control': 'no-cache', - Connection: 'keep-alive', - 'X-Advisor-Format': 'evidence-meta-v1', - }, - }) + 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 }) } } diff --git a/admin-compliance/lib/sdk/__tests__/advisor-evidence-adapter.test.ts b/admin-compliance/lib/sdk/__tests__/advisor-evidence-adapter.test.ts deleted file mode 100644 index 5a903083..00000000 --- a/admin-compliance/lib/sdk/__tests__/advisor-evidence-adapter.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { adaptEvidence } from '../advisor/evidence-adapter' -import type { SdkRagResult } from '../agents/advisor-rag' - -describe('adaptEvidence', () => { - it('maps a structured RAG result to a hierarchical Knowledge Unit', () => { - const results: SdkRagResult[] = [ - { - text: 'Der Verantwortliche fuehrt ein Verzeichnis ...', - regulation_code: 'DSGVO', - regulation_short: 'DSGVO', - article_label: 'Art. 30 DSGVO', - article: 'Art. 30', - paragraph: 'Abs. 1', - source_url: 'https://example.test/dsgvo-30', - score: 0.9, - }, - ] - const { sources, stats } = adaptEvidence({ results }) - expect(sources).toHaveLength(1) - expect(sources[0].label).toBe('Art. 30 DSGVO') - expect(sources[0].section).toBe('Art. 30') - expect(sources[0].paragraph).toBe('Abs. 1') - expect(sources[0].open?.originalUrl).toBe('https://example.test/dsgvo-30') - expect(sources[0].snippet).toContain('Verzeichnis') - expect(stats.sources).toBe(1) - expect(stats.regulations).toBe(1) - }) - - it('dedupes the same citation and keeps the highest score', () => { - const base: SdkRagResult = { - text: 'x', - regulation_code: 'CRA', - regulation_short: 'CRA', - article_label: 'Annex I', - article: 'Annex I', - } - const { sources } = adaptEvidence({ - results: [ - { ...base, score: 0.4 }, - { ...base, score: 0.8 }, - ], - }) - expect(sources).toHaveLength(1) - expect(sources[0].score).toBe(0.8) - }) - - it('counts distinct regulations in stats', () => { - const { stats } = adaptEvidence({ - results: [ - { text: 'a', regulation_code: 'DSGVO', article_label: 'Art. 5' }, - { text: 'b', regulation_code: 'DSGVO', article_label: 'Art. 6' }, - { text: 'c', regulation_code: 'BDSG', article_label: '§ 38' }, - ], - }) - expect(stats.sources).toBe(3) - expect(stats.regulations).toBe(2) - }) - - it('labels recitals as Erwaegungsgrund', () => { - const { sources } = adaptEvidence({ - results: [{ text: 'r', regulation_code: 'DSGVO', is_recital: true, article: '47' }], - }) - expect(sources[0].section).toBe('Erwägungsgrund 47') - }) - - it('maps figures (C8) to figure units and counts them', () => { - const { figures, stats } = adaptEvidence({ - results: [], - figures: [ - { - figure_id: 'fig-pdca', - label: 'Abbildung 3', - caption: 'PDCA-Zyklus', - regulation_short: 'EDPB WP248', - vision_summary: 'Kreislauf Plan-Do-Check-Act', - image_url: 'https://example.test/abb3.png', - }, - ], - }) - expect(figures).toHaveLength(1) - expect(figures[0].label).toBe('Abbildung 3') - expect(figures[0].caption).toBe('PDCA-Zyklus') - expect(figures[0].imageUrl).toBe('https://example.test/abb3.png') - expect(stats.figures).toBe(1) - }) - - it('maps footnotes (C-FN) and counts them', () => { - const { footnotes, stats } = adaptEvidence({ - results: [], - footnotes: [{ number: 17, regulation_short: 'EDPB WP248', section: 'Kapitel III.B', text: 'siehe ...' }], - }) - expect(footnotes).toHaveLength(1) - expect(footnotes[0].ref).toBe('Fußnote 17') - expect(stats.footnotes).toBe(1) - }) - - it('returns empty evidence for empty input', () => { - const meta = adaptEvidence({}) - expect(meta.sources).toEqual([]) - expect(meta.figures).toEqual([]) - expect(meta.footnotes).toEqual([]) - expect(meta.stats).toEqual({ sources: 0, regulations: 0, figures: 0, footnotes: 0 }) - }) -}) diff --git a/admin-compliance/lib/sdk/__tests__/advisor-retrieve-mapping.test.ts b/admin-compliance/lib/sdk/__tests__/advisor-retrieve-mapping.test.ts new file mode 100644 index 00000000..71e60932 --- /dev/null +++ b/admin-compliance/lib/sdk/__tests__/advisor-retrieve-mapping.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect } from 'vitest' +import { + resolveMode, + mapClarity, + mapFootnotes, + buildCitations, + numberedEvidenceForPrompt, +} 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', + }) + }) +}) diff --git a/admin-compliance/lib/sdk/advisor/contract.ts b/admin-compliance/lib/sdk/advisor/contract.ts index 77f41c60..47e4f70a 100644 --- a/admin-compliance/lib/sdk/advisor/contract.ts +++ b/admin-compliance/lib/sdk/advisor/contract.ts @@ -23,11 +23,14 @@ export interface EvidenceUnit { paragraph?: string snippet?: string url?: string + regulation_code?: string // preferred key for family grouping (from /retrieve) + context?: string // knowledge space / domain } /** 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 diff --git a/admin-compliance/lib/sdk/advisor/evidence-adapter.ts b/admin-compliance/lib/sdk/advisor/evidence-adapter.ts deleted file mode 100644 index 31880696..00000000 --- a/admin-compliance/lib/sdk/advisor/evidence-adapter.ts +++ /dev/null @@ -1,145 +0,0 @@ -// Adapter: RAG/compiler output -> the structured AdvisorEvidenceMeta the Evidence Workspace renders. -// This is the ONLY place that maps backend shapes to the frontend envelope. The frontend never -// parses the answer text — all structure originates here from structured fields. - -import type { - AdvisorEvidenceMeta, - FigureUnit, - FootnoteUnit, - KnowledgeUnit, - RegulationRef, -} from './evidence' -import { deriveStats } from './evidence' -import type { SdkRagResult } from '../agents/advisor-rag' - -/** Provisional raw figure (C8) shape — reconcile with the RAG-ingestion contract (board). */ -export interface RawFigure { - figure_id?: string - id?: string - label?: string // "Abbildung 3" - caption?: string - topic?: string - regulation_code?: string - regulation_short?: string - regulation_name?: string - section?: string - vision_summary?: string - description?: string - image_url?: string - url?: string -} - -/** Provisional raw footnote (C-FN) shape — reconcile with the RAG-ingestion contract (board). */ -export interface RawFootnote { - id?: string - ref?: string - number?: string | number - regulation_code?: string - regulation_short?: string - regulation_name?: string - section?: string - text?: string -} - -export interface RawEvidenceInput { - results?: SdkRagResult[] - figures?: RawFigure[] - footnotes?: RawFootnote[] -} - -function regulationRef( - code?: string, - name?: string, - short?: string, -): RegulationRef { - return { - code: (code || short || name || 'unknown').toLowerCase().replace(/\s+/g, '_'), - name: name || undefined, - short: short || name || code || 'Quelle', - } -} - -function truncate(text: string, max = 240): string { - const t = text.trim().replace(/\s+/g, ' ') - return t.length > max ? `${t.slice(0, max - 1)}…` : t -} - -function toKnowledgeUnit(r: SdkRagResult, idx: number): KnowledgeUnit | null { - const regulation = regulationRef(r.regulation_code, r.regulation_name, r.regulation_short) - const section = r.is_recital - ? `Erwägungsgrund ${r.article ?? ''}`.trim() - : r.article || undefined - const label = r.article_label?.trim() || undefined - // Drop empty placeholders: a unit needs at least a label or a section to be meaningful. - if (!label && !section && !regulation.name && regulation.short === 'Quelle') return null - return { - id: `src-${idx}`, - regulation, - section, - paragraph: r.paragraph || undefined, - subsection: r.sub || undefined, - label, - score: typeof r.score === 'number' ? r.score : undefined, - snippet: r.text ? truncate(r.text) : undefined, - open: r.source_url ? { originalUrl: r.source_url } : undefined, - } -} - -function dedupeKey(u: KnowledgeUnit): string { - return [u.regulation.code, u.section, u.paragraph, u.subsection, u.label] - .map((x) => x || '') - .join('|') -} - -function toFigureUnit(f: RawFigure, idx: number): FigureUnit | null { - const id = f.figure_id || f.id - const imageUrl = f.image_url || f.url - if (!id && !imageUrl && !f.label) return null - return { - id: id || `fig-${idx}`, - label: f.label || `Abbildung ${idx + 1}`, - caption: f.caption || undefined, - topic: f.topic || undefined, - source: regulationRef(f.regulation_code, f.regulation_name, f.regulation_short), - section: f.section || undefined, - visionSummary: f.vision_summary || f.description || undefined, - imageUrl: imageUrl || undefined, - } -} - -function toFootnoteUnit(f: RawFootnote, idx: number): FootnoteUnit | null { - const ref = f.ref || (f.number != null ? `Fußnote ${f.number}` : undefined) - if (!ref && !f.text) return null - return { - id: f.id || `fn-${idx}`, - ref: ref || `Fußnote ${idx + 1}`, - source: regulationRef(f.regulation_code, f.regulation_name, f.regulation_short), - section: f.section || undefined, - text: f.text || undefined, - } -} - -/** - * Build the structured evidence meta. Sources are deduped (same citation retrieved multiple - * times collapses to one, keeping the highest score) and order is preserved by score. - */ -export function adaptEvidence(input: RawEvidenceInput): AdvisorEvidenceMeta { - const seen = new Map() - ;(input.results || []).forEach((r, i) => { - const unit = toKnowledgeUnit(r, i) - if (!unit) return - const key = dedupeKey(unit) - const existing = seen.get(key) - if (!existing || (unit.score ?? 0) > (existing.score ?? 0)) seen.set(key, unit) - }) - const sources = [...seen.values()].sort((a, b) => (b.score ?? 0) - (a.score ?? 0)) - const figures = (input.figures || []) - .map(toFigureUnit) - .filter((x): x is FigureUnit => x !== null) - const footnotes = (input.footnotes || []) - .map(toFootnoteUnit) - .filter((x): x is FootnoteUnit => x !== null) - - const meta = { sources, figures, footnotes } - return { ...meta, stats: deriveStats(meta) } -} diff --git a/admin-compliance/lib/sdk/advisor/evidence.ts b/admin-compliance/lib/sdk/advisor/evidence.ts deleted file mode 100644 index 38923084..00000000 --- a/admin-compliance/lib/sdk/advisor/evidence.ts +++ /dev/null @@ -1,103 +0,0 @@ -// Structured evidence contract for the Compliance Advisor "Evidence Workspace". -// -// HARD RULE (architecture): the frontend renders ONLY these structured fields and -// NEVER parses the answer text. All structure (sources, figures, footnotes) is owned -// by the SDK/compiler (C-stages) and surfaced as data. The proxy is the adapter that -// fills this envelope from RAG/compiler output. See memory: advisor-evidence-workspace-no-parse. - -/** A regulation / document reference (CRA, EDPB WP248, MaschinenVO, ...). */ -export interface RegulationRef { - code: string // canonical id, e.g. "cra", "edpb_wp248", "maschinenvo" - name?: string // full name - short?: string // short label shown in the card header -} - -/** Openable targets for an evidence item — present only when the SDK can resolve them. */ -export interface OpenTargets { - originalUrl?: string // original text / source_url - chunkId?: string // retrieved chunk - footnoteId?: string // C-FN - figureId?: string // C8 -} - -/** - * A retrieved source as a hierarchical Knowledge Unit, mirroring the compiler: - * Regelwerk -> Section (C1/C2) -> Paragraph -> Footnote (C-FN). - * Rendered as a card, not a text-list line. E.g. "EDPB WP248 / Kapitel III.B / Fußnote 17". - */ -export interface KnowledgeUnit { - id: string - regulation: RegulationRef - section?: string // "Annex I" / "Kapitel III.B" / "Anhang III" - subsection?: string // "Abschnitt 2.3" - paragraph?: string // Absatz / paragraph - footnoteRef?: string // "Fußnote 17" when this unit IS a footnote-backed source - label?: string // pre-formatted citation fallback, e.g. "BDSG § 38 Abs. 1" - score?: number // retrieval score (optional) - snippet?: string // short passage preview (optional) — lets the user peek the cited text - open?: OpenTargets -} - -/** A figure (C8) as a Knowledge Unit — never a bare image. Only present when figures exist. */ -export interface FigureUnit { - id: string // figure_id - label: string // "Abbildung 3" - caption?: string // "PDCA-Zyklus" - topic?: string - source: RegulationRef // "EDPB ..." - section?: string - visionSummary?: string // vision/LLM description of the figure - imageUrl?: string // Playwright PNG; undefined until the RAG-ingestion contract delivers it -} - -/** A footnote (C-FN) as a first-class evidence item. */ -export interface FootnoteUnit { - id: string - ref: string // "Fußnote 17" - source: RegulationRef - section?: string - text?: string -} - -/** Counts for the stats bar above the answer ("Diese Antwort basiert auf N Quellen"). */ -export interface AdvisorStats { - sources: number - regulations: number // distinct Regelwerke - figures: number - footnotes: number -} - -/** - * Meta sent by the proxy FIRST (one JSON line), then the answer streams as tokens. - * RAG runs before the LLM, so all evidence is known up front and the panes render - * immediately while the answer streams in. - */ -export interface AdvisorEvidenceMeta { - stats: AdvisorStats - sources: KnowledgeUnit[] - figures: FigureUnit[] - footnotes: FootnoteUnit[] - relatedDocs?: KnowledgeUnit[] -} - -/** The full evidence a single answer turn holds (meta + the streamed answer markdown). */ -export interface AdvisorEvidence extends AdvisorEvidenceMeta { - answer: string // markdown prose, NO inline citations (sources live in the pane) -} - -export function emptyStats(): AdvisorStats { - return { sources: 0, regulations: 0, figures: 0, footnotes: 0 } -} - -/** Pure derivation of the stats bar from the evidence items (no parsing of answer text). */ -export function deriveStats( - e: Pick, -): AdvisorStats { - const regulations = new Set(e.sources.map((s) => s.regulation.code)) - return { - sources: e.sources.length, - regulations: regulations.size, - figures: e.figures.length, - footnotes: e.footnotes.length, - } -} diff --git a/admin-compliance/lib/sdk/advisor/regulation-display.ts b/admin-compliance/lib/sdk/advisor/regulation-display.ts index 2a125981..704f4aac 100644 --- a/admin-compliance/lib/sdk/advisor/regulation-display.ts +++ b/admin-compliance/lib/sdk/advisor/regulation-display.ts @@ -2,7 +2,11 @@ // friendly label (+ chapter for multi-part works like the DSK SDM). Presentation layer only: // it bridges G2 (clean RAG metadata) and keeps working once codes are clean. Extend the table freely. -import type { RegulationRef } from './evidence' +export interface RegulationRef { + code?: string + name?: string + short?: string +} export interface RegulationDisplay { familyKey: string // stable key used to GROUP evidence diff --git a/admin-compliance/lib/sdk/advisor/retrieve-mapping.ts b/admin-compliance/lib/sdk/advisor/retrieve-mapping.ts new file mode 100644 index 00000000..187a3a92 --- /dev/null +++ b/admin-compliance/lib/sdk/advisor/retrieve-mapping.ts @@ -0,0 +1,83 @@ +// 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') +} diff --git a/admin-compliance/lib/sdk/agents/advisor-llm.ts b/admin-compliance/lib/sdk/agents/advisor-llm.ts index fd0ecfc8..010051c8 100644 --- a/admin-compliance/lib/sdk/agents/advisor-llm.ts +++ b/admin-compliance/lib/sdk/agents/advisor-llm.ts @@ -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 { + 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 +} diff --git a/admin-compliance/lib/sdk/agents/advisor-rag.ts b/admin-compliance/lib/sdk/agents/advisor-rag.ts index 1bd3c938..2b785b23 100644 --- a/admin-compliance/lib/sdk/agents/advisor-rag.ts +++ b/admin-compliance/lib/sdk/agents/advisor-rag.ts @@ -12,6 +12,8 @@ * 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' @@ -122,3 +124,27 @@ export async function retrieveAdvisorEvidence(query: string): Promise { 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 { + 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 {} +} From 3f372bcb3981882fa3cd4b61895200327305643a Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Wed, 1 Jul 2026 13:53:17 +0200 Subject: [PATCH 6/8] =?UTF-8?q?feat(advisor):=20Phase=201=20=E2=80=94=20en?= =?UTF-8?q?dpoint=20backward-compat=20(keep=20breakpilot-workspace=20worki?= =?UTF-8?q?ng)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The advisor endpoint now serves two shapes off one orchestration: - new FE ({question}) -> v3 JSON contract (clarity/answer/evidence/citations/...). - legacy consumer ({message}, e.g. breakpilot-workspace which reads a text stream and persists raw bytes) -> plain-text stream of the L2 answer (clean prose, no [n] markup, no clarify gate). isLegacyRequest() discriminates; answerSystem() gains withCitations. Prevents the v3 contract from breaking breakpilot-workspace's chat (CLAUDE.md rule #4, keep every consumer working). No deploy. tsc clean, 13 vitest (incl. isLegacyRequest), check-loc 0. Co-Authored-By: Claude Opus 4.7 --- .../api/sdk/compliance-advisor/chat/route.ts | 44 ++++++++++++++++--- .../advisor-retrieve-mapping.test.ts | 11 +++++ .../lib/sdk/advisor/retrieve-mapping.ts | 8 ++++ 3 files changed, 57 insertions(+), 6 deletions(-) diff --git a/admin-compliance/app/api/sdk/compliance-advisor/chat/route.ts b/admin-compliance/app/api/sdk/compliance-advisor/chat/route.ts index 516b9f43..0bea8949 100644 --- a/admin-compliance/app/api/sdk/compliance-advisor/chat/route.ts +++ b/admin-compliance/app/api/sdk/compliance-advisor/chat/route.ts @@ -11,9 +11,10 @@ import { NextRequest, NextResponse } from 'next/server' import { readSoulFile } from '@/lib/sdk/agents/soul-reader' import { retrieveFull } from '@/lib/sdk/agents/advisor-rag' -import { completeAdvisorAnswer, type ChatMessage } from '@/lib/sdk/agents/advisor-llm' +import { completeAdvisorAnswer, streamAdvisorAnswer, type ChatMessage } from '@/lib/sdk/agents/advisor-llm' import { buildCitations, + isLegacyRequest, mapClarity, mapFootnotes, numberedEvidenceForPrompt, @@ -44,15 +45,24 @@ folgen belegte Quellen. Wenn der Begriff in mehreren Bereichen vorkommt, erwaehn const FALLBACK_SYSTEM = `Du bist der BreakPilot Compliance-Berater. Antworte quellenbasiert, verstaendlich und ehrlich auf Deutsch.` -function answerSystem(soul: string | null, country: Country | undefined, evidenceBlock: string): string { +function answerSystem( + soul: string | null, + country: Country | undefined, + evidenceBlock: string, + withCitations = true, +): string { let s = soul || FALLBACK_SYSTEM if (country) s += countryBlock(country) s += `\n\n## Belegte Evidence (nummeriert — DEINE EINZIGEN Quellen)\n${evidenceBlock || '(keine Evidence gefunden)'}` s += `\n\n## Antwortformat (WICHTIG) -- Gut gegliedertes Markdown: kurze ## Ueberschriften je Aspekt, Aufzaehlungen, **Fettung** fuer Kernbegriffe. -- 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. -- Triff KEINE Aussage, die nicht durch die nummerierte Evidence belegt ist; fehlt der Beleg, sage das offen.` +- Gut gegliedertes Markdown: kurze ## Ueberschriften je Aspekt, 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 } @@ -70,6 +80,28 @@ export async function POST(request: NextRequest) { } 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) }, + { 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') { diff --git a/admin-compliance/lib/sdk/__tests__/advisor-retrieve-mapping.test.ts b/admin-compliance/lib/sdk/__tests__/advisor-retrieve-mapping.test.ts index 71e60932..24cb8f60 100644 --- a/admin-compliance/lib/sdk/__tests__/advisor-retrieve-mapping.test.ts +++ b/admin-compliance/lib/sdk/__tests__/advisor-retrieve-mapping.test.ts @@ -5,6 +5,7 @@ import { mapFootnotes, buildCitations, numberedEvidenceForPrompt, + isLegacyRequest, } from '../advisor/retrieve-mapping' import type { EvidenceUnit } from '../advisor/contract' @@ -68,3 +69,13 @@ describe('mapFootnotes', () => { }) }) }) + +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) + }) +}) diff --git a/admin-compliance/lib/sdk/advisor/retrieve-mapping.ts b/admin-compliance/lib/sdk/advisor/retrieve-mapping.ts index 187a3a92..e036445f 100644 --- a/admin-compliance/lib/sdk/advisor/retrieve-mapping.ts +++ b/admin-compliance/lib/sdk/advisor/retrieve-mapping.ts @@ -81,3 +81,11 @@ export function numberedEvidenceForPrompt(evidence: EvidenceUnit[]): string { }) .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' +} From f37081b60b540bb14d85d9ec10b21ddfa6f0408f Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Wed, 1 Jul 2026 14:06:02 +0200 Subject: [PATCH 7/8] test(advisor): E2E for the Clarity-Gate chain (Playwright, stubbed endpoint) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per the "always write E2E" rule: drives the floating advisor widget end-to-end against a stubbed /api/sdk/compliance-advisor/chat with contract fixtures — clarify (L1 + context chips), answer ([n] citation + evidence pane), and clarify->pick-context->scoped-answer. No backend needed (route interception). Runs on CI/macmini (Next app on :3002); validated here via tsc + `playwright --list` (3 tests discovered). check-loc 0. Co-Authored-By: Claude Opus 4.7 --- .../e2e/specs/compliance-advisor.spec.ts | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 admin-compliance/e2e/specs/compliance-advisor.spec.ts diff --git a/admin-compliance/e2e/specs/compliance-advisor.spec.ts b/admin-compliance/e2e/specs/compliance-advisor.spec.ts new file mode 100644 index 00000000..e176794a --- /dev/null +++ b/admin-compliance/e2e/specs/compliance-advisor.spec.ts @@ -0,0 +1,99 @@ +/** + * 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' }, + ], + 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() + await expect(sdkPage.getByText('Cyber Resilience Act (CRA)')).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() + }) +}) From a9b04e5286f4aee38abaaec918c1ba0374babc26 Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Wed, 1 Jul 2026 15:17:21 +0200 Subject: [PATCH 8/8] feat(advisor): evidence-framed header + bindingness contract seam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rework the Compliance Advisor header ("Diese Antwort stuetzt sich auf") to describe the EVIDENCE rather than the documents: binding Rechtsgrundlagen split from Leitlinien (soft-law guidance), a per-regulation breakdown, plus Abbildungen, Fussnoten and Evidence Units. No fabricated trust score — objective counts only. - bindingness is a canonical Legal-KG fact (APEX rule): added an optional EvidenceUnit.bindingness contract seam; the FE renders the split from it and degrades to a neutral per-regulation breakdown when it is absent (SDK/RAG asked via board to populate it in /retrieve). - evidence-grouping.ts: pure, tested grouping/counting model. - route.ts: optional `audience` field (tonality) kept out of the retrieval question; answers lead with a "Kurz gesagt" summary, structured by theme. - E2E + unit tests updated for the evidence framing. Not deployed. Co-Authored-By: Claude Opus 4.7 --- .../api/sdk/compliance-advisor/chat/route.ts | 18 ++- .../sdk/advisor/EvidenceSummary.tsx | 124 ++++++++++++++---- .../e2e/specs/compliance-advisor.spec.ts | 7 +- .../advisor-evidence-grouping.test.ts | 85 ++++++++++++ admin-compliance/lib/sdk/advisor/contract.ts | 4 + .../lib/sdk/advisor/evidence-grouping.ts | 80 +++++++++++ 6 files changed, 290 insertions(+), 28 deletions(-) create mode 100644 admin-compliance/lib/sdk/__tests__/advisor-evidence-grouping.test.ts create mode 100644 admin-compliance/lib/sdk/advisor/evidence-grouping.ts diff --git a/admin-compliance/app/api/sdk/compliance-advisor/chat/route.ts b/admin-compliance/app/api/sdk/compliance-advisor/chat/route.ts index 0bea8949..4cd8647e 100644 --- a/admin-compliance/app/api/sdk/compliance-advisor/chat/route.ts +++ b/admin-compliance/app/api/sdk/compliance-advisor/chat/route.ts @@ -45,17 +45,26 @@ folgen belegte Quellen. Wenn der Begriff in mehreren Bereichen vorkommt, erwaehn 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) -- Gut gegliedertes Markdown: kurze ## Ueberschriften je Aspekt, Aufzaehlungen, **Fettung** fuer Kernbegriffe.` +- Beginne mit einer **Kurzzusammenfassung** (1–2 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.` @@ -71,6 +80,7 @@ export async function POST(request: NextRequest) { const body = await request.json() 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 @@ -87,7 +97,7 @@ export async function POST(request: NextRequest) { const legacyEvidence = retrieved.evidence ?? [] const legacySoul = await readSoulFile('compliance-advisor') const legacyStream = await streamAdvisorAnswer([ - { role: 'system', content: answerSystem(legacySoul, country, numberedEvidenceForPrompt(legacyEvidence), false) }, + { role: 'system', content: answerSystem(legacySoul, country, numberedEvidenceForPrompt(legacyEvidence), false, audience) }, { role: 'user', content: question }, ]) if (!legacyStream) { @@ -106,7 +116,7 @@ export async function POST(request: NextRequest) { if (mode === 'clarify') { const general = await completeAdvisorAnswer([ - { role: 'system', content: L1_SYSTEM }, + { role: 'system', content: L1_SYSTEM + audienceBlock(audience) }, { role: 'user', content: question }, ]) if (general === null) { @@ -130,7 +140,7 @@ export async function POST(request: NextRequest) { const evidence = retrieved.evidence ?? [] const soul = await readSoulFile('compliance-advisor') const messages: ChatMessage[] = [ - { role: 'system', content: answerSystem(soul, country, numberedEvidenceForPrompt(evidence)) }, + { role: 'system', content: answerSystem(soul, country, numberedEvidenceForPrompt(evidence), true, audience) }, { role: 'user', content: question }, ] const answer = await completeAdvisorAnswer(messages) diff --git a/admin-compliance/components/sdk/advisor/EvidenceSummary.tsx b/admin-compliance/components/sdk/advisor/EvidenceSummary.tsx index b4e313e2..af1f1869 100644 --- a/admin-compliance/components/sdk/advisor/EvidenceSummary.tsx +++ b/admin-compliance/components/sdk/advisor/EvidenceSummary.tsx @@ -1,10 +1,16 @@ 'use client' -import { FileText, Hash, Image as ImageIcon, Library } from 'lucide-react' +import { BookMarked, FileText, Hash, Image as ImageIcon, Library, Scale } from 'lucide-react' import type { AdvisorResponse } from '@/lib/sdk/advisor/contract' -import { resolveRegulation } from '@/lib/sdk/advisor/regulation-display' +import { + provisionSummary, + summarizeEvidence, + type FamilyGroup, +} from '@/lib/sdk/advisor/evidence-grouping' -function Card({ +const plural = (n: number, one: string, many: string) => (n === 1 ? one : many) + +function Count({ icon, value, label, @@ -30,26 +36,100 @@ function Card({ ) } -/** - * "Antwort basiert auf" — objective counts only (no fabricated trust score). Regelwerke = distinct - * document families. Leitlinien deliberately omitted until bindingness exists in the Legal-KG. - */ -export function EvidenceSummary({ response }: { response: AdvisorResponse }) { - const families = new Set( - response.evidence.map((e) => resolveRegulation({ code: e.document, short: e.document }).familyKey), - ).size - const cls = 'h-4 w-4' +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 ( -
    -
    - Antwort basiert auf -
    -
    - } value={families} label="Regelwerke" /> - } value={response.evidence.length} label="Evidence Units" /> - } value={response.visual_evidence.length} label="Diagramme" dim={response.visual_evidence.length === 0} /> - } value={response.footnotes.length} label="Fußnoten" dim={response.footnotes.length === 0} /> -
    +
    + {icon} + {group.label} + {detail && {detail}} +
    + ) +} + +function Section({ + title, + groups, + icon, +}: { + title: string + groups: FamilyGroup[] + icon: React.ReactNode +}) { + if (groups.length === 0) return null + return ( +
    +
    {title}
    + {groups.map((g) => ( + + ))} +
    + ) +} + +/** + * "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 ( +
    +
    + Diese Antwort stützt sich auf +
    + +
    + {m.hasBindingness && ( + <> + } + value={m.normProvisions} + label={plural(m.normProvisions, 'Rechtsgrundlage', 'Rechtsgrundlagen')} + /> + } + value={m.guidanceCount} + label={plural(m.guidanceCount, 'Leitlinie', 'Leitlinien')} + dim={m.guidanceCount === 0} + /> + + )} + } + value={figures} + label={plural(figures, 'Abbildung', 'Abbildungen')} + dim={figures === 0} + /> + } + value={notes} + label={plural(notes, 'Fußnote', 'Fußnoten')} + dim={notes === 0} + /> + } value={m.unitCount} label="Evidence Units" /> +
    + + {m.groups.length > 0 && ( +
    + {m.hasBindingness ? ( + <> +
    } /> +
    } /> +
    } /> + + ) : ( + m.groups.map((g) => } />) + )} +
    + )}
    ) } diff --git a/admin-compliance/e2e/specs/compliance-advisor.spec.ts b/admin-compliance/e2e/specs/compliance-advisor.spec.ts index e176794a..5bf1cca7 100644 --- a/admin-compliance/e2e/specs/compliance-advisor.spec.ts +++ b/admin-compliance/e2e/specs/compliance-advisor.spec.ts @@ -40,7 +40,7 @@ const ANSWER = { 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' }, + { 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' }, @@ -77,7 +77,10 @@ test.describe('Compliance Advisor — Clarity Gate', () => { await expect(sdkPage.getByText(/unverzüglich/)).toBeVisible() await expect(sdkPage.getByTitle('Beleg 1 anzeigen')).toBeVisible() - await expect(sdkPage.getByText('Cyber Resilience Act (CRA)')).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 }) => { diff --git a/admin-compliance/lib/sdk/__tests__/advisor-evidence-grouping.test.ts b/admin-compliance/lib/sdk/__tests__/advisor-evidence-grouping.test.ts new file mode 100644 index 00000000..6bbc5b37 --- /dev/null +++ b/admin-compliance/lib/sdk/__tests__/advisor-evidence-grouping.test.ts @@ -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 & { 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') + }) +}) diff --git a/admin-compliance/lib/sdk/advisor/contract.ts b/admin-compliance/lib/sdk/advisor/contract.ts index 47e4f70a..38da6304 100644 --- a/admin-compliance/lib/sdk/advisor/contract.ts +++ b/admin-compliance/lib/sdk/advisor/contract.ts @@ -25,6 +25,10 @@ export interface EvidenceUnit { 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). */ diff --git a/admin-compliance/lib/sdk/advisor/evidence-grouping.ts b/admin-compliance/lib/sdk/advisor/evidence-grouping.ts new file mode 100644 index 00000000..9f783b5e --- /dev/null +++ b/admin-compliance/lib/sdk/advisor/evidence-grouping.ts @@ -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() + 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, + } +}