feat(advisor): Evidence Workspace — structured panes, markdown, sources as knowledge units
Rebuilds the Compliance Advisor floating widget from a plain chat into an Evidence
Workspace: pinned last question, markdown-rendered answer (clean prose), and separate
panes for Sources (hierarchical Knowledge Units), Figures (C8, conditional) and
Footnotes (C-FN), plus a stats bar (Quellen/Regelwerke/Diagramme/Fußnoten). Scrollable
turn history; stays a floating icon on every SDK page.
Architecture (user direction): the frontend renders ONLY structured evidence and NEVER
parses the answer text. The proxy now returns a JSON AdvisorEvidenceMeta line followed
by the streamed markdown answer; advisor-rag exposes structured results; an adapter maps
RAG/compiler output to the frontend envelope. Figures/footnotes wire in once the
RAG-ingestion contract lands (requested on the board) — figures pane is conditional.
- lib/sdk/advisor/{evidence,evidence-adapter}.ts (+ adapter test, 7 cases)
- components/sdk/advisor/* panes + in-house safe Markdown (no new dep, no dangerouslySetInnerHTML) + test
- useAdvisorStream (meta-line parse + streamed answer) + useAdvisorEmail (escaped)
- proxy: evidence-meta-v1 envelope + clean-prose prompt (no inline citations)
- tsc clean, 11 vitest pass, check-loc 0. ESLint not installed in this node_modules -> CI lints on push.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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<Country, string> = {
|
||||
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<Uint8Array>): ReadableStream<Uint8Array> {
|
||||
const encoder = new TextEncoder()
|
||||
const metaLine = JSON.stringify(meta) + '\n'
|
||||
return new ReadableStream<Uint8Array>({
|
||||
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) {
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
'use client'
|
||||
|
||||
// =============================================================================
|
||||
// ComplianceAdvisorWidget — shared constants and sub-components
|
||||
// =============================================================================
|
||||
|
||||
// =============================================================================
|
||||
// EXAMPLE QUESTIONS
|
||||
// =============================================================================
|
||||
|
||||
export const EXAMPLE_QUESTIONS: Record<string, string[]> = {
|
||||
vvt: [
|
||||
'Was ist ein Verarbeitungsverzeichnis?',
|
||||
'Welche Informationen muss ich erfassen?',
|
||||
'Wie dokumentiere ich die Rechtsgrundlage?',
|
||||
],
|
||||
'compliance-scope': [
|
||||
'Was bedeutet L3?',
|
||||
'Wann brauche ich eine DSFA?',
|
||||
'Was ist der Unterschied zwischen L2 und L3?',
|
||||
],
|
||||
tom: [
|
||||
'Was sind TOM?',
|
||||
'Welche Massnahmen sind erforderlich?',
|
||||
'Wie dokumentiere ich Verschluesselung?',
|
||||
],
|
||||
dsfa: [
|
||||
'Was ist eine DSFA?',
|
||||
'Wann ist eine DSFA verpflichtend?',
|
||||
'Wie bewerte ich Risiken?',
|
||||
],
|
||||
loeschfristen: [
|
||||
'Wie definiere ich Loeschfristen?',
|
||||
'Was ist der Unterschied zwischen Loeschpflicht und Aufbewahrungspflicht?',
|
||||
'Wann muss ich Daten loeschen?',
|
||||
],
|
||||
default: [
|
||||
'Wie starte ich mit dem SDK?',
|
||||
'Was ist der erste Schritt?',
|
||||
'Welche Compliance-Anforderungen gelten fuer KI-Systeme?',
|
||||
],
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
export interface Message {
|
||||
id: string
|
||||
role: 'user' | 'agent'
|
||||
content: string
|
||||
timestamp: Date
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// EmptyState — shown when no messages yet
|
||||
// =============================================================================
|
||||
|
||||
interface EmptyStateProps {
|
||||
exampleQuestions: string[]
|
||||
onExampleClick: (question: string) => void
|
||||
}
|
||||
|
||||
export function AdvisorEmptyState({ exampleQuestions, onExampleClick }: EmptyStateProps) {
|
||||
return (
|
||||
<div className="text-center py-8">
|
||||
<div className="w-16 h-16 bg-purple-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<svg className="w-8 h-8 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-sm font-medium text-gray-900 mb-2">Willkommen beim Compliance Advisor</h3>
|
||||
<p className="text-xs text-gray-500 mb-4">Stellen Sie Fragen zu DSGVO, KI-Verordnung und mehr.</p>
|
||||
<div className="text-left space-y-2">
|
||||
<p className="text-xs font-medium text-gray-700 mb-2">Beispielfragen:</p>
|
||||
{exampleQuestions.map((question, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => onExampleClick(question)}
|
||||
className="w-full text-left px-3 py-2 text-xs bg-white hover:bg-purple-50 border border-gray-200 rounded-lg transition-colors text-gray-700"
|
||||
>
|
||||
{question}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MessageList — renders messages + typing indicator
|
||||
// =============================================================================
|
||||
|
||||
interface MessageListProps {
|
||||
messages: Message[]
|
||||
isTyping: boolean
|
||||
messagesEndRef: React.RefObject<HTMLDivElement | null>
|
||||
}
|
||||
|
||||
export function AdvisorMessageList({ messages, isTyping, messagesEndRef }: MessageListProps) {
|
||||
return (
|
||||
<>
|
||||
{messages.map((message) => (
|
||||
<div key={message.id} className={`flex ${message.role === 'user' ? 'justify-end' : 'justify-start'}`}>
|
||||
<div className={`max-w-[80%] rounded-lg px-3 py-2 ${message.role === 'user' ? 'bg-indigo-600 text-white' : 'bg-white border border-gray-200 text-gray-800'}`}>
|
||||
<p className={`text-sm ${message.role === 'agent' ? 'whitespace-pre-wrap' : ''}`}>
|
||||
{message.content || (message.role === 'agent' && isTyping ? '' : message.content)}
|
||||
</p>
|
||||
<p className={`text-xs mt-1 ${message.role === 'user' ? 'text-indigo-200' : 'text-gray-400'}`}>
|
||||
{message.timestamp.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{isTyping && (
|
||||
<div className="flex justify-start">
|
||||
<div className="bg-white border border-gray-200 rounded-lg px-3 py-2">
|
||||
<div className="flex space-x-1">
|
||||
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" />
|
||||
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '0.1s' }} />
|
||||
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '0.2s' }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef as React.RefObject<HTMLDivElement>} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,198 +1,47 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { EXAMPLE_QUESTIONS, AdvisorEmptyState, AdvisorMessageList, type Message } from './ComplianceAdvisorParts'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
import { useCallback, useState } from 'react'
|
||||
import { Check, Loader2, Mail, Maximize2, MessagesSquare, Minimize2, Send, Square, X } from 'lucide-react'
|
||||
import { EXAMPLE_QUESTIONS } from './advisor/EmptyState'
|
||||
import { EvidenceWorkspace } from './advisor/EvidenceWorkspace'
|
||||
import { 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<Message[]>([])
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
const [isTyping, setIsTyping] = useState(false)
|
||||
const [selectedCountry, setSelectedCountry] = useState<Country>('DE')
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
const abortControllerRef = useRef<AbortController | null>(null)
|
||||
const [country, setCountry] = useState<Country>('DE')
|
||||
|
||||
const { 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 }) =>
|
||||
`<div style="margin-bottom:16px;"><p style="font-weight:600;color:#1e293b;">Frage: ${q}</p><p style="color:#475569;white-space:pre-wrap;">${a}</p></div>`
|
||||
).join('')
|
||||
|
||||
const bodyHtml = `
|
||||
<h2 style="color:#1e293b;">Compliance Advisor — Beratungsprotokoll</h2>
|
||||
<p style="color:#64748b;font-size:13px;">Datum: ${new Date().toLocaleString('de-DE')} | Land: ${selectedCountry} | Kontext: ${currentStep}</p>
|
||||
<hr style="border-color:#e2e8f0;margin:16px 0;">
|
||||
${qaHtml}
|
||||
<hr style="border-color:#e2e8f0;margin:16px 0;">
|
||||
<p style="color:#94a3b8;font-size:11px;">Automatisch erstellt vom BreakPilot Compliance Advisor (Qwen)</p>
|
||||
`
|
||||
|
||||
await fetch('/api/sdk/v1/agent/notify', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
recipient: 'dsb@breakpilot.local',
|
||||
subject: `Compliance Advisor — ${qaPairs.length} Fragen (${currentStep})`,
|
||||
body_html: bodyHtml,
|
||||
role: 'Datenschutzbeauftragter',
|
||||
}),
|
||||
})
|
||||
setEmailSent(true)
|
||||
setTimeout(() => setEmailSent(false), 3000)
|
||||
} catch (e) {
|
||||
console.error('Email send failed:', e)
|
||||
} finally {
|
||||
setEmailSending(false)
|
||||
}
|
||||
}, [messages, emailSending, selectedCountry, currentStep])
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
const onKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSendMessage(inputValue)
|
||||
submit(inputValue)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,135 +49,108 @@ export function ComplianceAdvisorWidget({ currentStep = 'default' }: ComplianceA
|
||||
return (
|
||||
<button
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="fixed bottom-6 right-[5.5rem] w-14 h-14 bg-indigo-600 hover:bg-indigo-700 text-white rounded-full shadow-lg flex items-center justify-center transition-all duration-200 hover:scale-110 z-50"
|
||||
className="fixed bottom-6 right-[5.5rem] z-50 flex h-14 w-14 items-center justify-center rounded-full bg-indigo-600 text-white shadow-lg transition-all duration-200 hover:scale-110 hover:bg-indigo-700"
|
||||
aria-label="Compliance Advisor oeffnen"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
|
||||
</svg>
|
||||
<MessagesSquare className="h-6 w-6" />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`fixed bottom-6 right-6 ${isExpanded ? 'w-[700px] h-[80vh]' : 'w-[400px] h-[500px]'} max-h-screen bg-white rounded-2xl shadow-2xl flex flex-col z-50 border border-gray-200 transition-all duration-200`}>
|
||||
<div
|
||||
className={`fixed bottom-6 right-6 z-50 flex max-h-screen flex-col rounded-2xl border border-gray-200 bg-white shadow-2xl transition-all duration-200 ${
|
||||
isExpanded ? 'h-[85vh] w-[760px]' : 'h-[560px] w-[420px]'
|
||||
}`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="bg-gradient-to-r from-purple-600 to-indigo-600 text-white px-4 py-3 rounded-t-2xl flex items-center justify-between">
|
||||
<div className="flex items-center justify-between rounded-t-2xl bg-gradient-to-r from-purple-600 to-indigo-600 px-4 py-3 text-white">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 bg-white/20 rounded-full flex items-center justify-center">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||
</svg>
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-white/20">
|
||||
<MessagesSquare className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-sm">Compliance Advisor</div>
|
||||
<div className="flex items-center gap-1 mt-0.5">
|
||||
{COUNTRIES.map(({ code, label }) => (
|
||||
<div className="text-sm font-semibold">Compliance Advisor</div>
|
||||
<div className="mt-0.5 flex items-center gap-1">
|
||||
{COUNTRIES.map((c) => (
|
||||
<button
|
||||
key={code}
|
||||
onClick={() => setSelectedCountry(code)}
|
||||
className={`px-1.5 py-0.5 text-[10px] font-medium rounded transition-colors ${selectedCountry === code ? 'bg-white text-indigo-700' : 'bg-white/15 text-white/80 hover:bg-white/25'}`}
|
||||
key={c}
|
||||
onClick={() => setCountry(c)}
|
||||
className={`rounded px-1.5 py-0.5 text-[10px] font-medium transition-colors ${
|
||||
country === c ? 'bg-white text-indigo-700' : 'bg-white/15 text-white/80 hover:bg-white/25'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
{c}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Send as Email */}
|
||||
{messages.length > 0 && (
|
||||
{turns.length > 0 && (
|
||||
<button
|
||||
onClick={handleSendAsEmail}
|
||||
disabled={emailSending}
|
||||
className={`text-white/80 hover:text-white transition-colors ${emailSent ? 'text-green-300' : ''}`}
|
||||
onClick={email.send}
|
||||
disabled={email.sending}
|
||||
className={`text-white/80 transition-colors hover:text-white ${email.sent ? 'text-green-300' : ''}`}
|
||||
title={email.sent ? 'Email gesendet!' : 'Beratungsprotokoll als Email senden'}
|
||||
aria-label="Als Email an DSB senden"
|
||||
title={emailSent ? 'Email gesendet!' : 'Beratungsprotokoll als Email senden'}
|
||||
>
|
||||
{emailSent ? (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
) : emailSending ? (
|
||||
<svg className="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
{email.sent ? (
|
||||
<Check className="h-5 w-5" />
|
||||
) : email.sending ? (
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
) : (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<Mail className="h-5 w-5" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="text-white/80 hover:text-white transition-colors"
|
||||
onClick={() => setIsExpanded((v) => !v)}
|
||||
className="text-white/80 transition-colors hover:text-white"
|
||||
aria-label={isExpanded ? 'Verkleinern' : 'Vergroessern'}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
{isExpanded ? (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 9L4 4m0 0v4m0-4h4m6 6l5 5m0 0v-4m0 4h-4" />
|
||||
) : (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5v-4m0 4h-4m4 0l-5-5" />
|
||||
)}
|
||||
</svg>
|
||||
{isExpanded ? <Minimize2 className="h-5 w-5" /> : <Maximize2 className="h-5 w-5" />}
|
||||
</button>
|
||||
<button onClick={() => setIsOpen(false)} className="text-white/80 hover:text-white transition-colors" aria-label="Schliessen">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="text-white/80 transition-colors hover:text-white"
|
||||
aria-label="Schliessen"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages Area */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4 bg-gray-50">
|
||||
{messages.length === 0 ? (
|
||||
<AdvisorEmptyState
|
||||
exampleQuestions={exampleQuestions}
|
||||
onExampleClick={(q) => handleSendMessage(q)}
|
||||
/>
|
||||
) : (
|
||||
<AdvisorMessageList
|
||||
messages={messages}
|
||||
isTyping={isTyping}
|
||||
messagesEndRef={messagesEndRef}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{/* Evidence Workspace */}
|
||||
<EvidenceWorkspace turns={turns} exampleQuestions={exampleQuestions} onExample={submit} />
|
||||
|
||||
{/* Input Area */}
|
||||
<div className="border-t border-gray-200 p-3 bg-white rounded-b-2xl">
|
||||
{/* Input */}
|
||||
<div className="rounded-b-2xl border-t border-gray-200 bg-white p-3">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder="Frage eingeben..."
|
||||
disabled={isTyping}
|
||||
className="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent disabled:opacity-50"
|
||||
disabled={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 ? (
|
||||
<button
|
||||
onClick={handleStopGeneration}
|
||||
className="px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors"
|
||||
onClick={stop}
|
||||
className="rounded-lg bg-red-500 px-4 py-2 text-white transition-colors hover:bg-red-600"
|
||||
title="Generierung stoppen"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 6h12v12H6z" />
|
||||
</svg>
|
||||
<Square className="h-5 w-5" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleSendMessage(inputValue)}
|
||||
onClick={() => submit(inputValue)}
|
||||
disabled={!inputValue.trim()}
|
||||
className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
className="rounded-lg bg-indigo-600 px-4 py-2 text-white transition-colors hover:bg-indigo-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
|
||||
</svg>
|
||||
<Send className="h-5 w-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
'use client'
|
||||
|
||||
import { Markdown } from './Markdown'
|
||||
|
||||
/** The answer panel — rendered markdown (clean prose, no inline citations). */
|
||||
export function AnswerPane({
|
||||
answer,
|
||||
streaming,
|
||||
error,
|
||||
}: {
|
||||
answer: string
|
||||
streaming?: boolean
|
||||
error?: string
|
||||
}) {
|
||||
if (error) {
|
||||
return (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (!answer && streaming) {
|
||||
return (
|
||||
<div className="flex space-x-1 px-1 py-2" aria-label="Antwort wird generiert">
|
||||
<span className="h-2 w-2 animate-bounce rounded-full bg-gray-400" />
|
||||
<span className="h-2 w-2 animate-bounce rounded-full bg-gray-400" style={{ animationDelay: '0.1s' }} />
|
||||
<span className="h-2 w-2 animate-bounce rounded-full bg-gray-400" style={{ animationDelay: '0.2s' }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-200 bg-white px-3 py-2">
|
||||
<Markdown content={answer} />
|
||||
{streaming && <span className="ml-0.5 inline-block h-3 w-1.5 animate-pulse bg-indigo-400 align-middle" />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
'use client'
|
||||
|
||||
import { ShieldCheck } from 'lucide-react'
|
||||
|
||||
export const EXAMPLE_QUESTIONS: Record<string, string[]> = {
|
||||
vvt: [
|
||||
'Was ist ein Verarbeitungsverzeichnis?',
|
||||
'Welche Informationen muss ich erfassen?',
|
||||
'Wie dokumentiere ich die Rechtsgrundlage?',
|
||||
],
|
||||
'compliance-scope': [
|
||||
'Was bedeutet L3?',
|
||||
'Wann brauche ich eine DSFA?',
|
||||
'Was ist der Unterschied zwischen L2 und L3?',
|
||||
],
|
||||
tom: [
|
||||
'Was sind TOM?',
|
||||
'Welche Massnahmen sind erforderlich?',
|
||||
'Wie dokumentiere ich Verschluesselung?',
|
||||
],
|
||||
dsfa: ['Was ist eine DSFA?', 'Wann ist eine DSFA verpflichtend?', 'Wie bewerte ich Risiken?'],
|
||||
loeschfristen: [
|
||||
'Wie definiere ich Loeschfristen?',
|
||||
'Unterschied Loeschpflicht und Aufbewahrungspflicht?',
|
||||
'Wann muss ich Daten loeschen?',
|
||||
],
|
||||
default: [
|
||||
'Wie starte ich mit dem SDK?',
|
||||
'Was ist der erste Schritt?',
|
||||
'Welche Compliance-Anforderungen gelten fuer KI-Systeme?',
|
||||
],
|
||||
}
|
||||
|
||||
export function AdvisorEmptyState({
|
||||
exampleQuestions,
|
||||
onExampleClick,
|
||||
}: {
|
||||
exampleQuestions: string[]
|
||||
onExampleClick: (question: string) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="px-4 py-8 text-center">
|
||||
<div className="mx-auto mb-3 flex h-14 w-14 items-center justify-center rounded-full bg-indigo-100">
|
||||
<ShieldCheck className="h-7 w-7 text-indigo-600" />
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-gray-900">Compliance Advisor</h3>
|
||||
<p className="mx-auto mt-1 max-w-xs text-xs text-gray-500">
|
||||
Antworten mit nachvollziehbaren Quellen, Fundstellen und — wo vorhanden — Original-Abbildungen.
|
||||
</p>
|
||||
<div className="mt-4 space-y-2 text-left">
|
||||
<p className="text-xs font-medium text-gray-700">Beispielfragen</p>
|
||||
{exampleQuestions.map((q, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => onExampleClick(q)}
|
||||
className="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-left text-xs text-gray-700 transition-colors hover:bg-indigo-50"
|
||||
>
|
||||
{q}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
import type { AdvisorTurn } from './useAdvisorStream'
|
||||
import { StickyQuestion } from './StickyQuestion'
|
||||
import { TurnView } from './TurnView'
|
||||
import { AdvisorEmptyState } from './EmptyState'
|
||||
|
||||
/**
|
||||
* The Evidence Workspace body: a pinned "last question" + a scrollable history of turns, each
|
||||
* showing the answer alongside its sources / figures / footnotes. Scroll up to revisit a past
|
||||
* answer with its full evidence.
|
||||
*/
|
||||
export function EvidenceWorkspace({
|
||||
turns,
|
||||
exampleQuestions,
|
||||
onExample,
|
||||
}: {
|
||||
turns: AdvisorTurn[]
|
||||
exampleQuestions: string[]
|
||||
onExample: (q: string) => void
|
||||
}) {
|
||||
const endRef = useRef<HTMLDivElement>(null)
|
||||
const latest = turns[turns.length - 1]
|
||||
|
||||
// Scroll to the newest turn when a question is added (not on every streamed token,
|
||||
// so the user can scroll up to review history while the answer streams).
|
||||
useEffect(() => {
|
||||
endRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [turns.length])
|
||||
|
||||
if (turns.length === 0) {
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto bg-gray-50">
|
||||
<AdvisorEmptyState exampleQuestions={exampleQuestions} onExampleClick={onExample} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto bg-gray-50">
|
||||
{latest && <StickyQuestion question={latest.question} />}
|
||||
<div className="space-y-4 p-4">
|
||||
{turns.map((t, i) => (
|
||||
<TurnView key={t.id} turn={t} showQuestion={i !== turns.length - 1} />
|
||||
))}
|
||||
<div ref={endRef} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
'use client'
|
||||
|
||||
import { Image as ImageIcon, ExternalLink } from 'lucide-react'
|
||||
import type { FigureUnit } from '@/lib/sdk/advisor/evidence'
|
||||
import { PaneHeader } from './PaneHeader'
|
||||
|
||||
function FigureCard({ fig }: { fig: FigureUnit }) {
|
||||
const canOpen = !!fig.imageUrl && /^https?:\/\//i.test(fig.imageUrl)
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-2.5">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="text-xs font-semibold text-gray-900">
|
||||
{fig.label}
|
||||
{fig.caption ? <span className="font-normal text-gray-600"> — {fig.caption}</span> : null}
|
||||
</div>
|
||||
{canOpen && (
|
||||
<a
|
||||
href={fig.imageUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex flex-shrink-0 items-center gap-0.5 rounded px-1.5 py-0.5 text-[11px] font-medium text-indigo-600 hover:bg-indigo-50"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
Original anzeigen
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 text-[11px] text-gray-500">
|
||||
Quelle: {fig.source.short}
|
||||
{fig.section ? ` · ${fig.section}` : ''}
|
||||
</div>
|
||||
{canOpen ? (
|
||||
<a href={fig.imageUrl} target="_blank" rel="noopener noreferrer" className="mt-1.5 block">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={fig.imageUrl}
|
||||
alt={fig.caption || fig.label}
|
||||
loading="lazy"
|
||||
className="max-h-44 w-full rounded border border-gray-100 object-contain"
|
||||
/>
|
||||
</a>
|
||||
) : (
|
||||
<div className="mt-1.5 flex items-center justify-center rounded border border-dashed border-gray-200 bg-gray-50 px-3 py-5 text-[11px] text-gray-400">
|
||||
Original-Abbildung folgt
|
||||
</div>
|
||||
)}
|
||||
{fig.visionSummary && (
|
||||
<p className="mt-1.5 text-[11px] italic text-gray-500">{fig.visionSummary}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Figures pane (C8) — original document figures, rendered only when present. */
|
||||
export function FiguresPane({ figures }: { figures: FigureUnit[] }) {
|
||||
if (figures.length === 0) return null
|
||||
return (
|
||||
<section>
|
||||
<PaneHeader
|
||||
icon={<ImageIcon className="h-3.5 w-3.5 text-gray-500" />}
|
||||
title="Abbildungen & Diagramme"
|
||||
count={figures.length}
|
||||
/>
|
||||
<div className="space-y-1.5">
|
||||
{figures.map((f) => (
|
||||
<FigureCard key={f.id} fig={f} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
'use client'
|
||||
|
||||
import { Hash } from 'lucide-react'
|
||||
import type { FootnoteUnit } from '@/lib/sdk/advisor/evidence'
|
||||
import { PaneHeader } from './PaneHeader'
|
||||
|
||||
/** Footnotes pane (C-FN) — rendered only when present. */
|
||||
export function FootnotesPane({ footnotes }: { footnotes: FootnoteUnit[] }) {
|
||||
if (footnotes.length === 0) return null
|
||||
return (
|
||||
<section>
|
||||
<PaneHeader icon={<Hash className="h-3.5 w-3.5 text-gray-500" />} title="Fußnoten" count={footnotes.length} />
|
||||
<div className="space-y-1">
|
||||
{footnotes.map((fn) => (
|
||||
<div key={fn.id} className="rounded-md border border-gray-200 bg-white p-2 text-[11px]">
|
||||
<span className="font-semibold text-gray-900">{fn.ref}</span>
|
||||
<span className="text-gray-400">
|
||||
{' · '}
|
||||
{fn.source.short}
|
||||
{fn.section ? ` / ${fn.section}` : ''}
|
||||
</span>
|
||||
{fn.text && <p className="mt-0.5 text-gray-600">{fn.text}</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { ChevronDown, ChevronRight, ExternalLink } from 'lucide-react'
|
||||
import type { KnowledgeUnit } from '@/lib/sdk/advisor/evidence'
|
||||
|
||||
/**
|
||||
* A source rendered as a hierarchical Knowledge Unit (Regelwerk → Section → Paragraph → Footnote),
|
||||
* not a text-list line. [öffnen] resolves to the original source when available; the optional
|
||||
* snippet lets the user peek the cited text.
|
||||
*/
|
||||
export function KnowledgeUnitCard({ unit }: { unit: KnowledgeUnit }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const crumbs = [unit.section, unit.subsection, unit.paragraph, unit.footnoteRef].filter(Boolean)
|
||||
const href = unit.open?.originalUrl
|
||||
const canOpen = href && /^https?:\/\//i.test(href)
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-2.5">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-xs font-semibold text-gray-900">{unit.regulation.short}</div>
|
||||
{crumbs.length > 0 ? (
|
||||
<div className="mt-0.5 flex flex-wrap items-center gap-x-1 text-[11px] text-gray-500">
|
||||
{crumbs.map((c, i) => (
|
||||
<span key={i} className="flex items-center gap-1">
|
||||
{i > 0 && <span className="text-gray-300">›</span>}
|
||||
{c}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
unit.label && <div className="mt-0.5 text-[11px] text-gray-500">{unit.label}</div>
|
||||
)}
|
||||
</div>
|
||||
{canOpen && (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex flex-shrink-0 items-center gap-0.5 rounded px-1.5 py-0.5 text-[11px] font-medium text-indigo-600 hover:bg-indigo-50"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
öffnen
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{unit.snippet && (
|
||||
<div className="mt-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex items-center gap-0.5 text-[11px] text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{open ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
|
||||
Textauszug
|
||||
</button>
|
||||
{open && (
|
||||
<p className="mt-1 border-l-2 border-gray-200 pl-2 text-[11px] italic text-gray-500">
|
||||
{unit.snippet}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render } from '@testing-library/react'
|
||||
import { Markdown } from './Markdown'
|
||||
|
||||
describe('Markdown', () => {
|
||||
it('renders headings, bold and bullet lists (not raw markdown markers)', () => {
|
||||
const { container } = render(
|
||||
<Markdown
|
||||
content={'## Pflichten\n\nDer **Verantwortliche** muss:\n\n- ein Verzeichnis fuehren\n- Risiken bewerten'}
|
||||
/>,
|
||||
)
|
||||
expect(container.querySelector('h4')?.textContent).toBe('Pflichten')
|
||||
expect(container.querySelector('strong')?.textContent).toBe('Verantwortliche')
|
||||
expect(container.querySelectorAll('li')).toHaveLength(2)
|
||||
expect(container.textContent).not.toContain('##')
|
||||
expect(container.textContent).not.toContain('**')
|
||||
})
|
||||
|
||||
it('renders ordered lists and inline code', () => {
|
||||
const { container } = render(<Markdown content={'1. Erst `init`\n2. Dann `build`'} />)
|
||||
expect(container.querySelector('ol')).not.toBeNull()
|
||||
expect(container.querySelectorAll('li')).toHaveLength(2)
|
||||
expect(container.querySelectorAll('code')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('renders fenced code blocks', () => {
|
||||
const { container } = render(<Markdown content={'```\nconst x = 1\n```'} />)
|
||||
expect(container.querySelector('pre')).not.toBeNull()
|
||||
expect(container.textContent).toContain('const x = 1')
|
||||
})
|
||||
|
||||
it('only allows http(s) links', () => {
|
||||
const { container } = render(
|
||||
<Markdown content={'[ok](https://example.test) and [bad](javascript:alert(1))'} />,
|
||||
)
|
||||
const links = container.querySelectorAll('a')
|
||||
expect(links).toHaveLength(1)
|
||||
expect(links[0].getAttribute('href')).toBe('https://example.test')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,153 @@
|
||||
'use client'
|
||||
|
||||
// Minimal, SAFE markdown -> React renderer. No dangerouslySetInnerHTML, no dependency.
|
||||
// Covers the subset LLMs emit: headings, bold, italic, inline code, fenced code, ul/ol, links.
|
||||
// (The Evidence Workspace renders citations in a separate pane, so links are rarely needed.)
|
||||
|
||||
const INLINE_RE = /(`[^`]+`|\*\*[^*]+\*\*|\*[^*\s][^*]*\*|_[^_]+_|\[[^\]]+\]\([^)]+\))/g
|
||||
|
||||
function renderInline(text: string, kp: string): React.ReactNode[] {
|
||||
const nodes: React.ReactNode[] = []
|
||||
let last = 0
|
||||
let idx = 0
|
||||
INLINE_RE.lastIndex = 0
|
||||
let m: RegExpExecArray | null
|
||||
while ((m = INLINE_RE.exec(text)) !== null) {
|
||||
if (m.index > last) nodes.push(text.slice(last, m.index))
|
||||
const tok = m[0]
|
||||
const key = `${kp}-${idx++}`
|
||||
if (tok.startsWith('`')) {
|
||||
nodes.push(
|
||||
<code key={key} className="rounded bg-gray-100 px-1 py-0.5 font-mono text-[0.85em]">
|
||||
{tok.slice(1, -1)}
|
||||
</code>,
|
||||
)
|
||||
} else if (tok.startsWith('**')) {
|
||||
nodes.push(
|
||||
<strong key={key} className="font-semibold text-gray-900">
|
||||
{tok.slice(2, -2)}
|
||||
</strong>,
|
||||
)
|
||||
} else if (tok.startsWith('*') || tok.startsWith('_')) {
|
||||
nodes.push(<em key={key}>{tok.slice(1, -1)}</em>)
|
||||
} else {
|
||||
const mm = /^\[([^\]]+)\]\(([^)]+)\)$/.exec(tok)
|
||||
if (mm && /^https?:\/\//i.test(mm[2])) {
|
||||
nodes.push(
|
||||
<a
|
||||
key={key}
|
||||
href={mm[2]}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-indigo-600 underline hover:text-indigo-800"
|
||||
>
|
||||
{mm[1]}
|
||||
</a>,
|
||||
)
|
||||
} else {
|
||||
nodes.push(mm ? mm[1] : tok)
|
||||
}
|
||||
}
|
||||
last = m.index + tok.length
|
||||
}
|
||||
if (last < text.length) nodes.push(text.slice(last))
|
||||
return nodes
|
||||
}
|
||||
|
||||
function Heading({ level, kp, text }: { level: number; kp: string; text: string }) {
|
||||
const children = renderInline(text, kp)
|
||||
if (level <= 1) return <h3 className="mb-1 mt-3 text-base font-bold text-gray-900">{children}</h3>
|
||||
if (level === 2) return <h4 className="mb-1 mt-3 text-sm font-bold text-gray-900">{children}</h4>
|
||||
return <h5 className="mb-1 mt-2 text-sm font-semibold text-gray-800">{children}</h5>
|
||||
}
|
||||
|
||||
const UL_RE = /^\s*[-*]\s+/
|
||||
const OL_RE = /^\s*\d+\.\s+/
|
||||
const H_RE = /^(#{1,6})\s+(.*)$/
|
||||
|
||||
export function Markdown({ content }: { content: string }) {
|
||||
const lines = (content || '').replace(/\r\n/g, '\n').split('\n')
|
||||
const blocks: React.ReactNode[] = []
|
||||
let i = 0
|
||||
while (i < lines.length) {
|
||||
const line = lines[i]
|
||||
const key = `b${blocks.length}` // unique per pushed block (blocks.length is the next index)
|
||||
|
||||
if (line.trim().startsWith('```')) {
|
||||
const buf: string[] = []
|
||||
i++
|
||||
while (i < lines.length && !lines[i].trim().startsWith('```')) {
|
||||
buf.push(lines[i])
|
||||
i++
|
||||
}
|
||||
i++
|
||||
blocks.push(
|
||||
<pre
|
||||
key={key}
|
||||
className="my-2 overflow-x-auto rounded bg-gray-900 p-3 font-mono text-xs text-gray-100"
|
||||
>
|
||||
<code>{buf.join('\n')}</code>
|
||||
</pre>,
|
||||
)
|
||||
continue
|
||||
}
|
||||
if (line.trim() === '') {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
const h = H_RE.exec(line)
|
||||
if (h) {
|
||||
blocks.push(<Heading key={key} kp={key} level={h[1].length} text={h[2]} />)
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if (UL_RE.test(line)) {
|
||||
const items: string[] = []
|
||||
while (i < lines.length && UL_RE.test(lines[i])) {
|
||||
items.push(lines[i].replace(UL_RE, ''))
|
||||
i++
|
||||
}
|
||||
blocks.push(
|
||||
<ul key={key} className="my-1.5 ml-4 list-disc space-y-1 text-gray-700">
|
||||
{items.map((it, k) => (
|
||||
<li key={k}>{renderInline(it, `${key}-${k}`)}</li>
|
||||
))}
|
||||
</ul>,
|
||||
)
|
||||
continue
|
||||
}
|
||||
if (OL_RE.test(line)) {
|
||||
const items: string[] = []
|
||||
while (i < lines.length && OL_RE.test(lines[i])) {
|
||||
items.push(lines[i].replace(OL_RE, ''))
|
||||
i++
|
||||
}
|
||||
blocks.push(
|
||||
<ol key={key} className="my-1.5 ml-5 list-decimal space-y-1 text-gray-700">
|
||||
{items.map((it, k) => (
|
||||
<li key={k}>{renderInline(it, `${key}-${k}`)}</li>
|
||||
))}
|
||||
</ol>,
|
||||
)
|
||||
continue
|
||||
}
|
||||
const para: string[] = []
|
||||
while (
|
||||
i < lines.length &&
|
||||
lines[i].trim() !== '' &&
|
||||
!H_RE.test(lines[i]) &&
|
||||
!UL_RE.test(lines[i]) &&
|
||||
!OL_RE.test(lines[i]) &&
|
||||
!lines[i].trim().startsWith('```')
|
||||
) {
|
||||
para.push(lines[i])
|
||||
i++
|
||||
}
|
||||
blocks.push(
|
||||
<p key={key} className="my-1.5 leading-relaxed text-gray-700">
|
||||
{renderInline(para.join(' '), key)}
|
||||
</p>,
|
||||
)
|
||||
}
|
||||
return <div className="advisor-markdown text-sm">{blocks}</div>
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
'use client'
|
||||
|
||||
/** Shared section header for evidence panes (icon + title + count badge). */
|
||||
export function PaneHeader({
|
||||
icon,
|
||||
title,
|
||||
count,
|
||||
}: {
|
||||
icon: React.ReactNode
|
||||
title: string
|
||||
count?: number
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-1.5 flex items-center gap-1.5 text-xs font-semibold text-gray-700">
|
||||
{icon}
|
||||
<span>{title}</span>
|
||||
{count != null && (
|
||||
<span className="rounded-full bg-gray-100 px-1.5 text-[10px] font-medium text-gray-500">
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
'use client'
|
||||
|
||||
import { Library } from 'lucide-react'
|
||||
import type { KnowledgeUnit } from '@/lib/sdk/advisor/evidence'
|
||||
import { KnowledgeUnitCard } from './KnowledgeUnitCard'
|
||||
import { PaneHeader } from './PaneHeader'
|
||||
|
||||
/** Sources pane — the answer's evidence as hierarchical Knowledge Units, separate from the prose. */
|
||||
export function SourcesPane({ sources }: { sources: KnowledgeUnit[] }) {
|
||||
return (
|
||||
<section>
|
||||
<PaneHeader icon={<Library className="h-3.5 w-3.5 text-gray-500" />} title="Quellen" count={sources.length} />
|
||||
{sources.length === 0 ? (
|
||||
<p className="px-1 text-[11px] text-gray-400">Keine strukturierten Quellen zu dieser Antwort.</p>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{sources.map((s) => (
|
||||
<KnowledgeUnitCard key={s.id} unit={s} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
'use client'
|
||||
|
||||
import { FileText, Library, Image as ImageIcon, Hash } from 'lucide-react'
|
||||
import type { AdvisorStats } from '@/lib/sdk/advisor/evidence'
|
||||
|
||||
function Chip({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
dim,
|
||||
}: {
|
||||
icon: React.ReactNode
|
||||
label: string
|
||||
value: number
|
||||
dim?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center gap-1 rounded-md border px-1.5 py-0.5 text-[11px] ${
|
||||
dim ? 'border-gray-100 bg-gray-50 text-gray-400' : 'border-gray-200 bg-white text-gray-600'
|
||||
}`}
|
||||
title={`${label}: ${value}`}
|
||||
>
|
||||
{icon}
|
||||
<span className="font-semibold text-gray-900">{value}</span>
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Compact evidence summary: "Diese Antwort basiert auf N Quellen / M Regelwerken ...". */
|
||||
export function StatsBar({ stats }: { stats: AdvisorStats }) {
|
||||
const cls = 'h-3 w-3'
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Chip icon={<FileText className={cls} />} label="Quellen" value={stats.sources} />
|
||||
<Chip icon={<Library className={cls} />} label="Regelwerke" value={stats.regulations} />
|
||||
<Chip
|
||||
icon={<ImageIcon className={cls} />}
|
||||
label="Diagramme"
|
||||
value={stats.figures}
|
||||
dim={stats.figures === 0}
|
||||
/>
|
||||
<Chip
|
||||
icon={<Hash className={cls} />}
|
||||
label="Fußnoten"
|
||||
value={stats.footnotes}
|
||||
dim={stats.footnotes === 0}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
'use client'
|
||||
|
||||
import { HelpCircle } from 'lucide-react'
|
||||
|
||||
/** The last question, pinned so it never scrolls out of view while the answer grows. */
|
||||
export function StickyQuestion({ question }: { question: string }) {
|
||||
if (!question) return null
|
||||
return (
|
||||
<div className="sticky top-0 z-10 border-b border-indigo-100 bg-indigo-50/95 px-4 py-2 backdrop-blur">
|
||||
<div className="flex items-start gap-2">
|
||||
<HelpCircle className="mt-0.5 h-4 w-4 flex-shrink-0 text-indigo-500" />
|
||||
<div className="min-w-0">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wide text-indigo-400">
|
||||
Letzte Frage
|
||||
</div>
|
||||
<div className="text-sm font-medium text-gray-800">{question}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
'use client'
|
||||
|
||||
import type { AdvisorTurn } from './useAdvisorStream'
|
||||
import { StatsBar } from './StatsBar'
|
||||
import { AnswerPane } from './AnswerPane'
|
||||
import { SourcesPane } from './SourcesPane'
|
||||
import { FiguresPane } from './FiguresPane'
|
||||
import { FootnotesPane } from './FootnotesPane'
|
||||
|
||||
/** One question/answer turn rendered as stacked evidence panels. */
|
||||
export function TurnView({ turn, showQuestion }: { turn: AdvisorTurn; showQuestion?: boolean }) {
|
||||
const streaming = turn.status === 'streaming'
|
||||
return (
|
||||
<div className="space-y-2 border-b border-gray-100 pb-4 last:border-0">
|
||||
{showQuestion && (
|
||||
<div className="text-xs text-gray-500">
|
||||
<span className="font-medium text-gray-400">Frage:</span> {turn.question}
|
||||
</div>
|
||||
)}
|
||||
<StatsBar stats={turn.meta.stats} />
|
||||
<AnswerPane answer={turn.answer} streaming={streaming} error={turn.error} />
|
||||
<SourcesPane sources={turn.meta.sources} />
|
||||
<FiguresPane figures={turn.meta.figures} />
|
||||
<FootnotesPane footnotes={turn.meta.footnotes} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useState } from 'react'
|
||||
import type { AdvisorTurn } from './useAdvisorStream'
|
||||
|
||||
function esc(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.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 `<li>${esc(s.regulation.short || '')}${hier ? ` — ${esc(hier)}` : ''}</li>`
|
||||
})
|
||||
.join('')
|
||||
return `<p style="color:#64748b;font-size:12px;margin:4px 0 0;">Quellen:</p><ul style="color:#64748b;font-size:12px;margin:2px 0;">${items}</ul>`
|
||||
}
|
||||
|
||||
/** Sends the consultation transcript (question + answer + structured sources) as an email to the DSB. */
|
||||
export function useAdvisorEmail(turns: AdvisorTurn[], country: string, currentStep: string) {
|
||||
const [sending, setSending] = useState(false)
|
||||
const [sent, setSent] = useState(false)
|
||||
|
||||
const send = useCallback(async () => {
|
||||
if (turns.length === 0 || sending) return
|
||||
setSending(true)
|
||||
try {
|
||||
const qaHtml = turns
|
||||
.map(
|
||||
(t) =>
|
||||
`<div style="margin-bottom:16px;"><p style="font-weight:600;color:#1e293b;">Frage: ${esc(
|
||||
t.question,
|
||||
)}</p><p style="color:#475569;white-space:pre-wrap;">${esc(t.answer)}</p>${sourcesHtml(t)}</div>`,
|
||||
)
|
||||
.join('')
|
||||
|
||||
const bodyHtml = `
|
||||
<h2 style="color:#1e293b;">Compliance Advisor — Beratungsprotokoll</h2>
|
||||
<p style="color:#64748b;font-size:13px;">Datum: ${esc(new Date().toLocaleString('de-DE'))} | Land: ${esc(country)} | Kontext: ${esc(currentStep)}</p>
|
||||
<hr style="border-color:#e2e8f0;margin:16px 0;">
|
||||
${qaHtml}
|
||||
<hr style="border-color:#e2e8f0;margin:16px 0;">
|
||||
<p style="color:#94a3b8;font-size:11px;">Automatisch erstellt vom BreakPilot Compliance Advisor</p>`
|
||||
|
||||
await fetch('/api/sdk/v1/agent/notify', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
recipient: 'dsb@breakpilot.local',
|
||||
subject: `Compliance Advisor — ${turns.length} Fragen (${currentStep})`,
|
||||
body_html: bodyHtml,
|
||||
role: 'Datenschutzbeauftragter',
|
||||
}),
|
||||
})
|
||||
setSent(true)
|
||||
setTimeout(() => setSent(false), 3000)
|
||||
} catch (e) {
|
||||
console.error('Email send failed:', e)
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
}, [turns, sending, country, currentStep])
|
||||
|
||||
return { send, sending, sent }
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import type { AdvisorEvidenceMeta } from '@/lib/sdk/advisor/evidence'
|
||||
import { emptyStats } from '@/lib/sdk/advisor/evidence'
|
||||
|
||||
export interface AdvisorTurn {
|
||||
id: string
|
||||
question: string
|
||||
answer: string
|
||||
meta: AdvisorEvidenceMeta
|
||||
status: 'streaming' | 'done' | 'error'
|
||||
error?: string
|
||||
}
|
||||
|
||||
function emptyMeta(): AdvisorEvidenceMeta {
|
||||
return { stats: emptyStats(), sources: [], figures: [], footnotes: [] }
|
||||
}
|
||||
|
||||
interface UseAdvisorStreamArgs {
|
||||
currentStep: string
|
||||
country: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Drives the Evidence Workspace: posts a question, parses the FIRST line of the response as
|
||||
* structured `AdvisorEvidenceMeta`, then streams the remaining bytes as the markdown answer.
|
||||
* The answer text is NEVER parsed for structure — sources/figures/footnotes come from the meta.
|
||||
*/
|
||||
export function useAdvisorStream({ currentStep, country }: UseAdvisorStreamArgs) {
|
||||
const [turns, setTurns] = useState<AdvisorTurn[]>([])
|
||||
const [isStreaming, setIsStreaming] = useState(false)
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
|
||||
const patch = useCallback((id: string, p: Partial<AdvisorTurn>) => {
|
||||
setTurns((prev) => prev.map((t) => (t.id === id ? { ...t, ...p } : t)))
|
||||
}, [])
|
||||
|
||||
const stop = useCallback(() => {
|
||||
abortRef.current?.abort()
|
||||
setIsStreaming(false)
|
||||
}, [])
|
||||
|
||||
const send = useCallback(
|
||||
async (question: string) => {
|
||||
const q = question.trim()
|
||||
if (!q || isStreaming) return
|
||||
|
||||
const id = `turn-${Date.now()}`
|
||||
const history = turns.flatMap((t) => [
|
||||
{ role: 'user', content: t.question },
|
||||
{ role: 'assistant', content: t.answer },
|
||||
])
|
||||
setTurns((prev) => [...prev, { id, question: q, answer: '', meta: emptyMeta(), status: 'streaming' }])
|
||||
setIsStreaming(true)
|
||||
abortRef.current = new AbortController()
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/sdk/compliance-advisor/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message: q, history, currentStep, country }),
|
||||
signal: abortRef.current.signal,
|
||||
})
|
||||
if (!res.ok || !res.body) {
|
||||
const e = await res.json().catch(() => ({ error: 'Unbekannter Fehler' }))
|
||||
throw new Error(e.error || `Server-Fehler (${res.status})`)
|
||||
}
|
||||
|
||||
const reader = res.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buf = ''
|
||||
let metaEnd = -1
|
||||
let meta: AdvisorEvidenceMeta | null = null
|
||||
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
buf += decoder.decode(value, { stream: true })
|
||||
if (metaEnd === -1) {
|
||||
const nl = buf.indexOf('\n')
|
||||
if (nl === -1) continue
|
||||
metaEnd = nl + 1
|
||||
try {
|
||||
meta = JSON.parse(buf.slice(0, nl)) as AdvisorEvidenceMeta
|
||||
} catch {
|
||||
meta = null // no valid meta -> treat whole stream as answer
|
||||
metaEnd = 0
|
||||
}
|
||||
}
|
||||
patch(id, { answer: buf.slice(metaEnd), ...(meta ? { meta } : {}) })
|
||||
}
|
||||
|
||||
buf += decoder.decode()
|
||||
patch(id, { answer: buf.slice(metaEnd === -1 ? 0 : metaEnd), status: 'done', ...(meta ? { meta } : {}) })
|
||||
setIsStreaming(false)
|
||||
} catch (err) {
|
||||
setIsStreaming(false)
|
||||
if ((err as Error).name === 'AbortError') {
|
||||
patch(id, { status: 'done' })
|
||||
return
|
||||
}
|
||||
patch(id, { status: 'error', error: err instanceof Error ? err.message : 'Verbindung fehlgeschlagen' })
|
||||
}
|
||||
},
|
||||
[isStreaming, turns, currentStep, country, patch],
|
||||
)
|
||||
|
||||
return { turns, isStreaming, send, stop }
|
||||
}
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -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<string, KnowledgeUnit>()
|
||||
;(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) }
|
||||
}
|
||||
@@ -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<AdvisorEvidenceMeta, 'sources' | 'figures' | 'footnotes'>,
|
||||
): 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,
|
||||
}
|
||||
}
|
||||
@@ -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<string> {
|
||||
let passages: ScoredPassage[] = []
|
||||
/** Formatiert die Top-Passagen als Kontext-Block fuer den System-Prompt. */
|
||||
function formatContext(passages: ScoredPassage[]): string {
|
||||
if (passages.length === 0) return ''
|
||||
return passages
|
||||
.map((r, i) => `[Quelle ${i + 1}: ${r.source}]\n${r.content}`)
|
||||
.join('\n\n---\n\n')
|
||||
}
|
||||
|
||||
/** EIN collection-agnostischer Aufruf an die ai-sdk. Fehler -> leeres Ergebnis (graceful). */
|
||||
async function fetchRag(query: string): Promise<SdkRagResponse> {
|
||||
try {
|
||||
const res = await fetch(`${SDK_URL}/sdk/v1/rag/retrieve`, {
|
||||
method: 'POST',
|
||||
@@ -79,16 +88,37 @@ export async function queryAdvisorRAG(query: string): Promise<string> {
|
||||
body: JSON.stringify({ query, top_k: 8 }),
|
||||
signal: AbortSignal.timeout(15000),
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
passages = mapSdkResults(data.results)
|
||||
}
|
||||
if (res.ok) return ((await res.json()) as SdkRagResponse) || {}
|
||||
} catch {
|
||||
// graceful: keine Verbindung -> Antwort ohne RAG-Kontext
|
||||
}
|
||||
// Der Router liefert bereits authority-geordnete Top-K; Reihenfolge bewahren.
|
||||
if (passages.length === 0) return ''
|
||||
return passages
|
||||
.map((r, i) => `[Quelle ${i + 1}: ${r.source}]\n${r.content}`)
|
||||
.join('\n\n---\n\n')
|
||||
return {}
|
||||
}
|
||||
|
||||
export interface AdvisorEvidenceRaw {
|
||||
contextText: string
|
||||
results: SdkRagResult[]
|
||||
figures?: unknown[]
|
||||
footnotes?: unknown[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Strukturierte Evidence + Prompt-Kontext aus EINEM Retrieval. Das Frontend bekommt die
|
||||
* `results` (und kuenftig `figures`/`footnotes`) als Daten; der `contextText` geht in den
|
||||
* LLM-Prompt. Reihenfolge der authority-geordneten Top-K bleibt erhalten.
|
||||
*/
|
||||
export async function retrieveAdvisorEvidence(query: string): Promise<AdvisorEvidenceRaw> {
|
||||
const data = await fetchRag(query)
|
||||
const results = data.results || []
|
||||
return {
|
||||
contextText: formatContext(mapSdkResults(results)),
|
||||
results,
|
||||
figures: Array.isArray(data.figures) ? data.figures : undefined,
|
||||
footnotes: Array.isArray(data.footnotes) ? data.footnotes : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
/** Abwaertskompatibel: nur der Prompt-Kontext als String. */
|
||||
export async function queryAdvisorRAG(query: string): Promise<string> {
|
||||
return (await retrieveAdvisorEvidence(query)).contextText
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user