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..4cd8647e 100644 --- a/admin-compliance/app/api/sdk/compliance-advisor/chat/route.ts +++ b/admin-compliance/app/api/sdk/compliance-advisor/chat/route.ts @@ -1,34 +1,29 @@ /** - * Compliance Advisor Chat API + * Compliance Advisor Chat API — Clarity-Gate orchestration. * - * Verbindet das ComplianceAdvisorWidget mit: - * 1. Multi-Collection-RAG ueber die ai-compliance-sdk (bge-m3) — siehe advisor-rag - * 2. Strukturierten Controls zum erkannten Thema — buildControlsContext - * 3. LLM-Kaskade OVH (prod) -> Ollama (Dev) — siehe advisor-llm - * - * Laenderspezifische Filterung (DE, AT, CH, EU). Streamt die Antwort als Text. + * Consumes the SDK/RAG /retrieve (evidence/visual_evidence/footnotes/clarity) and returns the + * FE-facing contract (advisor-clarity-gate-contract): + * - clarify mode -> short L1 general answer (no RAG) + domain context chips + * - answer mode -> L2 answer over the scoped evidence with [n] citation markers + * Citations are generated here ([n] -> nth evidence unit). The FE renders ONLY this structured data. */ import { NextRequest, NextResponse } from 'next/server' import { readSoulFile } from '@/lib/sdk/agents/soul-reader' -import { buildControlsContext } from '@/lib/sdk/agents/controls-augmentation' -import { queryAdvisorRAG } from '@/lib/sdk/agents/advisor-rag' -import { streamAdvisorAnswer, type ChatMessage } from '@/lib/sdk/agents/advisor-llm' +import { retrieveFull } from '@/lib/sdk/agents/advisor-rag' +import { completeAdvisorAnswer, streamAdvisorAnswer, type ChatMessage } from '@/lib/sdk/agents/advisor-llm' +import { + buildCitations, + isLegacyRequest, + mapClarity, + mapFootnotes, + numberedEvidenceForPrompt, + resolveMode, +} from '@/lib/sdk/advisor/retrieve-mapping' +import type { AdvisorResponse } from '@/lib/sdk/advisor/contract' type Country = 'DE' | 'AT' | 'CH' | 'EU' -const FALLBACK_SYSTEM_PROMPT = `# Compliance Advisor Agent - -## Identitaet -Du bist der BreakPilot Compliance-Berater. Du hilfst Nutzern des AI Compliance SDK, -Datenschutz- und Compliance-Fragen in verstaendlicher Sprache zu beantworten. - -## Kernprinzipien -- Quellenbasiert: Verweise auf DSGVO-Artikel, BDSG-Paragraphen -- Verstaendlich: Einfache, praxisnahe Sprache -- Ehrlich: Bei Unsicherheit empfehle Rechtsberatung -- Deutsch als Hauptsprache` - const COUNTRY_LABELS: Record = { DE: 'Deutschland', AT: 'Oesterreich', @@ -38,84 +33,135 @@ 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.` +} + +// L1: general knowledge, deliberately NOT grounded (the clarify step precedes the legal retrieval). +const L1_SYSTEM = `Du bist der BreakPilot Compliance-Berater. Gib eine KURZE, allgemeine Definition/Erklaerung +des gefragten Begriffs aus Allgemeinwissen — 2 bis 4 Saetze, Markdown, neutral. NENNE KEINE Rechtsquellen, +Paragraphen, Artikel oder Fundstellen; der Nutzer waehlt anschliessend einen konkreten Kontext, erst dann +folgen belegte Quellen. Wenn der Begriff in mehreren Bereichen vorkommt, erwaehne das in einem Halbsatz.` + +const FALLBACK_SYSTEM = `Du bist der BreakPilot Compliance-Berater. Antworte quellenbasiert, verstaendlich und ehrlich auf Deutsch.` + +// Optional audience/tonality guidance (e.g. the workspace's role hint). Kept out of the retrieval +// `question` on purpose — it only shapes the answer's tone, so it belongs in the system prompt. +function audienceBlock(audience: string): string { + return audience ? `\n\n## Ansprache / Zielgruppe\n${audience}` : '' +} + +function answerSystem( + soul: string | null, + country: Country | undefined, + evidenceBlock: string, + withCitations = true, + audience = '', +): string { + let s = soul || FALLBACK_SYSTEM + if (country) s += countryBlock(country) + s += audienceBlock(audience) + s += `\n\n## Belegte Evidence (nummeriert — DEINE EINZIGEN Quellen)\n${evidenceBlock || '(keine Evidence gefunden)'}` + s += `\n\n## Antwortformat (WICHTIG) +- Beginne mit einer **Kurzzusammenfassung** (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.` + } else { + s += `\n- Nenne Fundstellen nur, wo sie der Antwort dienen (natuerlich im Text, KEIN [n]-Markup).` + } + s += `\n- Triff KEINE Aussage, die nicht durch die nummerierte Evidence belegt ist; fehlt der Beleg, sage das offen.` + return s } export async function POST(request: NextRequest) { try { const body = await request.json() - const { message, history = [], currentStep = 'default', country } = body - - if (!message || typeof message !== 'string') { - return NextResponse.json({ error: 'Message is required' }, { status: 400 }) - } - - const validCountry = (['DE', 'AT', 'CH', 'EU'] as const).includes(country) - ? (country as Country) + const question = String(body.question ?? body.message ?? '').trim() + const context: string | null = body.context ?? null + const audience = typeof body.audience === 'string' ? body.audience.trim() : '' + const country = (['DE', 'AT', 'CH', 'EU'] as const).includes(body.country) + ? (body.country as Country) : undefined - // 1. RAG (ai-sdk, bge-m3) + strukturierte Controls zum Thema — beide parallel - const [ragContext, controlsContext] = await Promise.all([ - queryAdvisorRAG(message), - buildControlsContext(message), - ]) - - // 2. System-Prompt zusammenbauen - const soulPrompt = await readSoulFile('compliance-advisor') - let systemContent = soulPrompt || FALLBACK_SYSTEM_PROMPT - if (validCountry) systemContent += countryBlock(validCountry) - if (ragContext) { - systemContent += `\n\n## Relevanter Kontext aus dem RAG-System (deine EINZIGEN Rechtsquellen)\n\nDies sind deine einzigen zulaessigen Rechtsquellen. Triff keine konkrete Rechtsaussage (Zahl, Frist, Schwelle, Pflicht, Fundstelle), die nicht hier oder im Controls-Block belegt ist — sonst sage offen, dass du sie aus deinen Quellen nicht belegen kannst. Verweise in deiner Antwort auf die jeweilige Quelle:\n\n${ragContext}` + if (!question) { + return NextResponse.json({ error: 'Question is required' }, { status: 400 }) } - if (controlsContext) systemContent += `\n\n${controlsContext}` - systemContent += `\n\n## Aktueller SDK-Schritt\nDer Nutzer befindet sich im SDK-Schritt: ${currentStep}` - // 3. Nachrichten (History auf die letzten 6 begrenzen) + const retrieved = await retrieveFull(question, context) + + // Backward-compat: legacy consumers (breakpilot-workspace) send {message} and read a plain-text + // stream. Serve the L2 answer streamed (clean prose, no [n]); no clarify gate, no JSON. + if (isLegacyRequest(body)) { + const legacyEvidence = retrieved.evidence ?? [] + const legacySoul = await readSoulFile('compliance-advisor') + const legacyStream = await streamAdvisorAnswer([ + { role: 'system', content: answerSystem(legacySoul, country, numberedEvidenceForPrompt(legacyEvidence), false, audience) }, + { role: 'user', content: question }, + ]) + if (!legacyStream) { + return NextResponse.json({ error: 'LLM nicht erreichbar.' }, { status: 502 }) + } + return new NextResponse(legacyStream, { + headers: { + 'Content-Type': 'text/plain; charset=utf-8', + 'Cache-Control': 'no-cache', + 'X-Advisor-Format': 'legacy-stream', + }, + }) + } + + const mode = resolveMode(retrieved.clarity?.mode, !!context) + + if (mode === 'clarify') { + const general = await completeAdvisorAnswer([ + { role: 'system', content: L1_SYSTEM + audienceBlock(audience) }, + { role: 'user', content: question }, + ]) + if (general === null) { + return NextResponse.json({ error: 'LLM nicht erreichbar.' }, { status: 502 }) + } + const resp: AdvisorResponse = { + mode: 'clarify', + question, + clarity: mapClarity(retrieved.clarity, 'clarify'), + general_answer: general, + answer: null, + scoped_query: null, + evidence: [], + citations: [], + visual_evidence: [], + footnotes: [], + } + return NextResponse.json(resp) + } + + const evidence = retrieved.evidence ?? [] + const soul = await readSoulFile('compliance-advisor') const messages: ChatMessage[] = [ - { role: 'system', content: systemContent }, - ...history.slice(-6).map((h: { role: string; content: string }) => ({ - role: h.role === 'user' ? 'user' : 'assistant', - content: h.content, - })), - { role: 'user', content: message }, + { role: 'system', content: answerSystem(soul, country, numberedEvidenceForPrompt(evidence), true, audience) }, + { role: 'user', content: question }, ] - - // 4. LLM-Kaskade -> Plain-Text-Stream - const stream = await streamAdvisorAnswer(messages) - if (!stream) { - return NextResponse.json( - { error: 'LLM nicht erreichbar. Weder OVH/LiteLLM noch Ollama haben geantwortet.' }, - { status: 502 }, - ) + const answer = await completeAdvisorAnswer(messages) + if (answer === null) { + return NextResponse.json({ error: 'LLM nicht erreichbar.' }, { status: 502 }) } - - return new NextResponse(stream, { - headers: { - 'Content-Type': 'text/plain; charset=utf-8', - 'Cache-Control': 'no-cache', - Connection: 'keep-alive', - }, - }) + const resp: AdvisorResponse = { + mode: 'answer', + question, + clarity: mapClarity(retrieved.clarity, 'answer'), + general_answer: null, + answer, + scoped_query: context, + evidence, + citations: buildCitations(evidence), + visual_evidence: retrieved.visual_evidence ?? [], + footnotes: mapFootnotes(retrieved.footnotes), + } + return NextResponse.json(resp) } catch (error) { console.error('Compliance advisor chat error:', error) - return NextResponse.json( - { error: 'Verbindung zum LLM fehlgeschlagen.' }, - { status: 503 }, - ) + return NextResponse.json({ error: 'Verbindung zum Advisor fehlgeschlagen.' }, { status: 503 }) } } 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..90b41e8c 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 { useAdvisorCase } from './advisor/useAdvisorCase' +import { useAdvisorEmail } from './advisor/useAdvisorEmail' interface ComplianceAdvisorWidgetProps { currentStep?: string } type Country = 'DE' | 'AT' | 'CH' | 'EU' +const COUNTRIES: Country[] = ['DE', 'AT', 'CH', 'EU'] -const COUNTRIES: { code: Country; label: string }[] = [ - { code: 'DE', label: 'DE' }, - { code: 'AT', label: 'AT' }, - { code: 'CH', label: 'CH' }, - { code: 'EU', label: 'EU' }, -] - -// ============================================================================= -// COMPONENT -// ============================================================================= - +/** + * Compliance Advisor — a floating Case Workspace on every SDK page. + * Renders ONLY structured SDK data (clarify/answer contract); it never parses the answer text. + * See memory: advisor-evidence-workspace-no-parse, advisor-clarity-gate-contract. + */ export function ComplianceAdvisorWidget({ currentStep = 'default' }: ComplianceAdvisorWidgetProps) { const [isOpen, setIsOpen] = useState(false) const [isExpanded, setIsExpanded] = useState(false) - const [messages, setMessages] = useState([]) 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 { cases, busy, ask, selectContext, stop } = useAdvisorCase({ currentStep, country }) + const email = useAdvisorEmail(cases, country, currentStep) const exampleQuestions = EXAMPLE_QUESTIONS[currentStep] || EXAMPLE_QUESTIONS.default - useEffect(() => { - messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) - }, [messages]) - - useEffect(() => { - return () => { - abortControllerRef.current?.abort() - } - }, []) - - const handleSendMessage = useCallback( - async (content: string) => { - if (!content.trim() || isTyping) return - - const userMessage: Message = { - id: `msg-${Date.now()}`, - role: 'user', - content: content.trim(), - timestamp: new Date(), - } - - setMessages((prev) => [...prev, userMessage]) + const submit = useCallback( + (q: string) => { + if (!q.trim() || busy) return setInputValue('') - setIsTyping(true) - - const agentMessageId = `msg-${Date.now()}-agent` - abortControllerRef.current = new AbortController() - - try { - const history = messages.map((m) => ({ - role: m.role === 'user' ? 'user' : 'assistant', - content: m.content, - })) - - const response = await fetch('/api/sdk/compliance-advisor/chat', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - message: content.trim(), - history, - currentStep, - country: selectedCountry, - }), - signal: abortControllerRef.current.signal, - }) - - if (!response.ok) { - const errorData = await response.json().catch(() => ({ error: 'Unbekannter Fehler' })) - throw new Error(errorData.error || `Server-Fehler (${response.status})`) - } - - setMessages((prev) => [ - ...prev, - { id: agentMessageId, role: 'agent', content: '', timestamp: new Date() }, - ]) - - const reader = response.body!.getReader() - const decoder = new TextDecoder() - let accumulated = '' - - while (true) { - const { done, value } = await reader.read() - if (done) break - - accumulated += decoder.decode(value, { stream: true }) - const currentText = accumulated - setMessages((prev) => - prev.map((m) => (m.id === agentMessageId ? { ...m, content: currentText } : m)) - ) - requestAnimationFrame(() => { - messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) - }) - } - - setIsTyping(false) - } catch (error) { - if ((error as Error).name === 'AbortError') { - setIsTyping(false) - return - } - - const errorMessage = error instanceof Error ? error.message : 'Verbindung fehlgeschlagen' - setMessages((prev) => { - const hasAgent = prev.some((m) => m.id === agentMessageId) - if (hasAgent) { - return prev.map((m) => - m.id === agentMessageId ? { ...m, content: `Fehler: ${errorMessage}` } : m - ) - } - return [ - ...prev, - { id: agentMessageId, role: 'agent' as const, content: `Fehler: ${errorMessage}`, timestamp: new Date() }, - ] - }) - setIsTyping(false) - } + ask(q) }, - [isTyping, messages, currentStep, selectedCountry] + [busy, ask], ) - const handleStopGeneration = useCallback(() => { - abortControllerRef.current?.abort() - setIsTyping(false) - }, []) - - const [emailSending, setEmailSending] = useState(false) - const [emailSent, setEmailSent] = useState(false) - - const handleSendAsEmail = useCallback(async () => { - if (messages.length === 0 || emailSending) return - setEmailSending(true) - try { - // Build HTML from chat messages - const qaPairs = messages.reduce<{ q: string; a: string }[]>((acc, m, i) => { - if (m.role === 'user') { - const next = messages[i + 1] - acc.push({ q: m.content, a: next?.role === 'agent' ? next.content : '(keine Antwort)' }) - } - return acc - }, []) - - const qaHtml = qaPairs.map(({ q, a }) => - `

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,102 @@ 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 && ( + {cases.length > 0 && ( )} -
- {/* Messages Area */} -
- {messages.length === 0 ? ( - handleSendMessage(q)} - /> - ) : ( - - )} -
+ - {/* Input Area */} -
+
setInputValue(e.target.value)} - onKeyDown={handleKeyDown} + onKeyDown={onKeyDown} placeholder="Frage eingeben..." - disabled={isTyping} - className="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent disabled:opacity-50" + disabled={busy} + className="flex-1 rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-transparent focus:outline-none focus:ring-2 focus:ring-purple-500 disabled:opacity-50" /> - {isTyping ? ( - ) : ( )}
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/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/EvidencePane.tsx b/admin-compliance/components/sdk/advisor/EvidencePane.tsx new file mode 100644 index 00000000..871a71bb --- /dev/null +++ b/admin-compliance/components/sdk/advisor/EvidencePane.tsx @@ -0,0 +1,76 @@ +'use client' + +import { useState } from 'react' +import { ChevronDown, ChevronRight, Library } from 'lucide-react' +import type { EvidenceUnit } from '@/lib/sdk/advisor/contract' +import { resolveRegulation } from '@/lib/sdk/advisor/regulation-display' +import { EvidenceUnitCard } from './EvidenceUnitCard' +import { PaneHeader } from './PaneHeader' + +interface Group { + key: string + label: string + units: EvidenceUnit[] +} + +function groupByFamily(units: EvidenceUnit[]): Group[] { + const map = new Map() + for (const u of units) { + const d = resolveRegulation({ code: u.document, short: u.document }) + const g = map.get(d.familyKey) ?? { key: d.familyKey, label: d.familyLabel, units: [] } + g.units.push(u) + map.set(d.familyKey, g) + } + return [...map.values()].sort((a, b) => b.units.length - a.units.length) +} + +function EvidenceGroup({ group, highlightedId }: { group: Group; highlightedId?: string }) { + const [open, setOpen] = useState(group.units.length <= 3) + return ( +
+ + {open && ( +
+ {group.units.map((u) => ( + + ))} +
+ )} +
+ ) +} + +/** 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={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 new file mode 100644 index 00000000..af1f1869 --- /dev/null +++ b/admin-compliance/components/sdk/advisor/EvidenceSummary.tsx @@ -0,0 +1,135 @@ +'use client' + +import { BookMarked, FileText, Hash, Image as ImageIcon, Library, Scale } from 'lucide-react' +import type { AdvisorResponse } from '@/lib/sdk/advisor/contract' +import { + provisionSummary, + summarizeEvidence, + type FamilyGroup, +} from '@/lib/sdk/advisor/evidence-grouping' + +const plural = (n: number, one: string, many: string) => (n === 1 ? one : many) + +function Count({ + icon, + value, + label, + dim, +}: { + icon: React.ReactNode + value: number + label: string + dim?: boolean +}) { + return ( +
+ {icon} + + {value}{' '} + {label} + +
+ ) +} + +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 ( +
+ {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/components/sdk/advisor/EvidenceUnitCard.tsx b/admin-compliance/components/sdk/advisor/EvidenceUnitCard.tsx new file mode 100644 index 00000000..f89a2bbd --- /dev/null +++ b/admin-compliance/components/sdk/advisor/EvidenceUnitCard.tsx @@ -0,0 +1,80 @@ +'use client' + +import { useState } from 'react' +import { ChevronDown, ChevronRight, ExternalLink } from 'lucide-react' +import type { EvidenceUnit } from '@/lib/sdk/advisor/contract' +import { resolveRegulation } from '@/lib/sdk/advisor/regulation-display' + +/** One evidence unit (contract shape). Compact inside a document group: chapter/section only. */ +export function EvidenceUnitCard({ + unit, + compact, + highlighted, +}: { + unit: EvidenceUnit + compact?: boolean + highlighted?: boolean +}) { + const [open, setOpen] = useState(false) + const d = resolveRegulation({ code: unit.document, short: unit.document }) + const crumbs = [unit.section, unit.paragraph].filter((x): x is string => Boolean(x)) + const canOpen = !!unit.url && /^https?:\/\//i.test(unit.url) + + const header = compact ? (d.chapter ? `Kapitel ${d.chapter}` : crumbs[0] || d.familyLabel) : d.familyLabel + const sub = compact && !d.chapter && crumbs.length ? crumbs.slice(1) : crumbs + + return ( +
+
+
+
{header}
+ {sub.length > 0 && ( +
+ {sub.map((c, i) => ( + + {i > 0 && } + {c} + + ))} +
+ )} +
+ {canOpen && ( + + + öffnen + + )} +
+ {unit.snippet && ( +
+ + {open && ( +

+ {unit.snippet} +

+ )} +
+ )} +
+ ) +} diff --git a/admin-compliance/components/sdk/advisor/EvidenceWorkspace.tsx b/admin-compliance/components/sdk/advisor/EvidenceWorkspace.tsx new file mode 100644 index 00000000..cd32ec3f --- /dev/null +++ b/admin-compliance/components/sdk/advisor/EvidenceWorkspace.tsx @@ -0,0 +1,136 @@ +'use client' + +import { useEffect, useRef, useState } from 'react' +import type { AdvisorCase } from './useAdvisorCase' +import { StickyQuestion } from './StickyQuestion' +import { AdvisorEmptyState } from './EmptyState' +import { CaseView, LoadingDots, ErrorBox } from './CaseView' +import { ClarifyView } from './ClarifyView' +import { EvidenceSummary } from './EvidenceSummary' +import { EvidencePane } from './EvidencePane' +import { VisualEvidencePane } from './VisualEvidencePane' +import { FootnotesPane } from './FootnotesPane' +import { Markdown } from './Markdown' +import { useCitationHighlight } from './useCitationHighlight' + +/** + * Advisor body as a series of CASES. + * - Narrow: stacked cases with a pinned last question. + * - Wide: 3-column Case Workspace — question+summary (left) | answer/clarify (center) | evidence (right). + */ +export function EvidenceWorkspace({ + cases, + expanded, + busy, + exampleQuestions, + onExample, + onSelectContext, +}: { + cases: AdvisorCase[] + expanded: boolean + busy: boolean + exampleQuestions: string[] + onExample: (q: string) => void + onSelectContext: (caseId: string, ctx: string) => void +}) { + const [activeId, setActiveId] = useState(null) + const endRef = useRef(null) + const latest = cases[cases.length - 1] + const active = cases.find((c) => c.id === activeId) ?? latest + + useEffect(() => { + setActiveId(null) + }, [cases.length]) + useEffect(() => { + if (!expanded) endRef.current?.scrollIntoView({ behavior: 'smooth' }) + }, [cases.length, expanded]) + + const answer = active?.response?.mode === 'answer' ? active.response : null + const { highlightedId, cite } = useCitationHighlight(answer?.citations ?? []) + + if (cases.length === 0) { + return ( +
+ +
+ ) + } + + if (!expanded) { + return ( +
+ {latest && } +
+ {cases.map((c, i) => ( + onSelectContext(c.id, ctx)} + /> + ))} +
+
+
+ ) + } + + const r = active?.response + return ( +
+ + +
+ {active?.status === 'loading' && } + {active?.status === 'error' && } + {r?.mode === 'clarify' && ( + active && onSelectContext(active.id, ctx)} + /> + )} + {r?.mode === 'answer' && ( +
+ +
+ )} +
+ + +
+ ) +} diff --git a/admin-compliance/components/sdk/advisor/FootnotesPane.tsx b/admin-compliance/components/sdk/advisor/FootnotesPane.tsx new file mode 100644 index 00000000..48bde062 --- /dev/null +++ b/admin-compliance/components/sdk/advisor/FootnotesPane.tsx @@ -0,0 +1,30 @@ +'use client' + +import { Hash } from 'lucide-react' +import type { Footnote } from '@/lib/sdk/advisor/contract' +import { PaneHeader } from './PaneHeader' + +/** Footnotes pane (C-FN) — rendered only when present. */ +export function FootnotesPane({ footnotes }: { footnotes: Footnote[] }) { + if (footnotes.length === 0) return null + return ( +
+ } title="Fußnoten" count={footnotes.length} /> +
+ {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/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..a1bdd2df --- /dev/null +++ b/admin-compliance/components/sdk/advisor/Markdown.tsx @@ -0,0 +1,176 @@ +'use client' + +// Minimal, SAFE markdown -> React renderer. No dangerouslySetInnerHTML, no dependency. +// Covers the subset LLMs emit: headings, bold, italic, inline code, fenced code, ul/ol, links. +// Plus deliberate [n] citation markers (mapped via `citations`, NOT parsed for structure). + +export interface CiteHandler { + count: number + onSelect: (n: number) => void +} + +const INLINE_RE = + /(`[^`]+`|\*\*[^*]+\*\*|\*[^*\s][^*]*\*|_[^_]+_|\[[^\]]+\]\([^)]+\)|\[\d+\])/g + +function renderInline(text: string, kp: string, cite?: CiteHandler): React.ReactNode[] { + const nodes: React.ReactNode[] = [] + let last = 0 + let idx = 0 + INLINE_RE.lastIndex = 0 + let m: RegExpExecArray | null + while ((m = INLINE_RE.exec(text)) !== null) { + if (m.index > last) nodes.push(text.slice(last, m.index)) + const tok = m[0] + const key = `${kp}-${idx++}` + if (tok.startsWith('`')) { + nodes.push( + + {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 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])) { + 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, 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}
+} + +const UL_RE = /^\s*[-*]\s+/ +const OL_RE = /^\s*\d+\.\s+/ +const H_RE = /^(#{1,6})\s+(.*)$/ + +export function Markdown({ content, citations }: { content: string; citations?: CiteHandler }) { + const lines = (content || '').replace(/\r\n/g, '\n').split('\n') + const blocks: React.ReactNode[] = [] + let i = 0 + while (i < lines.length) { + const line = lines[i] + const key = `b${blocks.length}` + + if (line.trim().startsWith('```')) { + const buf: string[] = [] + i++ + while (i < lines.length && !lines[i].trim().startsWith('```')) { + buf.push(lines[i]) + i++ + } + i++ + blocks.push( +
+          {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}`, citations)}
  • + ))} +
, + ) + 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}`, citations)}
  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, citations)} +

, + ) + } + 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/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/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 new file mode 100644 index 00000000..b06c6f71 --- /dev/null +++ b/admin-compliance/components/sdk/advisor/useAdvisorEmail.ts @@ -0,0 +1,72 @@ +'use client' + +import { useCallback, useState } from 'react' +import type { AdvisorCase } from './useAdvisorCase' + +function esc(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') +} + +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 `

    Evidence:

      ${items}
    ` +} + +/** Sends the consultation cases (question + answer + evidence) as an email to the DSB. */ +export function useAdvisorEmail(cases: AdvisorCase[], country: string, currentStep: string) { + const [sending, setSending] = useState(false) + const [sent, setSent] = useState(false) + + const send = useCallback(async () => { + if (cases.length === 0 || sending) return + setSending(true) + try { + const qaHtml = cases + .map((c) => { + const a = c.response?.answer || c.response?.general_answer || '(keine Antwort)' + return `

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

    ${esc(a)}

    ${evidenceHtml(c)}
    ` + }) + .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 — ${cases.length} Fragen (${currentStep})`, + body_html: bodyHtml, + role: 'Datenschutzbeauftragter', + }), + }) + setSent(true) + setTimeout(() => setSent(false), 3000) + } catch (e) { + console.error('Email send failed:', e) + } finally { + setSending(false) + } + }, [cases, sending, country, currentStep]) + + return { send, sending, sent } +} 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/e2e/specs/compliance-advisor.spec.ts b/admin-compliance/e2e/specs/compliance-advisor.spec.ts new file mode 100644 index 00000000..5bf1cca7 --- /dev/null +++ b/admin-compliance/e2e/specs/compliance-advisor.spec.ts @@ -0,0 +1,102 @@ +/** + * E2E: Compliance Advisor — Clarity Gate (v3 contract) + * + * Drives the floating advisor widget end-to-end against a stubbed /api/sdk/compliance-advisor/chat + * (contract fixtures), so the whole FE chain is exercised without the RAG/LLM backend: + * - underspecified question -> clarify mode (L1 general answer + domain context chips) + * - specific question -> answer mode (markdown + [n] citation coupling + evidence pane) + * - clarify -> pick a context -> scoped answer + * Runs on CI / macmini (needs the Next app on :3002). + */ + +import { test, expect } from '../fixtures/sdk-fixtures' + +const CHAT_ROUTE = '**/api/sdk/compliance-advisor/chat' +const openAdvisor = 'Compliance Advisor oeffnen' +const inputPlaceholder = 'Frage eingeben...' + +const CLARIFY = { + mode: 'clarify', + question: 'Was ist PDCA?', + clarity: { + is_underspecified: true, + concentration: 0.3, + suggested_contexts: [ + { id: 'datenschutz', label: 'Datenschutz' }, + { id: 'cyber', label: 'Cybersecurity' }, + ], + }, + general_answer: 'PDCA steht für **Plan-Do-Check-Act**.', + answer: null, + evidence: [], + citations: [], + visual_evidence: [], + footnotes: [], +} + +const ANSWER = { + mode: 'answer', + question: 'CRA Meldefrist', + clarity: { is_underspecified: false, dominant_context: 'cyber', concentration: 0.88 }, + answer: 'Die Meldung erfolgt unverzüglich [1].', + evidence: [ + { evidence_id: 'e1', document: 'CRA', section: 'Art. 14', paragraph: 'Abs. 1', snippet: 'unverzüglich melden', bindingness: 'binding' }, + ], + citations: [ + { citation_id: 'c1', number: 1, evidence_id: 'e1', document: 'CRA', section: 'Art. 14', paragraph: 'Abs. 1' }, + ], + visual_evidence: [], + footnotes: [], +} + +async function ask(page: import('@playwright/test').Page, question: string) { + await page.getByRole('button', { name: openAdvisor }).click() + const input = page.getByPlaceholder(inputPlaceholder) + await input.fill(question) + await input.press('Enter') +} + +test.describe('Compliance Advisor — Clarity Gate', () => { + test('underspecified question -> clarify (L1 definition + context chips, no evidence)', async ({ sdkPage }) => { + await sdkPage.route(CHAT_ROUTE, (r) => + r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(CLARIFY) }), + ) + await ask(sdkPage, 'Was ist PDCA?') + + await expect(sdkPage.getByText('Allgemeine Definition')).toBeVisible() + await expect(sdkPage.getByText('Plan-Do-Check-Act')).toBeVisible() + await expect(sdkPage.getByRole('button', { name: 'Datenschutz' })).toBeVisible() + await expect(sdkPage.getByRole('button', { name: 'Cybersecurity' })).toBeVisible() + }) + + test('specific question -> answer with [n] citation + evidence pane', async ({ sdkPage }) => { + await sdkPage.route(CHAT_ROUTE, (r) => + r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(ANSWER) }), + ) + await ask(sdkPage, 'CRA Meldefrist') + + await expect(sdkPage.getByText(/unverzüglich/)).toBeVisible() + await expect(sdkPage.getByTitle('Beleg 1 anzeigen')).toBeVisible() + // bindingness present -> header splits into Rechtsgrundlagen vs Leitlinien (evidence framing) + await expect(sdkPage.getByText('Rechtsgrundlagen').first()).toBeVisible() + // family name resolved for the user (shown both in the summary breakdown and the evidence card) + await expect(sdkPage.getByText('Cyber Resilience Act (CRA)').first()).toBeVisible() + }) + + test('clarify -> pick a context -> scoped answer', async ({ sdkPage }) => { + let calls = 0 + await sdkPage.route(CHAT_ROUTE, (r) => { + calls += 1 + r.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(calls === 1 ? CLARIFY : ANSWER), + }) + }) + await ask(sdkPage, 'Was ist PDCA?') + await sdkPage.getByRole('button', { name: 'Datenschutz' }).click() + + await expect(sdkPage.getByText(/unverzüglich/)).toBeVisible() + await expect(sdkPage.getByTitle('Beleg 1 anzeigen')).toBeVisible() + }) +}) 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/__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/__tests__/advisor-retrieve-mapping.test.ts b/admin-compliance/lib/sdk/__tests__/advisor-retrieve-mapping.test.ts new file mode 100644 index 00000000..24cb8f60 --- /dev/null +++ b/admin-compliance/lib/sdk/__tests__/advisor-retrieve-mapping.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect } from 'vitest' +import { + resolveMode, + mapClarity, + mapFootnotes, + buildCitations, + numberedEvidenceForPrompt, + isLegacyRequest, +} from '../advisor/retrieve-mapping' +import type { EvidenceUnit } from '../advisor/contract' + +describe('resolveMode', () => { + it('a chosen context always forces answer', () => expect(resolveMode('clarify', true)).toBe('answer')) + it('clarify + no context -> clarify', () => expect(resolveMode('clarify', false)).toBe('clarify')) + it('answer -> answer', () => expect(resolveMode('answer', false)).toBe('answer')) + it('unknown/undefined -> answer', () => expect(resolveMode(undefined, false)).toBe('answer')) +}) + +describe('mapClarity', () => { + it('clarify maps candidate_contexts -> suggested_contexts', () => { + const c = mapClarity( + { mode: 'clarify', concentration: 0.3, candidate_contexts: [{ id: 'ds', label: 'Datenschutz', hits: 5 }] }, + 'clarify', + ) + expect(c.is_underspecified).toBe(true) + expect(c.suggested_contexts).toEqual([{ id: 'ds', label: 'Datenschutz' }]) + }) + it('answer keeps dominant_context, drops suggestions', () => { + const c = mapClarity({ mode: 'answer', concentration: 0.88, dominant_context: 'ds' }, 'answer') + expect(c.is_underspecified).toBe(false) + expect(c.dominant_context).toBe('ds') + expect(c.suggested_contexts).toBeUndefined() + }) +}) + +const ev: EvidenceUnit[] = [ + { evidence_id: 'e1', document: 'DSGVO', section: 'Art. 30', paragraph: 'Abs. 1', snippet: 'x' }, + { evidence_id: 'e2', document: 'BDSG', section: '§ 38' }, +] + +describe('buildCitations', () => { + it('numbers citations 1..n mapped to evidence', () => { + const cs = buildCitations(ev) + expect(cs).toHaveLength(2) + expect(cs[0]).toMatchObject({ citation_id: 'c1', number: 1, evidence_id: 'e1' }) + expect(cs[1].number).toBe(2) + }) +}) + +describe('numberedEvidenceForPrompt', () => { + it('prefixes each unit with [n] + its location', () => { + const s = numberedEvidenceForPrompt(ev) + expect(s).toContain('[1] DSGVO Art. 30 Abs. 1') + expect(s).toContain('[2] BDSG § 38') + }) +}) + +describe('mapFootnotes', () => { + it('remaps a /retrieve footnote to the contract footnote', () => { + const fns = mapFootnotes([ + { id: 'f1', number: 17, regulation_short: 'EDPB WP248', section: 'Kap III', text: 't' }, + ]) + expect(fns[0]).toMatchObject({ + footnote_id: 'f1', + ref: 'Fußnote 17', + document: 'EDPB WP248', + section: 'Kap III', + text: 't', + }) + }) +}) + +describe('isLegacyRequest', () => { + it('message-only (workspace) -> legacy stream', () => { + expect(isLegacyRequest({ message: 'Ist meine DSE ausreichend?' })).toBe(true) + }) + it('question present -> contract (JSON)', () => { + expect(isLegacyRequest({ question: 'x', message: 'y' })).toBe(false) + expect(isLegacyRequest({ question: 'x' })).toBe(false) + }) +}) diff --git a/admin-compliance/lib/sdk/advisor/contract.ts b/admin-compliance/lib/sdk/advisor/contract.ts new file mode 100644 index 00000000..38da6304 --- /dev/null +++ b/admin-compliance/lib/sdk/advisor/contract.ts @@ -0,0 +1,83 @@ +// FE-facing contract for the Compliance Advisor "Case" (Clarity Gate). +// Matches the SDK<->FE contract (board 2026-07-01 / memory advisor-clarity-gate-contract). +// The FE renders ONLY these structured fields; it never extracts structure from the answer text. +// The only exception is rendering the deliberate [n] citation markers, mapped via `citations`. + +export interface SuggestedContext { + id: string // e.g. "datenschutz" + label: string // e.g. "Datenschutz" +} + +export interface ClarityInfo { + is_underspecified: boolean + concentration: number + suggested_contexts?: SuggestedContext[] // clarify mode + dominant_context?: string // answer mode +} + +/** A retrieved evidence unit. (`evidence[]` item shape — confirm with SDK; see board rückfrage.) */ +export interface EvidenceUnit { + evidence_id: string + document: string + section?: string + paragraph?: string + snippet?: string + url?: string + regulation_code?: string // preferred key for family grouping (from /retrieve) + context?: string // knowledge space / domain + // Canonical Legal-KG fact (APEX rule): binding norm vs. soft-law guidance. Owned by the + // Legal-KG/RAG, not derived in the FE. Absent until /retrieve populates it (board request 2026-07-01); + // the FE degrades to a neutral per-regulation breakdown when it is missing. + bindingness?: 'binding' | 'guidance' +} + +/** Numbered [n] <-> evidence coupling, produced by the SDK (not parsed from the answer). */ +export interface Citation { + citation_id: string + number?: number // 1-based marker number ([n]) + evidence_id: string + document: string + section?: string | null + paragraph?: string | null + footnote?: string | null + figure?: string | null +} + +/** C8 / visual evidence — `visual_type` generalizes beyond figures (flowchart/bpmn/state_machine/...). */ +export interface VisualEvidence { + visual_id: string + visual_type: string + caption?: string + document: string + context?: string + image_ref?: string + vision_summary?: string +} + +export interface Footnote { + footnote_id?: string + ref?: string + document?: string + section?: string + text?: string +} + +export type AdvisorMode = 'clarify' | 'answer' + +export interface AdvisorResponse { + mode: AdvisorMode + question: string + clarity: ClarityInfo + general_answer?: string | null // L1 (clarify mode) + answer?: string | null // L2 (answer mode) + scoped_query?: string | null + evidence: EvidenceUnit[] + citations: Citation[] + visual_evidence: VisualEvidence[] + footnotes: Footnote[] +} + +export interface AdvisorRequest { + question: string + context?: string | null +} 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, + } +} 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..704f4aac --- /dev/null +++ b/admin-compliance/lib/sdk/advisor/regulation-display.ts @@ -0,0 +1,57 @@ +// Human-readable display for regulations. Maps messy codes/short-names to a stable FAMILY key + +// friendly label (+ chapter for multi-part works like the DSK SDM). Presentation layer only: +// it bridges G2 (clean RAG metadata) and keeps working once codes are clean. Extend the table freely. + +export interface RegulationRef { + code?: string + name?: string + short?: string +} + +export interface RegulationDisplay { + familyKey: string // stable key used to GROUP evidence + familyLabel: string // human-readable regulation name + chapter?: string // e.g. "B51" for a DSK SDM building block +} + +interface Rule { + test: RegExp + key: string + label: string + chapter?: RegExp +} + +// Order matters: more specific patterns first. +const RULES: Rule[] = [ + { + test: /dsk.?sdm|standard.?datenschutzmodell|(^|[^a-z])sdm([^a-z]|$)/i, + key: 'dsk_sdm', + label: 'DSK Standard-Datenschutzmodell (SDM)', + chapter: /\b([A-Z]\d{1,3})\b/, + }, + { test: /cyber.?resilience|(^|[^a-z])cra([^a-z]|$)/i, key: 'cra', label: 'Cyber Resilience Act (CRA)' }, + { test: /(^|[^a-z])nis.?2([^a-z]|$)/i, key: 'nis2', label: 'NIS2-Richtlinie' }, + { test: /data.?privacy.?framework|(^|[^a-z])dpf([^a-z]|$)/i, key: 'dpf', label: 'EU-US Data Privacy Framework' }, + { test: /maschinen|2023.?1230/i, key: 'maschinenvo', label: 'Maschinenverordnung (EU) 2023/1230' }, + { test: /ds.?gvo|gdpr/i, key: 'dsgvo', label: 'DSGVO – Datenschutz-Grundverordnung' }, + { test: /(^|[^a-z])bdsg([^a-z]|$)/i, key: 'bdsg', label: 'BDSG – Bundesdatenschutzgesetz' }, + { test: /tdddg|ttdsg/i, key: 'tddg', label: 'TDDDG (Digitale-Dienste-Datenschutz)' }, + { test: /edpb|edsa|(^|[^a-z])wp\s?\d+/i, key: 'edpb', label: 'EDPB / DSK Leitlinien' }, + { test: /(^|[^a-z])bsi([^a-z]|$)/i, key: 'bsi', label: 'BSI' }, +] + +export function resolveRegulation(reg: RegulationRef): RegulationDisplay { + const hay = `${reg.code || ''} ${reg.short || ''} ${reg.name || ''}` + for (const r of RULES) { + if (r.test.test(hay)) { + const chapter = r.chapter + ? r.chapter.exec(reg.short || reg.code || '')?.[1] || undefined + : undefined + return { familyKey: r.key, familyLabel: r.label, chapter } + } + } + return { + familyKey: reg.code || reg.short || 'unknown', + familyLabel: reg.short || reg.name || reg.code || 'Regelwerk', + } +} 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..e036445f --- /dev/null +++ b/admin-compliance/lib/sdk/advisor/retrieve-mapping.ts @@ -0,0 +1,91 @@ +// Pure mappings from the Go /retrieve response (SDK/RAG-owned; board 2026-07-01 12:25) +// to the FE-facing advisor contract. Kept pure + testable; the orchestration (route.ts) wires them. + +import type { Citation, ClarityInfo, EvidenceUnit, Footnote, VisualEvidence } from './contract' + +export interface RetrieveClarity { + mode?: string // 'clarify' | 'answer' + reason?: string // e.g. 'middle_band_llm_needed' + concentration?: number + domain_count?: number + dominant_context?: string + candidate_contexts?: { id: string; label: string; hits?: number }[] +} + +export interface RetrieveFootnote { + id?: string + ref?: string + number?: number + regulation_code?: string + regulation_short?: string + regulation_name?: string + section?: string + text?: string +} + +export interface RetrieveResponse { + evidence?: EvidenceUnit[] + visual_evidence?: VisualEvidence[] + footnotes?: RetrieveFootnote[] + clarity?: RetrieveClarity + results?: unknown[] + tables?: unknown[] // C6 — not in the FE contract yet (future TablesPane) +} + +/** clarify unless a context was chosen; /retrieve's clarity.mode decides for un-scoped queries. */ +export function resolveMode(clarityMode: string | undefined, hasContext: boolean): 'clarify' | 'answer' { + if (hasContext) return 'answer' + return clarityMode === 'clarify' ? 'clarify' : 'answer' +} + +export function mapClarity(c: RetrieveClarity | undefined, mode: 'clarify' | 'answer'): ClarityInfo { + return { + is_underspecified: mode === 'clarify', + concentration: c?.concentration ?? 0, + dominant_context: c?.dominant_context, + suggested_contexts: + mode === 'clarify' ? (c?.candidate_contexts ?? []).map((cc) => ({ id: cc.id, label: cc.label })) : undefined, + } +} + +export function mapFootnotes(fns: RetrieveFootnote[] | undefined): Footnote[] { + return (fns ?? []).map((f) => ({ + footnote_id: f.id, + ref: f.ref ?? (f.number != null ? `Fußnote ${f.number}` : undefined), + document: f.regulation_short || f.regulation_name || f.regulation_code, + section: f.section, + text: f.text, + })) +} + +/** Citations are generated by the orchestration (not by /retrieve): [n] -> nth evidence unit. */ +export function buildCitations(evidence: EvidenceUnit[]): Citation[] { + return evidence.map((e, i) => ({ + citation_id: `c${i + 1}`, + number: i + 1, + evidence_id: e.evidence_id, + document: e.document, + section: e.section ?? null, + paragraph: e.paragraph ?? null, + footnote: null, + figure: null, + })) +} + +/** Numbered evidence list injected into the L2 prompt so the LLM can cite [n]. */ +export function numberedEvidenceForPrompt(evidence: EvidenceUnit[]): string { + return evidence + .map((e, i) => { + const loc = [e.document, e.section, e.paragraph].filter(Boolean).join(' ') + return `[${i + 1}] ${loc}\n${e.snippet ?? ''}`.trim() + }) + .join('\n\n') +} + +/** + * Backward-compat discriminator: legacy consumers (e.g. breakpilot-workspace) send `{message}` + * and read a plain-text stream; the new FE sends `{question}` and expects the JSON contract. + */ +export function isLegacyRequest(body: { question?: unknown; message?: unknown }): boolean { + return body.question == null && typeof body.message === 'string' +} 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 b42d1cd2..2b785b23 100644 --- a/admin-compliance/lib/sdk/agents/advisor-rag.ts +++ b/admin-compliance/lib/sdk/agents/advisor-rag.ts @@ -7,10 +7,13 @@ * Advisor bleibt damit collection-agnostisch (Vertrag: Compiler -> Collections -> Retriever * -> Advisor); die fruehere Multi-Collection-Logik liegt jetzt im Retriever. * - * Fehler werden geschluckt (graceful: Antwort ohne RAG-Kontext). - * Fundstellen via article_label sind live ab dem Prod-Re-Ingest 2026-06. + * `retrieveAdvisorEvidence` liefert die STRUKTURIERTEN Treffer (fuer das Evidence-Workspace- + * Frontend, das nur strukturierte Daten rendert und nie den Antworttext parst) UND den + * vorformatierten Kontext-Block fuer den LLM-Prompt. Fehler werden geschluckt (graceful). */ +import type { RetrieveResponse } from '@/lib/sdk/advisor/retrieve-mapping' + const SDK_URL = process.env.SDK_API_URL || process.env.SDK_URL || 'http://ai-compliance-sdk:8090' @@ -18,7 +21,7 @@ const DEFAULT_USER = '00000000-0000-0000-0000-000000000001' const DEFAULT_TENANT = process.env.DEFAULT_TENANT_ID || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e' -interface SdkRagResult { +export interface SdkRagResult { text?: string regulation_code?: string regulation_name?: string @@ -34,20 +37,27 @@ interface SdkRagResult { score?: number } +/** Raw RAG response. `figures`/`footnotes` (C8 / C-FN) are passed through untyped until the + * RAG-ingestion contract is finalized (board), then mapped in the evidence-adapter. */ +interface SdkRagResponse { + results?: SdkRagResult[] + figures?: unknown[] + footnotes?: unknown[] +} + interface ScoredPassage { content: string source: string score: number } -/** Normalisiert eine ai-sdk-RAG-Antwort auf {content, source, score}. */ +/** Normalisiert eine ai-sdk-RAG-Antwort auf {content, source, score} (fuer den Prompt-Kontext). */ export function mapSdkResults(results: SdkRagResult[] | undefined): ScoredPassage[] { return (results || []) .map((r) => ({ content: r.text || '', // Fundstelle: article_label ist die fertig formatierte, druckbare Quelle aus der - // Ingestion ("BDSG § 38 Abs. 1"); Fallback baut sie aus den strukturierten Feldern - // (bzw. alt-ingestierte Chunks ohne Legal-Metadaten). Siehe rag_reingest_spec.md §2/§7. + // Ingestion ("BDSG § 38 Abs. 1"); Fallback baut sie aus den strukturierten Feldern. source: (r.article_label && r.article_label.trim()) || [r.regulation_short || r.regulation_name || r.regulation_code, r.article, r.paragraph, r.sub] @@ -59,15 +69,16 @@ export function mapSdkResults(results: SdkRagResult[] | undefined): ScoredPassag .filter((p) => p.content) } -/** - * Authority Router: EIN collection-agnostischer Aufruf an die ai-sdk (`/sdk/v1/rag/retrieve`). - * Der Router waehlt die Collections (Broad-Authority-Base + KB-2026.1-Slice bei in-scope), - * merged + authority-ranked sie und liefert die Top-Passagen. Der Advisor weiss damit nichts - * mehr ueber einzelne Collections — die fruehere Multi-Collection-Logik liegt jetzt im Retriever. - * Fehler werden geschluckt (graceful: Antwort ohne RAG-Kontext). - */ -export async function queryAdvisorRAG(query: string): Promise { - 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 +90,61 @@ 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 +} + +/** + * 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 {} }