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 }