merge(admin): FE Evidence-Workspace → main (evidence-framed header + bindingness)
CI / detect-changes (push) Successful in 5s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / build-sha-integrity (push) Successful in 4s
CI / validate-canonical-controls (push) Successful in 4s
CI / loc-budget (push) Successful in 18s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Successful in 3m2s
CI / test-go (push) Successful in 1m0s
CI / iace-gt-coverage (push) Successful in 15s
CI / test-python-backend (push) Has been skipped
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
CI / detect-changes (push) Successful in 5s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / build-sha-integrity (push) Successful in 4s
CI / validate-canonical-controls (push) Successful in 4s
CI / loc-budget (push) Successful in 18s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Successful in 3m2s
CI / test-go (push) Successful in 1m0s
CI / iace-gt-coverage (push) Successful in 15s
CI / test-python-backend (push) Has been skipped
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
This commit is contained in:
@@ -1,34 +1,29 @@
|
||||
/**
|
||||
* Compliance Advisor Chat API
|
||||
* Compliance Advisor Chat API — Clarity-Gate orchestration.
|
||||
*
|
||||
* Verbindet das ComplianceAdvisorWidget mit:
|
||||
* 1. Multi-Collection-RAG ueber die ai-compliance-sdk (bge-m3) — siehe advisor-rag
|
||||
* 2. Strukturierten Controls zum erkannten Thema — buildControlsContext
|
||||
* 3. LLM-Kaskade OVH (prod) -> Ollama (Dev) — siehe advisor-llm
|
||||
*
|
||||
* Laenderspezifische Filterung (DE, AT, CH, EU). Streamt die Antwort als Text.
|
||||
* Consumes the SDK/RAG /retrieve (evidence/visual_evidence/footnotes/clarity) and returns the
|
||||
* FE-facing contract (advisor-clarity-gate-contract):
|
||||
* - clarify mode -> short L1 general answer (no RAG) + domain context chips
|
||||
* - answer mode -> L2 answer over the scoped evidence with [n] citation markers
|
||||
* Citations are generated here ([n] -> nth evidence unit). The FE renders ONLY this structured data.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { readSoulFile } from '@/lib/sdk/agents/soul-reader'
|
||||
import { buildControlsContext } from '@/lib/sdk/agents/controls-augmentation'
|
||||
import { queryAdvisorRAG } from '@/lib/sdk/agents/advisor-rag'
|
||||
import { streamAdvisorAnswer, type ChatMessage } from '@/lib/sdk/agents/advisor-llm'
|
||||
import { retrieveFull } from '@/lib/sdk/agents/advisor-rag'
|
||||
import { completeAdvisorAnswer, streamAdvisorAnswer, type ChatMessage } from '@/lib/sdk/agents/advisor-llm'
|
||||
import {
|
||||
buildCitations,
|
||||
isLegacyRequest,
|
||||
mapClarity,
|
||||
mapFootnotes,
|
||||
numberedEvidenceForPrompt,
|
||||
resolveMode,
|
||||
} from '@/lib/sdk/advisor/retrieve-mapping'
|
||||
import type { AdvisorResponse } from '@/lib/sdk/advisor/contract'
|
||||
|
||||
type Country = 'DE' | 'AT' | 'CH' | 'EU'
|
||||
|
||||
const FALLBACK_SYSTEM_PROMPT = `# Compliance Advisor Agent
|
||||
|
||||
## Identitaet
|
||||
Du bist der BreakPilot Compliance-Berater. Du hilfst Nutzern des AI Compliance SDK,
|
||||
Datenschutz- und Compliance-Fragen in verstaendlicher Sprache zu beantworten.
|
||||
|
||||
## Kernprinzipien
|
||||
- Quellenbasiert: Verweise auf DSGVO-Artikel, BDSG-Paragraphen
|
||||
- Verstaendlich: Einfache, praxisnahe Sprache
|
||||
- Ehrlich: Bei Unsicherheit empfehle Rechtsberatung
|
||||
- Deutsch als Hauptsprache`
|
||||
|
||||
const COUNTRY_LABELS: Record<Country, string> = {
|
||||
DE: 'Deutschland',
|
||||
AT: 'Oesterreich',
|
||||
@@ -38,84 +33,135 @@ const COUNTRY_LABELS: Record<Country, string> = {
|
||||
|
||||
function countryBlock(c: Country): string {
|
||||
const label = COUNTRY_LABELS[c]
|
||||
const nationalLaws =
|
||||
c === 'DE'
|
||||
? 'BDSG, TDDDG, TKG, UWG'
|
||||
: c === 'AT'
|
||||
? 'AT DSG, ECG, TKG, KSchG, MedienG'
|
||||
: 'CH DSG, DSV, OR, UWG, FMG'
|
||||
const guidance =
|
||||
c === 'EU'
|
||||
? 'EU-weiten Fragen: Beziehe dich auf EU-Verordnungen und -Richtlinien'
|
||||
: `${label}: Beziehe nationale Gesetze (${nationalLaws}) mit ein`
|
||||
return `\n\n## Laenderspezifische Auskunft
|
||||
Der Nutzer hat "${label} (${c})" gewaehlt.
|
||||
- Beziehe dich AUSSCHLIESSLICH auf ${c}-Recht + anwendbares EU-Recht
|
||||
- Nenne IMMER explizit das Land in deiner Antwort
|
||||
- Verwende NIEMALS Gesetze eines anderen Landes
|
||||
- Bei ${guidance}`
|
||||
Der Nutzer hat "${label} (${c})" gewaehlt. Beziehe dich auf ${c}-Recht + anwendbares EU-Recht und nenne das Land.`
|
||||
}
|
||||
|
||||
// L1: general knowledge, deliberately NOT grounded (the clarify step precedes the legal retrieval).
|
||||
const L1_SYSTEM = `Du bist der BreakPilot Compliance-Berater. Gib eine KURZE, allgemeine Definition/Erklaerung
|
||||
des gefragten Begriffs aus Allgemeinwissen — 2 bis 4 Saetze, Markdown, neutral. NENNE KEINE Rechtsquellen,
|
||||
Paragraphen, Artikel oder Fundstellen; der Nutzer waehlt anschliessend einen konkreten Kontext, erst dann
|
||||
folgen belegte Quellen. Wenn der Begriff in mehreren Bereichen vorkommt, erwaehne das in einem Halbsatz.`
|
||||
|
||||
const FALLBACK_SYSTEM = `Du bist der BreakPilot Compliance-Berater. Antworte quellenbasiert, verstaendlich und ehrlich auf Deutsch.`
|
||||
|
||||
// Optional audience/tonality guidance (e.g. the workspace's role hint). Kept out of the retrieval
|
||||
// `question` on purpose — it only shapes the answer's tone, so it belongs in the system prompt.
|
||||
function audienceBlock(audience: string): string {
|
||||
return audience ? `\n\n## Ansprache / Zielgruppe\n${audience}` : ''
|
||||
}
|
||||
|
||||
function answerSystem(
|
||||
soul: string | null,
|
||||
country: Country | undefined,
|
||||
evidenceBlock: string,
|
||||
withCitations = true,
|
||||
audience = '',
|
||||
): string {
|
||||
let s = soul || FALLBACK_SYSTEM
|
||||
if (country) s += countryBlock(country)
|
||||
s += audienceBlock(audience)
|
||||
s += `\n\n## Belegte Evidence (nummeriert — DEINE EINZIGEN Quellen)\n${evidenceBlock || '(keine Evidence gefunden)'}`
|
||||
s += `\n\n## Antwortformat (WICHTIG)
|
||||
- Beginne mit einer **Kurzzusammenfassung** (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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
'use client'
|
||||
|
||||
// =============================================================================
|
||||
// ComplianceAdvisorWidget — shared constants and sub-components
|
||||
// =============================================================================
|
||||
|
||||
// =============================================================================
|
||||
// EXAMPLE QUESTIONS
|
||||
// =============================================================================
|
||||
|
||||
export const EXAMPLE_QUESTIONS: Record<string, string[]> = {
|
||||
vvt: [
|
||||
'Was ist ein Verarbeitungsverzeichnis?',
|
||||
'Welche Informationen muss ich erfassen?',
|
||||
'Wie dokumentiere ich die Rechtsgrundlage?',
|
||||
],
|
||||
'compliance-scope': [
|
||||
'Was bedeutet L3?',
|
||||
'Wann brauche ich eine DSFA?',
|
||||
'Was ist der Unterschied zwischen L2 und L3?',
|
||||
],
|
||||
tom: [
|
||||
'Was sind TOM?',
|
||||
'Welche Massnahmen sind erforderlich?',
|
||||
'Wie dokumentiere ich Verschluesselung?',
|
||||
],
|
||||
dsfa: [
|
||||
'Was ist eine DSFA?',
|
||||
'Wann ist eine DSFA verpflichtend?',
|
||||
'Wie bewerte ich Risiken?',
|
||||
],
|
||||
loeschfristen: [
|
||||
'Wie definiere ich Loeschfristen?',
|
||||
'Was ist der Unterschied zwischen Loeschpflicht und Aufbewahrungspflicht?',
|
||||
'Wann muss ich Daten loeschen?',
|
||||
],
|
||||
default: [
|
||||
'Wie starte ich mit dem SDK?',
|
||||
'Was ist der erste Schritt?',
|
||||
'Welche Compliance-Anforderungen gelten fuer KI-Systeme?',
|
||||
],
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
export interface Message {
|
||||
id: string
|
||||
role: 'user' | 'agent'
|
||||
content: string
|
||||
timestamp: Date
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// EmptyState — shown when no messages yet
|
||||
// =============================================================================
|
||||
|
||||
interface EmptyStateProps {
|
||||
exampleQuestions: string[]
|
||||
onExampleClick: (question: string) => void
|
||||
}
|
||||
|
||||
export function AdvisorEmptyState({ exampleQuestions, onExampleClick }: EmptyStateProps) {
|
||||
return (
|
||||
<div className="text-center py-8">
|
||||
<div className="w-16 h-16 bg-purple-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<svg className="w-8 h-8 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-sm font-medium text-gray-900 mb-2">Willkommen beim Compliance Advisor</h3>
|
||||
<p className="text-xs text-gray-500 mb-4">Stellen Sie Fragen zu DSGVO, KI-Verordnung und mehr.</p>
|
||||
<div className="text-left space-y-2">
|
||||
<p className="text-xs font-medium text-gray-700 mb-2">Beispielfragen:</p>
|
||||
{exampleQuestions.map((question, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => onExampleClick(question)}
|
||||
className="w-full text-left px-3 py-2 text-xs bg-white hover:bg-purple-50 border border-gray-200 rounded-lg transition-colors text-gray-700"
|
||||
>
|
||||
{question}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MessageList — renders messages + typing indicator
|
||||
// =============================================================================
|
||||
|
||||
interface MessageListProps {
|
||||
messages: Message[]
|
||||
isTyping: boolean
|
||||
messagesEndRef: React.RefObject<HTMLDivElement | null>
|
||||
}
|
||||
|
||||
export function AdvisorMessageList({ messages, isTyping, messagesEndRef }: MessageListProps) {
|
||||
return (
|
||||
<>
|
||||
{messages.map((message) => (
|
||||
<div key={message.id} className={`flex ${message.role === 'user' ? 'justify-end' : 'justify-start'}`}>
|
||||
<div className={`max-w-[80%] rounded-lg px-3 py-2 ${message.role === 'user' ? 'bg-indigo-600 text-white' : 'bg-white border border-gray-200 text-gray-800'}`}>
|
||||
<p className={`text-sm ${message.role === 'agent' ? 'whitespace-pre-wrap' : ''}`}>
|
||||
{message.content || (message.role === 'agent' && isTyping ? '' : message.content)}
|
||||
</p>
|
||||
<p className={`text-xs mt-1 ${message.role === 'user' ? 'text-indigo-200' : 'text-gray-400'}`}>
|
||||
{message.timestamp.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{isTyping && (
|
||||
<div className="flex justify-start">
|
||||
<div className="bg-white border border-gray-200 rounded-lg px-3 py-2">
|
||||
<div className="flex space-x-1">
|
||||
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" />
|
||||
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '0.1s' }} />
|
||||
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '0.2s' }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef as React.RefObject<HTMLDivElement>} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,198 +1,47 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { EXAMPLE_QUESTIONS, AdvisorEmptyState, AdvisorMessageList, type Message } from './ComplianceAdvisorParts'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
import { useCallback, useState } from 'react'
|
||||
import { Check, Loader2, Mail, Maximize2, MessagesSquare, Minimize2, Send, Square, X } from 'lucide-react'
|
||||
import { EXAMPLE_QUESTIONS } from './advisor/EmptyState'
|
||||
import { EvidenceWorkspace } from './advisor/EvidenceWorkspace'
|
||||
import { useAdvisorCase } from './advisor/useAdvisorCase'
|
||||
import { useAdvisorEmail } from './advisor/useAdvisorEmail'
|
||||
|
||||
interface ComplianceAdvisorWidgetProps {
|
||||
currentStep?: string
|
||||
}
|
||||
|
||||
type Country = 'DE' | 'AT' | 'CH' | 'EU'
|
||||
const COUNTRIES: Country[] = ['DE', 'AT', 'CH', 'EU']
|
||||
|
||||
const COUNTRIES: { code: Country; label: string }[] = [
|
||||
{ code: 'DE', label: 'DE' },
|
||||
{ code: 'AT', label: 'AT' },
|
||||
{ code: 'CH', label: 'CH' },
|
||||
{ code: 'EU', label: 'EU' },
|
||||
]
|
||||
|
||||
// =============================================================================
|
||||
// COMPONENT
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Compliance Advisor — a floating Case Workspace on every SDK page.
|
||||
* Renders ONLY structured SDK data (clarify/answer contract); it never parses the answer text.
|
||||
* See memory: advisor-evidence-workspace-no-parse, advisor-clarity-gate-contract.
|
||||
*/
|
||||
export function ComplianceAdvisorWidget({ currentStep = 'default' }: ComplianceAdvisorWidgetProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const [messages, setMessages] = useState<Message[]>([])
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
const [isTyping, setIsTyping] = useState(false)
|
||||
const [selectedCountry, setSelectedCountry] = useState<Country>('DE')
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
const abortControllerRef = useRef<AbortController | null>(null)
|
||||
const [country, setCountry] = useState<Country>('DE')
|
||||
|
||||
const { cases, busy, ask, selectContext, stop } = useAdvisorCase({ currentStep, country })
|
||||
const email = useAdvisorEmail(cases, country, currentStep)
|
||||
const exampleQuestions = EXAMPLE_QUESTIONS[currentStep] || EXAMPLE_QUESTIONS.default
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [messages])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
abortControllerRef.current?.abort()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleSendMessage = useCallback(
|
||||
async (content: string) => {
|
||||
if (!content.trim() || isTyping) return
|
||||
|
||||
const userMessage: Message = {
|
||||
id: `msg-${Date.now()}`,
|
||||
role: 'user',
|
||||
content: content.trim(),
|
||||
timestamp: new Date(),
|
||||
}
|
||||
|
||||
setMessages((prev) => [...prev, userMessage])
|
||||
const submit = useCallback(
|
||||
(q: string) => {
|
||||
if (!q.trim() || busy) return
|
||||
setInputValue('')
|
||||
setIsTyping(true)
|
||||
|
||||
const agentMessageId = `msg-${Date.now()}-agent`
|
||||
abortControllerRef.current = new AbortController()
|
||||
|
||||
try {
|
||||
const history = messages.map((m) => ({
|
||||
role: m.role === 'user' ? 'user' : 'assistant',
|
||||
content: m.content,
|
||||
}))
|
||||
|
||||
const response = await fetch('/api/sdk/compliance-advisor/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
message: content.trim(),
|
||||
history,
|
||||
currentStep,
|
||||
country: selectedCountry,
|
||||
}),
|
||||
signal: abortControllerRef.current.signal,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ error: 'Unbekannter Fehler' }))
|
||||
throw new Error(errorData.error || `Server-Fehler (${response.status})`)
|
||||
}
|
||||
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ id: agentMessageId, role: 'agent', content: '', timestamp: new Date() },
|
||||
])
|
||||
|
||||
const reader = response.body!.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let accumulated = ''
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
accumulated += decoder.decode(value, { stream: true })
|
||||
const currentText = accumulated
|
||||
setMessages((prev) =>
|
||||
prev.map((m) => (m.id === agentMessageId ? { ...m, content: currentText } : m))
|
||||
)
|
||||
requestAnimationFrame(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
})
|
||||
}
|
||||
|
||||
setIsTyping(false)
|
||||
} catch (error) {
|
||||
if ((error as Error).name === 'AbortError') {
|
||||
setIsTyping(false)
|
||||
return
|
||||
}
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : 'Verbindung fehlgeschlagen'
|
||||
setMessages((prev) => {
|
||||
const hasAgent = prev.some((m) => m.id === agentMessageId)
|
||||
if (hasAgent) {
|
||||
return prev.map((m) =>
|
||||
m.id === agentMessageId ? { ...m, content: `Fehler: ${errorMessage}` } : m
|
||||
)
|
||||
}
|
||||
return [
|
||||
...prev,
|
||||
{ id: agentMessageId, role: 'agent' as const, content: `Fehler: ${errorMessage}`, timestamp: new Date() },
|
||||
]
|
||||
})
|
||||
setIsTyping(false)
|
||||
}
|
||||
ask(q)
|
||||
},
|
||||
[isTyping, messages, currentStep, selectedCountry]
|
||||
[busy, ask],
|
||||
)
|
||||
|
||||
const handleStopGeneration = useCallback(() => {
|
||||
abortControllerRef.current?.abort()
|
||||
setIsTyping(false)
|
||||
}, [])
|
||||
|
||||
const [emailSending, setEmailSending] = useState(false)
|
||||
const [emailSent, setEmailSent] = useState(false)
|
||||
|
||||
const handleSendAsEmail = useCallback(async () => {
|
||||
if (messages.length === 0 || emailSending) return
|
||||
setEmailSending(true)
|
||||
try {
|
||||
// Build HTML from chat messages
|
||||
const qaPairs = messages.reduce<{ q: string; a: string }[]>((acc, m, i) => {
|
||||
if (m.role === 'user') {
|
||||
const next = messages[i + 1]
|
||||
acc.push({ q: m.content, a: next?.role === 'agent' ? next.content : '(keine Antwort)' })
|
||||
}
|
||||
return acc
|
||||
}, [])
|
||||
|
||||
const qaHtml = qaPairs.map(({ q, a }) =>
|
||||
`<div style="margin-bottom:16px;"><p style="font-weight:600;color:#1e293b;">Frage: ${q}</p><p style="color:#475569;white-space:pre-wrap;">${a}</p></div>`
|
||||
).join('')
|
||||
|
||||
const bodyHtml = `
|
||||
<h2 style="color:#1e293b;">Compliance Advisor — Beratungsprotokoll</h2>
|
||||
<p style="color:#64748b;font-size:13px;">Datum: ${new Date().toLocaleString('de-DE')} | Land: ${selectedCountry} | Kontext: ${currentStep}</p>
|
||||
<hr style="border-color:#e2e8f0;margin:16px 0;">
|
||||
${qaHtml}
|
||||
<hr style="border-color:#e2e8f0;margin:16px 0;">
|
||||
<p style="color:#94a3b8;font-size:11px;">Automatisch erstellt vom BreakPilot Compliance Advisor (Qwen)</p>
|
||||
`
|
||||
|
||||
await fetch('/api/sdk/v1/agent/notify', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
recipient: 'dsb@breakpilot.local',
|
||||
subject: `Compliance Advisor — ${qaPairs.length} Fragen (${currentStep})`,
|
||||
body_html: bodyHtml,
|
||||
role: 'Datenschutzbeauftragter',
|
||||
}),
|
||||
})
|
||||
setEmailSent(true)
|
||||
setTimeout(() => setEmailSent(false), 3000)
|
||||
} catch (e) {
|
||||
console.error('Email send failed:', e)
|
||||
} finally {
|
||||
setEmailSending(false)
|
||||
}
|
||||
}, [messages, emailSending, selectedCountry, currentStep])
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
const onKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSendMessage(inputValue)
|
||||
submit(inputValue)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,135 +49,102 @@ export function ComplianceAdvisorWidget({ currentStep = 'default' }: ComplianceA
|
||||
return (
|
||||
<button
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="fixed bottom-6 right-[5.5rem] w-14 h-14 bg-indigo-600 hover:bg-indigo-700 text-white rounded-full shadow-lg flex items-center justify-center transition-all duration-200 hover:scale-110 z-50"
|
||||
className="fixed bottom-6 right-[5.5rem] z-50 flex h-14 w-14 items-center justify-center rounded-full bg-indigo-600 text-white shadow-lg transition-all duration-200 hover:scale-110 hover:bg-indigo-700"
|
||||
aria-label="Compliance Advisor oeffnen"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
|
||||
</svg>
|
||||
<MessagesSquare className="h-6 w-6" />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`fixed bottom-6 right-6 ${isExpanded ? 'w-[700px] h-[80vh]' : 'w-[400px] h-[500px]'} max-h-screen bg-white rounded-2xl shadow-2xl flex flex-col z-50 border border-gray-200 transition-all duration-200`}>
|
||||
{/* Header */}
|
||||
<div className="bg-gradient-to-r from-purple-600 to-indigo-600 text-white px-4 py-3 rounded-t-2xl flex items-center justify-between">
|
||||
<div
|
||||
className={`fixed bottom-6 right-6 z-50 flex max-h-screen flex-col rounded-2xl border border-gray-200 bg-white shadow-2xl transition-all duration-200 ${
|
||||
isExpanded ? 'h-[85vh] w-[960px]' : 'h-[560px] w-[420px]'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between rounded-t-2xl bg-gradient-to-r from-purple-600 to-indigo-600 px-4 py-3 text-white">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 bg-white/20 rounded-full flex items-center justify-center">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||
</svg>
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-white/20">
|
||||
<MessagesSquare className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-sm">Compliance Advisor</div>
|
||||
<div className="flex items-center gap-1 mt-0.5">
|
||||
{COUNTRIES.map(({ code, label }) => (
|
||||
<div className="text-sm font-semibold">Compliance Advisor</div>
|
||||
<div className="mt-0.5 flex items-center gap-1">
|
||||
{COUNTRIES.map((c) => (
|
||||
<button
|
||||
key={code}
|
||||
onClick={() => setSelectedCountry(code)}
|
||||
className={`px-1.5 py-0.5 text-[10px] font-medium rounded transition-colors ${selectedCountry === code ? 'bg-white text-indigo-700' : 'bg-white/15 text-white/80 hover:bg-white/25'}`}
|
||||
key={c}
|
||||
onClick={() => setCountry(c)}
|
||||
className={`rounded px-1.5 py-0.5 text-[10px] font-medium transition-colors ${
|
||||
country === c ? 'bg-white text-indigo-700' : 'bg-white/15 text-white/80 hover:bg-white/25'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
{c}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Send as Email */}
|
||||
{messages.length > 0 && (
|
||||
{cases.length > 0 && (
|
||||
<button
|
||||
onClick={handleSendAsEmail}
|
||||
disabled={emailSending}
|
||||
className={`text-white/80 hover:text-white transition-colors ${emailSent ? 'text-green-300' : ''}`}
|
||||
onClick={email.send}
|
||||
disabled={email.sending}
|
||||
className={`text-white/80 transition-colors hover:text-white ${email.sent ? 'text-green-300' : ''}`}
|
||||
title={email.sent ? 'Email gesendet!' : 'Beratungsprotokoll als Email senden'}
|
||||
aria-label="Als Email an DSB senden"
|
||||
title={emailSent ? 'Email gesendet!' : 'Beratungsprotokoll als Email senden'}
|
||||
>
|
||||
{emailSent ? (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
) : emailSending ? (
|
||||
<svg className="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
)}
|
||||
{email.sent ? <Check className="h-5 w-5" /> : email.sending ? <Loader2 className="h-5 w-5 animate-spin" /> : <Mail className="h-5 w-5" />}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="text-white/80 hover:text-white transition-colors"
|
||||
onClick={() => setIsExpanded((v) => !v)}
|
||||
className="text-white/80 transition-colors hover:text-white"
|
||||
aria-label={isExpanded ? 'Verkleinern' : 'Vergroessern'}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
{isExpanded ? (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 9L4 4m0 0v4m0-4h4m6 6l5 5m0 0v-4m0 4h-4" />
|
||||
) : (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5v-4m0 4h-4m4 0l-5-5" />
|
||||
)}
|
||||
</svg>
|
||||
{isExpanded ? <Minimize2 className="h-5 w-5" /> : <Maximize2 className="h-5 w-5" />}
|
||||
</button>
|
||||
<button onClick={() => setIsOpen(false)} className="text-white/80 hover:text-white transition-colors" aria-label="Schliessen">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="text-white/80 transition-colors hover:text-white"
|
||||
aria-label="Schliessen"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages Area */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4 bg-gray-50">
|
||||
{messages.length === 0 ? (
|
||||
<AdvisorEmptyState
|
||||
exampleQuestions={exampleQuestions}
|
||||
onExampleClick={(q) => handleSendMessage(q)}
|
||||
/>
|
||||
) : (
|
||||
<AdvisorMessageList
|
||||
messages={messages}
|
||||
isTyping={isTyping}
|
||||
messagesEndRef={messagesEndRef}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<EvidenceWorkspace
|
||||
cases={cases}
|
||||
expanded={isExpanded}
|
||||
busy={busy}
|
||||
exampleQuestions={exampleQuestions}
|
||||
onExample={submit}
|
||||
onSelectContext={selectContext}
|
||||
/>
|
||||
|
||||
{/* Input Area */}
|
||||
<div className="border-t border-gray-200 p-3 bg-white rounded-b-2xl">
|
||||
<div className="rounded-b-2xl border-t border-gray-200 bg-white p-3">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder="Frage eingeben..."
|
||||
disabled={isTyping}
|
||||
className="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent disabled:opacity-50"
|
||||
disabled={busy}
|
||||
className="flex-1 rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-transparent focus:outline-none focus:ring-2 focus:ring-purple-500 disabled:opacity-50"
|
||||
/>
|
||||
{isTyping ? (
|
||||
<button
|
||||
onClick={handleStopGeneration}
|
||||
className="px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors"
|
||||
title="Generierung stoppen"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 6h12v12H6z" />
|
||||
</svg>
|
||||
{busy ? (
|
||||
<button onClick={stop} className="rounded-lg bg-red-500 px-4 py-2 text-white transition-colors hover:bg-red-600" title="Abbrechen">
|
||||
<Square className="h-5 w-5" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleSendMessage(inputValue)}
|
||||
onClick={() => submit(inputValue)}
|
||||
disabled={!inputValue.trim()}
|
||||
className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
className="rounded-lg bg-indigo-600 px-4 py-2 text-white transition-colors hover:bg-indigo-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
|
||||
</svg>
|
||||
<Send className="h-5 w-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, fireEvent } from '@testing-library/react'
|
||||
import { CaseView } from './CaseView'
|
||||
import type { AdvisorCase } from './useAdvisorCase'
|
||||
import type { AdvisorResponse } from '@/lib/sdk/advisor/contract'
|
||||
|
||||
const clarify: AdvisorResponse = {
|
||||
mode: 'clarify',
|
||||
question: 'Was ist PDCA?',
|
||||
clarity: {
|
||||
is_underspecified: true,
|
||||
concentration: 0.38,
|
||||
suggested_contexts: [
|
||||
{ id: 'datenschutz', label: 'Datenschutz' },
|
||||
{ id: 'qm', label: 'Qualitätsmanagement' },
|
||||
],
|
||||
},
|
||||
general_answer: 'PDCA steht für **Plan-Do-Check-Act**.',
|
||||
answer: null,
|
||||
evidence: [],
|
||||
citations: [],
|
||||
visual_evidence: [],
|
||||
footnotes: [],
|
||||
}
|
||||
|
||||
const answer: AdvisorResponse = {
|
||||
mode: 'answer',
|
||||
question: 'PDCA im Datenschutz?',
|
||||
clarity: { is_underspecified: false, dominant_context: 'datenschutz', concentration: 0.88 },
|
||||
answer: 'Der DSM-Zyklus [1] beschreibt den Ablauf.',
|
||||
evidence: [
|
||||
{ evidence_id: 'e1', document: 'DSK Sdm B41', section: 'Art. 5', paragraph: 'Abs. 2', snippet: 'x' },
|
||||
],
|
||||
citations: [
|
||||
{ citation_id: 'c1', evidence_id: 'e1', document: 'DSK Sdm B41', section: 'Art. 5', paragraph: 'Abs. 2' },
|
||||
],
|
||||
visual_evidence: [
|
||||
{ visual_id: 'v1', visual_type: 'flowchart', caption: 'PDCA-Zyklus', document: 'DSK SDM', vision_summary: 's' },
|
||||
],
|
||||
footnotes: [],
|
||||
}
|
||||
|
||||
function mk(response: AdvisorResponse): AdvisorCase {
|
||||
return { id: 'case1', question: response.question, response, selectedContext: null, status: 'done' }
|
||||
}
|
||||
|
||||
describe('CaseView — clarify mode', () => {
|
||||
it('renders the L1 general answer + context chips and fires onSelectContext', () => {
|
||||
const onSel = vi.fn()
|
||||
const { container, getByText } = render(
|
||||
<CaseView c={mk(clarify)} busy={false} onSelectContext={onSel} />,
|
||||
)
|
||||
expect(container.textContent).toContain('Plan-Do-Check-Act')
|
||||
expect(container.textContent).toContain('Allgemeine Definition')
|
||||
fireEvent.click(getByText('Datenschutz'))
|
||||
expect(onSel).toHaveBeenCalledWith('datenschutz')
|
||||
})
|
||||
})
|
||||
|
||||
describe('CaseView — answer mode', () => {
|
||||
it('renders answer with a clickable [n] citation, grouped evidence (friendly name), and visual', () => {
|
||||
const { container } = render(<CaseView c={mk(answer)} busy={false} onSelectContext={() => {}} />)
|
||||
expect(container.textContent).toContain('DSM-Zyklus')
|
||||
expect(container.querySelector('button[title="Beleg 1 anzeigen"]')).not.toBeNull()
|
||||
expect(container.textContent).toContain('DSK Standard-Datenschutzmodell')
|
||||
expect(container.textContent).toContain('PDCA-Zyklus')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,75 @@
|
||||
'use client'
|
||||
|
||||
import type { AdvisorResponse } from '@/lib/sdk/advisor/contract'
|
||||
import type { AdvisorCase } from './useAdvisorCase'
|
||||
import { ClarifyView } from './ClarifyView'
|
||||
import { EvidenceSummary } from './EvidenceSummary'
|
||||
import { EvidencePane } from './EvidencePane'
|
||||
import { VisualEvidencePane } from './VisualEvidencePane'
|
||||
import { FootnotesPane } from './FootnotesPane'
|
||||
import { Markdown } from './Markdown'
|
||||
import { useCitationHighlight } from './useCitationHighlight'
|
||||
|
||||
export function LoadingDots() {
|
||||
return (
|
||||
<div className="flex space-x-1 px-1 py-2" aria-label="Antwort wird erstellt">
|
||||
<span className="h-2 w-2 animate-bounce rounded-full bg-gray-400" />
|
||||
<span className="h-2 w-2 animate-bounce rounded-full bg-gray-400" style={{ animationDelay: '0.1s' }} />
|
||||
<span className="h-2 w-2 animate-bounce rounded-full bg-gray-400" style={{ animationDelay: '0.2s' }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ErrorBox({ msg }: { msg?: string }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
{msg || 'Verbindung fehlgeschlagen'}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Answer mode body (stacked): summary + answer (with [n] coupling) + evidence/visual/footnotes. */
|
||||
export function AnswerBody({ response }: { response: AdvisorResponse }) {
|
||||
const { highlightedId, cite } = useCitationHighlight(response.citations)
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<EvidenceSummary response={response} />
|
||||
<div className="rounded-lg border border-gray-200 bg-white px-3 py-2">
|
||||
<Markdown content={response.answer || ''} citations={cite} />
|
||||
</div>
|
||||
<EvidencePane evidence={response.evidence} highlightedId={highlightedId} />
|
||||
<VisualEvidencePane items={response.visual_evidence} />
|
||||
<FootnotesPane footnotes={response.footnotes} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** One case rendered stacked (narrow mode). Clarify -> L1 + chips; answer -> full evidence body. */
|
||||
export function CaseView({
|
||||
c,
|
||||
onSelectContext,
|
||||
busy,
|
||||
showQuestion,
|
||||
}: {
|
||||
c: AdvisorCase
|
||||
onSelectContext: (ctx: string) => void
|
||||
busy: boolean
|
||||
showQuestion?: boolean
|
||||
}) {
|
||||
const r = c.response
|
||||
return (
|
||||
<div className="space-y-2 border-b border-gray-100 pb-4 last:border-0">
|
||||
{showQuestion && (
|
||||
<div className="text-xs text-gray-500">
|
||||
<span className="font-medium text-gray-400">Frage:</span> {c.question}
|
||||
</div>
|
||||
)}
|
||||
{c.status === 'loading' && <LoadingDots />}
|
||||
{c.status === 'error' && <ErrorBox msg={c.error} />}
|
||||
{r && r.mode === 'clarify' && (
|
||||
<ClarifyView response={r} onSelectContext={onSelectContext} busy={busy} />
|
||||
)}
|
||||
{r && r.mode === 'answer' && <AnswerBody response={r} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
'use client'
|
||||
|
||||
import { Info } from 'lucide-react'
|
||||
import type { AdvisorResponse } from '@/lib/sdk/advisor/contract'
|
||||
import { Markdown } from './Markdown'
|
||||
|
||||
/**
|
||||
* Clarify mode: a short general (L1) definition — explicitly marked as general, no legal source —
|
||||
* plus domain context chips. Picking a chip re-runs the case scoped to that domain (-> L2).
|
||||
*/
|
||||
export function ClarifyView({
|
||||
response,
|
||||
onSelectContext,
|
||||
busy,
|
||||
}: {
|
||||
response: AdvisorResponse
|
||||
onSelectContext: (id: string) => void
|
||||
busy: boolean
|
||||
}) {
|
||||
const chips = response.clarity.suggested_contexts ?? []
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2">
|
||||
<div className="mb-1 flex items-center gap-1 text-[11px] font-semibold text-amber-700">
|
||||
<Info className="h-3.5 w-3.5" />
|
||||
Allgemeine Definition (ohne Rechtsquelle)
|
||||
</div>
|
||||
<Markdown content={response.general_answer || ''} />
|
||||
</div>
|
||||
{chips.length > 0 && (
|
||||
<div>
|
||||
<div className="mb-1.5 text-xs font-medium text-gray-700">
|
||||
Meintest du einen bestimmten Kontext?
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{chips.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => onSelectContext(c.id)}
|
||||
className="rounded-full border border-indigo-200 bg-white px-3 py-1 text-xs font-medium text-indigo-700 transition-colors hover:bg-indigo-50 disabled:opacity-50"
|
||||
>
|
||||
{c.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
'use client'
|
||||
|
||||
import { ShieldCheck } from 'lucide-react'
|
||||
|
||||
export const EXAMPLE_QUESTIONS: Record<string, string[]> = {
|
||||
vvt: [
|
||||
'Was ist ein Verarbeitungsverzeichnis?',
|
||||
'Welche Informationen muss ich erfassen?',
|
||||
'Wie dokumentiere ich die Rechtsgrundlage?',
|
||||
],
|
||||
'compliance-scope': [
|
||||
'Was bedeutet L3?',
|
||||
'Wann brauche ich eine DSFA?',
|
||||
'Was ist der Unterschied zwischen L2 und L3?',
|
||||
],
|
||||
tom: [
|
||||
'Was sind TOM?',
|
||||
'Welche Massnahmen sind erforderlich?',
|
||||
'Wie dokumentiere ich Verschluesselung?',
|
||||
],
|
||||
dsfa: ['Was ist eine DSFA?', 'Wann ist eine DSFA verpflichtend?', 'Wie bewerte ich Risiken?'],
|
||||
loeschfristen: [
|
||||
'Wie definiere ich Loeschfristen?',
|
||||
'Unterschied Loeschpflicht und Aufbewahrungspflicht?',
|
||||
'Wann muss ich Daten loeschen?',
|
||||
],
|
||||
default: [
|
||||
'Wie starte ich mit dem SDK?',
|
||||
'Was ist der erste Schritt?',
|
||||
'Welche Compliance-Anforderungen gelten fuer KI-Systeme?',
|
||||
],
|
||||
}
|
||||
|
||||
export function AdvisorEmptyState({
|
||||
exampleQuestions,
|
||||
onExampleClick,
|
||||
}: {
|
||||
exampleQuestions: string[]
|
||||
onExampleClick: (question: string) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="px-4 py-8 text-center">
|
||||
<div className="mx-auto mb-3 flex h-14 w-14 items-center justify-center rounded-full bg-indigo-100">
|
||||
<ShieldCheck className="h-7 w-7 text-indigo-600" />
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-gray-900">Compliance Advisor</h3>
|
||||
<p className="mx-auto mt-1 max-w-xs text-xs text-gray-500">
|
||||
Antworten mit nachvollziehbaren Quellen, Fundstellen und — wo vorhanden — Original-Abbildungen.
|
||||
</p>
|
||||
<div className="mt-4 space-y-2 text-left">
|
||||
<p className="text-xs font-medium text-gray-700">Beispielfragen</p>
|
||||
{exampleQuestions.map((q, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => onExampleClick(q)}
|
||||
className="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-left text-xs text-gray-700 transition-colors hover:bg-indigo-50"
|
||||
>
|
||||
{q}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { ChevronDown, ChevronRight, Library } from 'lucide-react'
|
||||
import type { EvidenceUnit } from '@/lib/sdk/advisor/contract'
|
||||
import { resolveRegulation } from '@/lib/sdk/advisor/regulation-display'
|
||||
import { EvidenceUnitCard } from './EvidenceUnitCard'
|
||||
import { PaneHeader } from './PaneHeader'
|
||||
|
||||
interface Group {
|
||||
key: string
|
||||
label: string
|
||||
units: EvidenceUnit[]
|
||||
}
|
||||
|
||||
function groupByFamily(units: EvidenceUnit[]): Group[] {
|
||||
const map = new Map<string, Group>()
|
||||
for (const u of units) {
|
||||
const d = resolveRegulation({ code: u.document, short: u.document })
|
||||
const g = map.get(d.familyKey) ?? { key: d.familyKey, label: d.familyLabel, units: [] }
|
||||
g.units.push(u)
|
||||
map.set(d.familyKey, g)
|
||||
}
|
||||
return [...map.values()].sort((a, b) => b.units.length - a.units.length)
|
||||
}
|
||||
|
||||
function EvidenceGroup({ group, highlightedId }: { group: Group; highlightedId?: string }) {
|
||||
const [open, setOpen] = useState(group.units.length <= 3)
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-200 bg-white">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex w-full items-center justify-between gap-2 px-2.5 py-2 text-left"
|
||||
>
|
||||
<span className="min-w-0 truncate text-xs font-semibold text-gray-900">{group.label}</span>
|
||||
<span className="flex flex-shrink-0 items-center gap-1 text-[11px] text-gray-500">
|
||||
{group.units.length} Treffer
|
||||
{open ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
|
||||
</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="space-y-1 border-t border-gray-100 px-2 py-2">
|
||||
{group.units.map((u) => (
|
||||
<EvidenceUnitCard key={u.evidence_id} unit={u} compact highlighted={u.evidence_id === highlightedId} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Evidence pane — units grouped by document/regulation family, count + expandable. */
|
||||
export function EvidencePane({
|
||||
evidence,
|
||||
highlightedId,
|
||||
}: {
|
||||
evidence: EvidenceUnit[]
|
||||
highlightedId?: string
|
||||
}) {
|
||||
const groups = groupByFamily(evidence)
|
||||
return (
|
||||
<section>
|
||||
<PaneHeader icon={<Library className="h-3.5 w-3.5 text-gray-500" />} title="Evidence" count={evidence.length} />
|
||||
{groups.length === 0 ? (
|
||||
<p className="px-1 text-[11px] text-gray-400">Keine strukturierte Evidence zu dieser Antwort.</p>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{groups.map((g) => (
|
||||
<EvidenceGroup key={g.key} group={g} highlightedId={highlightedId} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
'use client'
|
||||
|
||||
import { BookMarked, FileText, Hash, Image as ImageIcon, Library, Scale } from 'lucide-react'
|
||||
import type { AdvisorResponse } from '@/lib/sdk/advisor/contract'
|
||||
import {
|
||||
provisionSummary,
|
||||
summarizeEvidence,
|
||||
type FamilyGroup,
|
||||
} from '@/lib/sdk/advisor/evidence-grouping'
|
||||
|
||||
const plural = (n: number, one: string, many: string) => (n === 1 ? one : many)
|
||||
|
||||
function Count({
|
||||
icon,
|
||||
value,
|
||||
label,
|
||||
dim,
|
||||
}: {
|
||||
icon: React.ReactNode
|
||||
value: number
|
||||
label: string
|
||||
dim?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center gap-2 rounded-lg border px-2.5 py-1.5 ${
|
||||
dim ? 'border-gray-100 bg-gray-50' : 'border-gray-200 bg-white'
|
||||
}`}
|
||||
>
|
||||
<span className={dim ? 'text-gray-300' : 'text-indigo-500'}>{icon}</span>
|
||||
<span>
|
||||
<span className={`text-sm font-bold ${dim ? 'text-gray-400' : 'text-gray-900'}`}>{value}</span>{' '}
|
||||
<span className="text-[11px] text-gray-500">{label}</span>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function GroupRow({ group, icon }: { group: FamilyGroup; icon: React.ReactNode }) {
|
||||
// A single-unit guidance doc needs no "1 Fundstelle" noise; norms always show their provisions.
|
||||
const detail = group.sections.length === 0 && group.units <= 1 ? '' : provisionSummary(group)
|
||||
return (
|
||||
<div className="flex items-start gap-2 py-0.5">
|
||||
<span className="mt-0.5 shrink-0 text-gray-400">{icon}</span>
|
||||
<span className="min-w-0 flex-1 text-[12px] leading-snug text-gray-700">{group.label}</span>
|
||||
{detail && <span className="shrink-0 text-[11px] font-medium text-gray-500">{detail}</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Section({
|
||||
title,
|
||||
groups,
|
||||
icon,
|
||||
}: {
|
||||
title: string
|
||||
groups: FamilyGroup[]
|
||||
icon: React.ReactNode
|
||||
}) {
|
||||
if (groups.length === 0) return null
|
||||
return (
|
||||
<div className="mt-2 first:mt-0">
|
||||
<div className="mb-0.5 text-[10px] font-semibold uppercase tracking-wide text-gray-400">{title}</div>
|
||||
{groups.map((g) => (
|
||||
<GroupRow key={g.key} group={g} icon={icon} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* "Diese Antwort stützt sich auf" — describes the EVIDENCE (not the documents), objective counts
|
||||
* only (no fabricated trust score). When the Legal-KG ships `bindingness`, binding Rechtsgrundlagen
|
||||
* are split from Leitlinien (soft-law guidance); until then it shows a neutral evidence breakdown.
|
||||
*/
|
||||
export function EvidenceSummary({ response }: { response: AdvisorResponse }) {
|
||||
const m = summarizeEvidence(response.evidence)
|
||||
const figures = response.visual_evidence.length
|
||||
const notes = response.footnotes.length
|
||||
const cls = 'h-4 w-4'
|
||||
const smallIcon = 'h-3.5 w-3.5'
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-1.5 text-[10px] font-semibold uppercase tracking-wide text-gray-400">
|
||||
Diese Antwort stützt sich auf
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{m.hasBindingness && (
|
||||
<>
|
||||
<Count
|
||||
icon={<Scale className={cls} />}
|
||||
value={m.normProvisions}
|
||||
label={plural(m.normProvisions, 'Rechtsgrundlage', 'Rechtsgrundlagen')}
|
||||
/>
|
||||
<Count
|
||||
icon={<BookMarked className={cls} />}
|
||||
value={m.guidanceCount}
|
||||
label={plural(m.guidanceCount, 'Leitlinie', 'Leitlinien')}
|
||||
dim={m.guidanceCount === 0}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Count
|
||||
icon={<ImageIcon className={cls} />}
|
||||
value={figures}
|
||||
label={plural(figures, 'Abbildung', 'Abbildungen')}
|
||||
dim={figures === 0}
|
||||
/>
|
||||
<Count
|
||||
icon={<Hash className={cls} />}
|
||||
value={notes}
|
||||
label={plural(notes, 'Fußnote', 'Fußnoten')}
|
||||
dim={notes === 0}
|
||||
/>
|
||||
<Count icon={<FileText className={cls} />} value={m.unitCount} label="Evidence Units" />
|
||||
</div>
|
||||
|
||||
{m.groups.length > 0 && (
|
||||
<div className="mt-2.5 rounded-lg border border-gray-100 bg-gray-50/60 px-2.5 py-1.5">
|
||||
{m.hasBindingness ? (
|
||||
<>
|
||||
<Section title="Rechtsgrundlagen" groups={m.norms} icon={<Scale className={smallIcon} />} />
|
||||
<Section title="Leitlinien" groups={m.guidance} icon={<BookMarked className={smallIcon} />} />
|
||||
<Section title="Weitere" groups={m.other} icon={<FileText className={smallIcon} />} />
|
||||
</>
|
||||
) : (
|
||||
m.groups.map((g) => <GroupRow key={g.key} group={g} icon={<Library className={smallIcon} />} />)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { ChevronDown, ChevronRight, ExternalLink } from 'lucide-react'
|
||||
import type { EvidenceUnit } from '@/lib/sdk/advisor/contract'
|
||||
import { resolveRegulation } from '@/lib/sdk/advisor/regulation-display'
|
||||
|
||||
/** One evidence unit (contract shape). Compact inside a document group: chapter/section only. */
|
||||
export function EvidenceUnitCard({
|
||||
unit,
|
||||
compact,
|
||||
highlighted,
|
||||
}: {
|
||||
unit: EvidenceUnit
|
||||
compact?: boolean
|
||||
highlighted?: boolean
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const d = resolveRegulation({ code: unit.document, short: unit.document })
|
||||
const crumbs = [unit.section, unit.paragraph].filter((x): x is string => Boolean(x))
|
||||
const canOpen = !!unit.url && /^https?:\/\//i.test(unit.url)
|
||||
|
||||
const header = compact ? (d.chapter ? `Kapitel ${d.chapter}` : crumbs[0] || d.familyLabel) : d.familyLabel
|
||||
const sub = compact && !d.chapter && crumbs.length ? crumbs.slice(1) : crumbs
|
||||
|
||||
return (
|
||||
<div
|
||||
id={`ev-${unit.evidence_id}`}
|
||||
className={`${
|
||||
compact
|
||||
? 'rounded-md border border-gray-100 bg-gray-50 p-2'
|
||||
: 'rounded-lg border border-gray-200 bg-white p-2.5'
|
||||
} ${highlighted ? 'ring-2 ring-indigo-400' : ''}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-xs font-semibold text-gray-900">{header}</div>
|
||||
{sub.length > 0 && (
|
||||
<div className="mt-0.5 flex flex-wrap items-center gap-x-1 text-[11px] text-gray-500">
|
||||
{sub.map((c, i) => (
|
||||
<span key={i} className="flex items-center gap-1">
|
||||
{i > 0 && <span className="text-gray-300">›</span>}
|
||||
{c}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{canOpen && (
|
||||
<a
|
||||
href={unit.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex flex-shrink-0 items-center gap-0.5 rounded px-1.5 py-0.5 text-[11px] font-medium text-indigo-600 hover:bg-indigo-50"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
öffnen
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
{unit.snippet && (
|
||||
<div className="mt-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex items-center gap-0.5 text-[11px] text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{open ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
|
||||
Textauszug
|
||||
</button>
|
||||
{open && (
|
||||
<p className="mt-1 border-l-2 border-gray-200 pl-2 text-[11px] italic text-gray-500">
|
||||
{unit.snippet}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { AdvisorCase } from './useAdvisorCase'
|
||||
import { StickyQuestion } from './StickyQuestion'
|
||||
import { AdvisorEmptyState } from './EmptyState'
|
||||
import { CaseView, LoadingDots, ErrorBox } from './CaseView'
|
||||
import { ClarifyView } from './ClarifyView'
|
||||
import { EvidenceSummary } from './EvidenceSummary'
|
||||
import { EvidencePane } from './EvidencePane'
|
||||
import { VisualEvidencePane } from './VisualEvidencePane'
|
||||
import { FootnotesPane } from './FootnotesPane'
|
||||
import { Markdown } from './Markdown'
|
||||
import { useCitationHighlight } from './useCitationHighlight'
|
||||
|
||||
/**
|
||||
* Advisor body as a series of CASES.
|
||||
* - Narrow: stacked cases with a pinned last question.
|
||||
* - Wide: 3-column Case Workspace — question+summary (left) | answer/clarify (center) | evidence (right).
|
||||
*/
|
||||
export function EvidenceWorkspace({
|
||||
cases,
|
||||
expanded,
|
||||
busy,
|
||||
exampleQuestions,
|
||||
onExample,
|
||||
onSelectContext,
|
||||
}: {
|
||||
cases: AdvisorCase[]
|
||||
expanded: boolean
|
||||
busy: boolean
|
||||
exampleQuestions: string[]
|
||||
onExample: (q: string) => void
|
||||
onSelectContext: (caseId: string, ctx: string) => void
|
||||
}) {
|
||||
const [activeId, setActiveId] = useState<string | null>(null)
|
||||
const endRef = useRef<HTMLDivElement>(null)
|
||||
const latest = cases[cases.length - 1]
|
||||
const active = cases.find((c) => c.id === activeId) ?? latest
|
||||
|
||||
useEffect(() => {
|
||||
setActiveId(null)
|
||||
}, [cases.length])
|
||||
useEffect(() => {
|
||||
if (!expanded) endRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [cases.length, expanded])
|
||||
|
||||
const answer = active?.response?.mode === 'answer' ? active.response : null
|
||||
const { highlightedId, cite } = useCitationHighlight(answer?.citations ?? [])
|
||||
|
||||
if (cases.length === 0) {
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto bg-gray-50">
|
||||
<AdvisorEmptyState exampleQuestions={exampleQuestions} onExampleClick={onExample} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!expanded) {
|
||||
return (
|
||||
<div className="min-h-0 flex-1 overflow-y-auto bg-gray-50">
|
||||
{latest && <StickyQuestion question={latest.question} />}
|
||||
<div className="space-y-4 p-4">
|
||||
{cases.map((c, i) => (
|
||||
<CaseView
|
||||
key={c.id}
|
||||
c={c}
|
||||
busy={busy}
|
||||
showQuestion={i !== cases.length - 1}
|
||||
onSelectContext={(ctx) => onSelectContext(c.id, ctx)}
|
||||
/>
|
||||
))}
|
||||
<div ref={endRef} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const r = active?.response
|
||||
return (
|
||||
<div className="grid min-h-0 flex-1 grid-cols-[220px_1fr_320px] divide-x divide-gray-200 overflow-hidden">
|
||||
<aside className="min-h-0 overflow-y-auto bg-indigo-50/40 p-3">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wide text-indigo-400">Frage</div>
|
||||
<div className="mb-3 text-sm font-medium text-gray-800">{active?.question}</div>
|
||||
{answer && <EvidenceSummary response={answer} />}
|
||||
{cases.length > 1 && (
|
||||
<div className="mt-4">
|
||||
<div className="mb-1 text-[10px] font-semibold uppercase tracking-wide text-gray-400">Verlauf</div>
|
||||
<div className="space-y-1">
|
||||
{cases.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
onClick={() => setActiveId(c.id)}
|
||||
className={`block w-full truncate rounded px-2 py-1 text-left text-[11px] ${
|
||||
c.id === active?.id ? 'bg-indigo-100 text-indigo-800' : 'text-gray-600 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
{c.question}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
<main className="min-h-0 overflow-y-auto bg-gray-50 p-4">
|
||||
{active?.status === 'loading' && <LoadingDots />}
|
||||
{active?.status === 'error' && <ErrorBox msg={active.error} />}
|
||||
{r?.mode === 'clarify' && (
|
||||
<ClarifyView
|
||||
response={r}
|
||||
busy={busy}
|
||||
onSelectContext={(ctx) => active && onSelectContext(active.id, ctx)}
|
||||
/>
|
||||
)}
|
||||
{r?.mode === 'answer' && (
|
||||
<div className="rounded-lg border border-gray-200 bg-white px-3 py-2">
|
||||
<Markdown content={r.answer || ''} citations={cite} />
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<aside className="min-h-0 space-y-3 overflow-y-auto bg-gray-50 p-3">
|
||||
{answer ? (
|
||||
<>
|
||||
<EvidencePane evidence={answer.evidence} highlightedId={highlightedId} />
|
||||
<VisualEvidencePane items={answer.visual_evidence} />
|
||||
<FootnotesPane footnotes={answer.footnotes} />
|
||||
</>
|
||||
) : (
|
||||
<p className="px-1 text-[11px] text-gray-400">Evidence erscheint nach Auswahl eines Kontexts.</p>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
'use client'
|
||||
|
||||
import { Hash } from 'lucide-react'
|
||||
import type { Footnote } from '@/lib/sdk/advisor/contract'
|
||||
import { PaneHeader } from './PaneHeader'
|
||||
|
||||
/** Footnotes pane (C-FN) — rendered only when present. */
|
||||
export function FootnotesPane({ footnotes }: { footnotes: Footnote[] }) {
|
||||
if (footnotes.length === 0) return null
|
||||
return (
|
||||
<section>
|
||||
<PaneHeader icon={<Hash className="h-3.5 w-3.5 text-gray-500" />} title="Fußnoten" count={footnotes.length} />
|
||||
<div className="space-y-1">
|
||||
{footnotes.map((fn, i) => (
|
||||
<div key={fn.footnote_id || i} className="rounded-md border border-gray-200 bg-white p-2 text-[11px]">
|
||||
<span className="font-semibold text-gray-900">{fn.ref || `Fußnote ${i + 1}`}</span>
|
||||
{(fn.document || fn.section) && (
|
||||
<span className="text-gray-400">
|
||||
{' · '}
|
||||
{fn.document}
|
||||
{fn.section ? ` / ${fn.section}` : ''}
|
||||
</span>
|
||||
)}
|
||||
{fn.text && <p className="mt-0.5 text-gray-600">{fn.text}</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render } from '@testing-library/react'
|
||||
import { Markdown } from './Markdown'
|
||||
|
||||
describe('Markdown', () => {
|
||||
it('renders headings, bold and bullet lists (not raw markdown markers)', () => {
|
||||
const { container } = render(
|
||||
<Markdown
|
||||
content={'## Pflichten\n\nDer **Verantwortliche** muss:\n\n- ein Verzeichnis fuehren\n- Risiken bewerten'}
|
||||
/>,
|
||||
)
|
||||
expect(container.querySelector('h4')?.textContent).toBe('Pflichten')
|
||||
expect(container.querySelector('strong')?.textContent).toBe('Verantwortliche')
|
||||
expect(container.querySelectorAll('li')).toHaveLength(2)
|
||||
expect(container.textContent).not.toContain('##')
|
||||
expect(container.textContent).not.toContain('**')
|
||||
})
|
||||
|
||||
it('renders ordered lists and inline code', () => {
|
||||
const { container } = render(<Markdown content={'1. Erst `init`\n2. Dann `build`'} />)
|
||||
expect(container.querySelector('ol')).not.toBeNull()
|
||||
expect(container.querySelectorAll('li')).toHaveLength(2)
|
||||
expect(container.querySelectorAll('code')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('renders fenced code blocks', () => {
|
||||
const { container } = render(<Markdown content={'```\nconst x = 1\n```'} />)
|
||||
expect(container.querySelector('pre')).not.toBeNull()
|
||||
expect(container.textContent).toContain('const x = 1')
|
||||
})
|
||||
|
||||
it('only allows http(s) links', () => {
|
||||
const { container } = render(
|
||||
<Markdown content={'[ok](https://example.test) and [bad](javascript:alert(1))'} />,
|
||||
)
|
||||
const links = container.querySelectorAll('a')
|
||||
expect(links).toHaveLength(1)
|
||||
expect(links[0].getAttribute('href')).toBe('https://example.test')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,176 @@
|
||||
'use client'
|
||||
|
||||
// Minimal, SAFE markdown -> React renderer. No dangerouslySetInnerHTML, no dependency.
|
||||
// Covers the subset LLMs emit: headings, bold, italic, inline code, fenced code, ul/ol, links.
|
||||
// Plus deliberate [n] citation markers (mapped via `citations`, NOT parsed for structure).
|
||||
|
||||
export interface CiteHandler {
|
||||
count: number
|
||||
onSelect: (n: number) => void
|
||||
}
|
||||
|
||||
const INLINE_RE =
|
||||
/(`[^`]+`|\*\*[^*]+\*\*|\*[^*\s][^*]*\*|_[^_]+_|\[[^\]]+\]\([^)]+\)|\[\d+\])/g
|
||||
|
||||
function renderInline(text: string, kp: string, cite?: CiteHandler): React.ReactNode[] {
|
||||
const nodes: React.ReactNode[] = []
|
||||
let last = 0
|
||||
let idx = 0
|
||||
INLINE_RE.lastIndex = 0
|
||||
let m: RegExpExecArray | null
|
||||
while ((m = INLINE_RE.exec(text)) !== null) {
|
||||
if (m.index > last) nodes.push(text.slice(last, m.index))
|
||||
const tok = m[0]
|
||||
const key = `${kp}-${idx++}`
|
||||
if (tok.startsWith('`')) {
|
||||
nodes.push(
|
||||
<code key={key} className="rounded bg-gray-100 px-1 py-0.5 font-mono text-[0.85em]">
|
||||
{tok.slice(1, -1)}
|
||||
</code>,
|
||||
)
|
||||
} else if (tok.startsWith('**')) {
|
||||
nodes.push(
|
||||
<strong key={key} className="font-semibold text-gray-900">
|
||||
{tok.slice(2, -2)}
|
||||
</strong>,
|
||||
)
|
||||
} else if (tok.startsWith('*') || tok.startsWith('_')) {
|
||||
nodes.push(<em key={key}>{tok.slice(1, -1)}</em>)
|
||||
} else if (/^\[\d+\]$/.test(tok)) {
|
||||
const n = parseInt(tok.slice(1, -1), 10)
|
||||
if (cite && n >= 1 && n <= cite.count) {
|
||||
nodes.push(
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => cite.onSelect(n)}
|
||||
className="mx-0.5 align-super text-[10px] font-semibold text-indigo-600 hover:underline"
|
||||
title={`Beleg ${n} anzeigen`}
|
||||
>
|
||||
[{n}]
|
||||
</button>,
|
||||
)
|
||||
} else {
|
||||
nodes.push(tok)
|
||||
}
|
||||
} else {
|
||||
const mm = /^\[([^\]]+)\]\(([^)]+)\)$/.exec(tok)
|
||||
if (mm && /^https?:\/\//i.test(mm[2])) {
|
||||
nodes.push(
|
||||
<a
|
||||
key={key}
|
||||
href={mm[2]}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-indigo-600 underline hover:text-indigo-800"
|
||||
>
|
||||
{mm[1]}
|
||||
</a>,
|
||||
)
|
||||
} else {
|
||||
nodes.push(mm ? mm[1] : tok)
|
||||
}
|
||||
}
|
||||
last = m.index + tok.length
|
||||
}
|
||||
if (last < text.length) nodes.push(text.slice(last))
|
||||
return nodes
|
||||
}
|
||||
|
||||
function Heading({ level, kp, text, cite }: { level: number; kp: string; text: string; cite?: CiteHandler }) {
|
||||
const children = renderInline(text, kp, cite)
|
||||
if (level <= 1) return <h3 className="mb-1 mt-3 text-base font-bold text-gray-900">{children}</h3>
|
||||
if (level === 2) return <h4 className="mb-1 mt-3 text-sm font-bold text-gray-900">{children}</h4>
|
||||
return <h5 className="mb-1 mt-2 text-sm font-semibold text-gray-800">{children}</h5>
|
||||
}
|
||||
|
||||
const UL_RE = /^\s*[-*]\s+/
|
||||
const OL_RE = /^\s*\d+\.\s+/
|
||||
const H_RE = /^(#{1,6})\s+(.*)$/
|
||||
|
||||
export function Markdown({ content, citations }: { content: string; citations?: CiteHandler }) {
|
||||
const lines = (content || '').replace(/\r\n/g, '\n').split('\n')
|
||||
const blocks: React.ReactNode[] = []
|
||||
let i = 0
|
||||
while (i < lines.length) {
|
||||
const line = lines[i]
|
||||
const key = `b${blocks.length}`
|
||||
|
||||
if (line.trim().startsWith('```')) {
|
||||
const buf: string[] = []
|
||||
i++
|
||||
while (i < lines.length && !lines[i].trim().startsWith('```')) {
|
||||
buf.push(lines[i])
|
||||
i++
|
||||
}
|
||||
i++
|
||||
blocks.push(
|
||||
<pre
|
||||
key={key}
|
||||
className="my-2 overflow-x-auto rounded bg-gray-900 p-3 font-mono text-xs text-gray-100"
|
||||
>
|
||||
<code>{buf.join('\n')}</code>
|
||||
</pre>,
|
||||
)
|
||||
continue
|
||||
}
|
||||
if (line.trim() === '') {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
const h = H_RE.exec(line)
|
||||
if (h) {
|
||||
blocks.push(<Heading key={key} kp={key} level={h[1].length} text={h[2]} cite={citations} />)
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if (UL_RE.test(line)) {
|
||||
const items: string[] = []
|
||||
while (i < lines.length && UL_RE.test(lines[i])) {
|
||||
items.push(lines[i].replace(UL_RE, ''))
|
||||
i++
|
||||
}
|
||||
blocks.push(
|
||||
<ul key={key} className="my-1.5 ml-4 list-disc space-y-1 text-gray-700">
|
||||
{items.map((it, k) => (
|
||||
<li key={k}>{renderInline(it, `${key}-${k}`, citations)}</li>
|
||||
))}
|
||||
</ul>,
|
||||
)
|
||||
continue
|
||||
}
|
||||
if (OL_RE.test(line)) {
|
||||
const items: string[] = []
|
||||
while (i < lines.length && OL_RE.test(lines[i])) {
|
||||
items.push(lines[i].replace(OL_RE, ''))
|
||||
i++
|
||||
}
|
||||
blocks.push(
|
||||
<ol key={key} className="my-1.5 ml-5 list-decimal space-y-1 text-gray-700">
|
||||
{items.map((it, k) => (
|
||||
<li key={k}>{renderInline(it, `${key}-${k}`, citations)}</li>
|
||||
))}
|
||||
</ol>,
|
||||
)
|
||||
continue
|
||||
}
|
||||
const para: string[] = []
|
||||
while (
|
||||
i < lines.length &&
|
||||
lines[i].trim() !== '' &&
|
||||
!H_RE.test(lines[i]) &&
|
||||
!UL_RE.test(lines[i]) &&
|
||||
!OL_RE.test(lines[i]) &&
|
||||
!lines[i].trim().startsWith('```')
|
||||
) {
|
||||
para.push(lines[i])
|
||||
i++
|
||||
}
|
||||
blocks.push(
|
||||
<p key={key} className="my-1.5 leading-relaxed text-gray-700">
|
||||
{renderInline(para.join(' '), key, citations)}
|
||||
</p>,
|
||||
)
|
||||
}
|
||||
return <div className="advisor-markdown text-sm">{blocks}</div>
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
'use client'
|
||||
|
||||
/** Shared section header for evidence panes (icon + title + count badge). */
|
||||
export function PaneHeader({
|
||||
icon,
|
||||
title,
|
||||
count,
|
||||
}: {
|
||||
icon: React.ReactNode
|
||||
title: string
|
||||
count?: number
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-1.5 flex items-center gap-1.5 text-xs font-semibold text-gray-700">
|
||||
{icon}
|
||||
<span>{title}</span>
|
||||
{count != null && (
|
||||
<span className="rounded-full bg-gray-100 px-1.5 text-[10px] font-medium text-gray-500">
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
'use client'
|
||||
|
||||
import { HelpCircle } from 'lucide-react'
|
||||
|
||||
/** The last question, pinned so it never scrolls out of view while the answer grows. */
|
||||
export function StickyQuestion({ question }: { question: string }) {
|
||||
if (!question) return null
|
||||
return (
|
||||
<div className="sticky top-0 z-10 border-b border-indigo-100 bg-indigo-50/95 px-4 py-2 backdrop-blur">
|
||||
<div className="flex items-start gap-2">
|
||||
<HelpCircle className="mt-0.5 h-4 w-4 flex-shrink-0 text-indigo-500" />
|
||||
<div className="min-w-0">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wide text-indigo-400">
|
||||
Letzte Frage
|
||||
</div>
|
||||
<div className="text-sm font-medium text-gray-800">{question}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
'use client'
|
||||
|
||||
import { ExternalLink, Image as ImageIcon } from 'lucide-react'
|
||||
import type { VisualEvidence } from '@/lib/sdk/advisor/contract'
|
||||
import { PaneHeader } from './PaneHeader'
|
||||
|
||||
function VisualCard({ v }: { v: VisualEvidence }) {
|
||||
const canOpen = !!v.image_ref && /^https?:\/\//i.test(v.image_ref)
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-2.5">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-semibold text-gray-900">{v.caption || v.visual_type}</div>
|
||||
<div className="mt-0.5 flex flex-wrap items-center gap-1 text-[11px] text-gray-500">
|
||||
<span className="rounded bg-gray-100 px-1 text-[10px] uppercase tracking-wide text-gray-500">
|
||||
{v.visual_type}
|
||||
</span>
|
||||
<span>Quelle: {v.document}</span>
|
||||
</div>
|
||||
</div>
|
||||
{canOpen && (
|
||||
<a
|
||||
href={v.image_ref}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex flex-shrink-0 items-center gap-0.5 rounded px-1.5 py-0.5 text-[11px] font-medium text-indigo-600 hover:bg-indigo-50"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
Original anzeigen
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
{canOpen ? (
|
||||
<a href={v.image_ref} target="_blank" rel="noopener noreferrer" className="mt-1.5 block">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={v.image_ref}
|
||||
alt={v.caption || v.visual_type}
|
||||
loading="lazy"
|
||||
className="max-h-44 w-full rounded border border-gray-100 object-contain"
|
||||
/>
|
||||
</a>
|
||||
) : (
|
||||
<div className="mt-1.5 flex items-center justify-center rounded border border-dashed border-gray-200 bg-gray-50 px-3 py-5 text-[11px] text-gray-400">
|
||||
Original-Darstellung folgt
|
||||
</div>
|
||||
)}
|
||||
{v.vision_summary && <p className="mt-1.5 text-[11px] italic text-gray-500">{v.vision_summary}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Visual evidence (C8) — diagrams/figures, rendered only when present. */
|
||||
export function VisualEvidencePane({ items }: { items: VisualEvidence[] }) {
|
||||
if (items.length === 0) return null
|
||||
return (
|
||||
<section>
|
||||
<PaneHeader
|
||||
icon={<ImageIcon className="h-3.5 w-3.5 text-gray-500" />}
|
||||
title="Diagramme & Abbildungen"
|
||||
count={items.length}
|
||||
/>
|
||||
<div className="space-y-1.5">
|
||||
{items.map((v) => (
|
||||
<VisualCard key={v.visual_id} v={v} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import type { AdvisorResponse } from '@/lib/sdk/advisor/contract'
|
||||
|
||||
export interface AdvisorCase {
|
||||
id: string
|
||||
question: string
|
||||
response: AdvisorResponse | null
|
||||
selectedContext: string | null
|
||||
status: 'loading' | 'done' | 'error'
|
||||
error?: string
|
||||
}
|
||||
|
||||
interface UseAdvisorCaseArgs {
|
||||
currentStep: string
|
||||
country: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Drives the Advisor as a series of CASES. Each ask posts {question, context?} and receives a
|
||||
* structured AdvisorResponse (mode: clarify | answer) — no streaming, no answer-text parsing.
|
||||
* selectContext() re-runs the same case scoped to a chosen domain (clarify -> answer).
|
||||
*/
|
||||
export function useAdvisorCase({ currentStep, country }: UseAdvisorCaseArgs) {
|
||||
const [cases, setCases] = useState<AdvisorCase[]>([])
|
||||
const [busy, setBusy] = useState(false)
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
|
||||
const patch = useCallback((id: string, p: Partial<AdvisorCase>) => {
|
||||
setCases((prev) => prev.map((c) => (c.id === id ? { ...c, ...p } : c)))
|
||||
}, [])
|
||||
|
||||
const run = useCallback(
|
||||
async (id: string, question: string, context: string | null) => {
|
||||
setBusy(true)
|
||||
abortRef.current = new AbortController()
|
||||
try {
|
||||
const res = await fetch('/api/sdk/compliance-advisor/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ question, context, currentStep, country }),
|
||||
signal: abortRef.current.signal,
|
||||
})
|
||||
if (!res.ok) {
|
||||
const e = await res.json().catch(() => ({ error: 'Unbekannter Fehler' }))
|
||||
throw new Error(e.error || `Server-Fehler (${res.status})`)
|
||||
}
|
||||
const data = (await res.json()) as AdvisorResponse
|
||||
patch(id, { response: data, status: 'done', selectedContext: context })
|
||||
} catch (err) {
|
||||
if ((err as Error).name === 'AbortError') {
|
||||
patch(id, { status: 'done' })
|
||||
return
|
||||
}
|
||||
patch(id, {
|
||||
status: 'error',
|
||||
error: err instanceof Error ? err.message : 'Verbindung fehlgeschlagen',
|
||||
})
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
},
|
||||
[currentStep, country, patch],
|
||||
)
|
||||
|
||||
const ask = useCallback(
|
||||
(question: string) => {
|
||||
const q = question.trim()
|
||||
if (!q || busy) return
|
||||
const id = `case-${Date.now()}`
|
||||
setCases((prev) => [
|
||||
...prev,
|
||||
{ id, question: q, response: null, selectedContext: null, status: 'loading' },
|
||||
])
|
||||
void run(id, q, null)
|
||||
},
|
||||
[busy, run],
|
||||
)
|
||||
|
||||
const selectContext = useCallback(
|
||||
(id: string, context: string) => {
|
||||
const c = cases.find((x) => x.id === id)
|
||||
if (!c || busy) return
|
||||
patch(id, { status: 'loading', selectedContext: context })
|
||||
void run(id, c.question, context)
|
||||
},
|
||||
[cases, busy, run, patch],
|
||||
)
|
||||
|
||||
const stop = useCallback(() => {
|
||||
abortRef.current?.abort()
|
||||
setBusy(false)
|
||||
}, [])
|
||||
|
||||
return { cases, busy, ask, selectContext, stop }
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useState } from 'react'
|
||||
import type { AdvisorCase } from './useAdvisorCase'
|
||||
|
||||
function esc(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.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) =>
|
||||
`<li>${esc(e.document)}${e.section ? ` — ${esc(e.section)}` : ''}${e.paragraph ? ` ${esc(e.paragraph)}` : ''}</li>`,
|
||||
)
|
||||
.join('')
|
||||
return `<p style="color:#64748b;font-size:12px;margin:4px 0 0;">Evidence:</p><ul style="color:#64748b;font-size:12px;margin:2px 0;">${items}</ul>`
|
||||
}
|
||||
|
||||
/** Sends the consultation cases (question + answer + evidence) as an email to the DSB. */
|
||||
export function useAdvisorEmail(cases: AdvisorCase[], country: string, currentStep: string) {
|
||||
const [sending, setSending] = useState(false)
|
||||
const [sent, setSent] = useState(false)
|
||||
|
||||
const send = useCallback(async () => {
|
||||
if (cases.length === 0 || sending) return
|
||||
setSending(true)
|
||||
try {
|
||||
const qaHtml = cases
|
||||
.map((c) => {
|
||||
const a = c.response?.answer || c.response?.general_answer || '(keine Antwort)'
|
||||
return `<div style="margin-bottom:16px;"><p style="font-weight:600;color:#1e293b;">Frage: ${esc(
|
||||
c.question,
|
||||
)}</p><p style="color:#475569;white-space:pre-wrap;">${esc(a)}</p>${evidenceHtml(c)}</div>`
|
||||
})
|
||||
.join('')
|
||||
|
||||
const bodyHtml = `
|
||||
<h2 style="color:#1e293b;">Compliance Advisor — Beratungsprotokoll</h2>
|
||||
<p style="color:#64748b;font-size:13px;">Datum: ${esc(new Date().toLocaleString('de-DE'))} | Land: ${esc(country)} | Kontext: ${esc(currentStep)}</p>
|
||||
<hr style="border-color:#e2e8f0;margin:16px 0;">
|
||||
${qaHtml}
|
||||
<hr style="border-color:#e2e8f0;margin:16px 0;">
|
||||
<p style="color:#94a3b8;font-size:11px;">Automatisch erstellt vom BreakPilot Compliance Advisor</p>`
|
||||
|
||||
await fetch('/api/sdk/v1/agent/notify', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
recipient: 'dsb@breakpilot.local',
|
||||
subject: `Compliance Advisor — ${cases.length} Fragen (${currentStep})`,
|
||||
body_html: bodyHtml,
|
||||
role: 'Datenschutzbeauftragter',
|
||||
}),
|
||||
})
|
||||
setSent(true)
|
||||
setTimeout(() => setSent(false), 3000)
|
||||
} catch (e) {
|
||||
console.error('Email send failed:', e)
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
}, [cases, sending, country, currentStep])
|
||||
|
||||
return { send, sending, sent }
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import type { Citation } from '@/lib/sdk/advisor/contract'
|
||||
import type { CiteHandler } from './Markdown'
|
||||
|
||||
/**
|
||||
* Couples answer [n] markers to evidence cards: clicking [n] highlights + scrolls to the referenced
|
||||
* evidence unit. Works across layout columns via the card's DOM id (ev-<evidence_id>).
|
||||
*/
|
||||
export function useCitationHighlight(citations: Citation[]): {
|
||||
highlightedId?: string
|
||||
cite?: CiteHandler
|
||||
} {
|
||||
const [highlightedId, setHighlightedId] = useState<string | undefined>()
|
||||
if (citations.length === 0) return { highlightedId }
|
||||
return {
|
||||
highlightedId,
|
||||
cite: {
|
||||
count: citations.length,
|
||||
onSelect: (n: number) => {
|
||||
const c = citations[n - 1]
|
||||
if (!c) return
|
||||
setHighlightedId(c.evidence_id)
|
||||
if (typeof document !== 'undefined') {
|
||||
document.getElementById(`ev-${c.evidence_id}`)?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
})
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* E2E: Compliance Advisor — Clarity Gate (v3 contract)
|
||||
*
|
||||
* Drives the floating advisor widget end-to-end against a stubbed /api/sdk/compliance-advisor/chat
|
||||
* (contract fixtures), so the whole FE chain is exercised without the RAG/LLM backend:
|
||||
* - underspecified question -> clarify mode (L1 general answer + domain context chips)
|
||||
* - specific question -> answer mode (markdown + [n] citation coupling + evidence pane)
|
||||
* - clarify -> pick a context -> scoped answer
|
||||
* Runs on CI / macmini (needs the Next app on :3002).
|
||||
*/
|
||||
|
||||
import { test, expect } from '../fixtures/sdk-fixtures'
|
||||
|
||||
const CHAT_ROUTE = '**/api/sdk/compliance-advisor/chat'
|
||||
const openAdvisor = 'Compliance Advisor oeffnen'
|
||||
const inputPlaceholder = 'Frage eingeben...'
|
||||
|
||||
const CLARIFY = {
|
||||
mode: 'clarify',
|
||||
question: 'Was ist PDCA?',
|
||||
clarity: {
|
||||
is_underspecified: true,
|
||||
concentration: 0.3,
|
||||
suggested_contexts: [
|
||||
{ id: 'datenschutz', label: 'Datenschutz' },
|
||||
{ id: 'cyber', label: 'Cybersecurity' },
|
||||
],
|
||||
},
|
||||
general_answer: 'PDCA steht für **Plan-Do-Check-Act**.',
|
||||
answer: null,
|
||||
evidence: [],
|
||||
citations: [],
|
||||
visual_evidence: [],
|
||||
footnotes: [],
|
||||
}
|
||||
|
||||
const ANSWER = {
|
||||
mode: 'answer',
|
||||
question: 'CRA Meldefrist',
|
||||
clarity: { is_underspecified: false, dominant_context: 'cyber', concentration: 0.88 },
|
||||
answer: 'Die Meldung erfolgt unverzüglich [1].',
|
||||
evidence: [
|
||||
{ evidence_id: 'e1', document: 'CRA', section: 'Art. 14', paragraph: 'Abs. 1', snippet: 'unverzüglich melden', bindingness: 'binding' },
|
||||
],
|
||||
citations: [
|
||||
{ citation_id: 'c1', number: 1, evidence_id: 'e1', document: 'CRA', section: 'Art. 14', paragraph: 'Abs. 1' },
|
||||
],
|
||||
visual_evidence: [],
|
||||
footnotes: [],
|
||||
}
|
||||
|
||||
async function ask(page: import('@playwright/test').Page, question: string) {
|
||||
await page.getByRole('button', { name: openAdvisor }).click()
|
||||
const input = page.getByPlaceholder(inputPlaceholder)
|
||||
await input.fill(question)
|
||||
await input.press('Enter')
|
||||
}
|
||||
|
||||
test.describe('Compliance Advisor — Clarity Gate', () => {
|
||||
test('underspecified question -> clarify (L1 definition + context chips, no evidence)', async ({ sdkPage }) => {
|
||||
await sdkPage.route(CHAT_ROUTE, (r) =>
|
||||
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(CLARIFY) }),
|
||||
)
|
||||
await ask(sdkPage, 'Was ist PDCA?')
|
||||
|
||||
await expect(sdkPage.getByText('Allgemeine Definition')).toBeVisible()
|
||||
await expect(sdkPage.getByText('Plan-Do-Check-Act')).toBeVisible()
|
||||
await expect(sdkPage.getByRole('button', { name: 'Datenschutz' })).toBeVisible()
|
||||
await expect(sdkPage.getByRole('button', { name: 'Cybersecurity' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('specific question -> answer with [n] citation + evidence pane', async ({ sdkPage }) => {
|
||||
await sdkPage.route(CHAT_ROUTE, (r) =>
|
||||
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(ANSWER) }),
|
||||
)
|
||||
await ask(sdkPage, 'CRA Meldefrist')
|
||||
|
||||
await expect(sdkPage.getByText(/unverzüglich/)).toBeVisible()
|
||||
await expect(sdkPage.getByTitle('Beleg 1 anzeigen')).toBeVisible()
|
||||
// bindingness present -> header splits into Rechtsgrundlagen vs Leitlinien (evidence framing)
|
||||
await expect(sdkPage.getByText('Rechtsgrundlagen').first()).toBeVisible()
|
||||
// family name resolved for the user (shown both in the summary breakdown and the evidence card)
|
||||
await expect(sdkPage.getByText('Cyber Resilience Act (CRA)').first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('clarify -> pick a context -> scoped answer', async ({ sdkPage }) => {
|
||||
let calls = 0
|
||||
await sdkPage.route(CHAT_ROUTE, (r) => {
|
||||
calls += 1
|
||||
r.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(calls === 1 ? CLARIFY : ANSWER),
|
||||
})
|
||||
})
|
||||
await ask(sdkPage, 'Was ist PDCA?')
|
||||
await sdkPage.getByRole('button', { name: 'Datenschutz' }).click()
|
||||
|
||||
await expect(sdkPage.getByText(/unverzüglich/)).toBeVisible()
|
||||
await expect(sdkPage.getByTitle('Beleg 1 anzeigen')).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
groupByFamily,
|
||||
provisionSummary,
|
||||
summarizeEvidence,
|
||||
type FamilyGroup,
|
||||
} from '../advisor/evidence-grouping'
|
||||
import type { EvidenceUnit } from '../advisor/contract'
|
||||
|
||||
function u(p: Partial<EvidenceUnit> & { document: string }): EvidenceUnit {
|
||||
return { evidence_id: Math.random().toString(36).slice(2), ...p }
|
||||
}
|
||||
|
||||
// The Datenschutzerklärung scenario the user reviewed: 6 Kernnormen (5 DSGVO Artikel + § 25 TDDDG)
|
||||
// + 2 Leitlinien (DSK, EDPB) across 8 evidence units.
|
||||
const DSE: EvidenceUnit[] = [
|
||||
u({ document: 'DSGVO', section: 'Art. 6', bindingness: 'binding' }),
|
||||
u({ document: 'DSGVO', section: 'Art. 7', bindingness: 'binding' }),
|
||||
u({ document: 'DSGVO', section: 'Art. 12', bindingness: 'binding' }),
|
||||
u({ document: 'DSGVO', section: 'Art. 13', bindingness: 'binding' }),
|
||||
u({ document: 'DSGVO', section: 'Art. 14', bindingness: 'binding' }),
|
||||
u({ document: 'TDDDG', section: '§ 25', bindingness: 'binding' }),
|
||||
u({ document: 'DSK', bindingness: 'guidance' }),
|
||||
u({ document: 'EDPB WP 259', bindingness: 'guidance' }),
|
||||
]
|
||||
|
||||
describe('groupByFamily', () => {
|
||||
it('groups a family and collects distinct provisions in order', () => {
|
||||
const groups = groupByFamily(DSE)
|
||||
const dsgvo = groups.find((g) => g.key === 'dsgvo')!
|
||||
expect(dsgvo.units).toBe(5)
|
||||
expect(dsgvo.sections).toEqual(['Art. 6', 'Art. 7', 'Art. 12', 'Art. 13', 'Art. 14'])
|
||||
expect(dsgvo.bindingness).toBe('binding')
|
||||
})
|
||||
|
||||
it('does not duplicate a repeated section', () => {
|
||||
const groups = groupByFamily([
|
||||
u({ document: 'DSGVO', section: 'Art. 13', bindingness: 'binding' }),
|
||||
u({ document: 'DSGVO', section: 'Art. 13', bindingness: 'binding' }),
|
||||
])
|
||||
expect(groups[0].sections).toEqual(['Art. 13'])
|
||||
expect(groups[0].units).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('summarizeEvidence', () => {
|
||||
it('splits binding norms from guidance with correct counts', () => {
|
||||
const m = summarizeEvidence(DSE)
|
||||
expect(m.hasBindingness).toBe(true)
|
||||
expect(m.normProvisions).toBe(6) // 5 DSGVO Artikel + § 25 TDDDG
|
||||
expect(m.guidanceCount).toBe(2) // DSK + EDPB
|
||||
expect(m.unitCount).toBe(8)
|
||||
expect(m.norms.map((g) => g.key).sort()).toEqual(['dsgvo', 'tddg'])
|
||||
})
|
||||
|
||||
it('degrades to a neutral breakdown when bindingness is absent', () => {
|
||||
const m = summarizeEvidence([
|
||||
u({ document: 'DSGVO', section: 'Art. 30' }),
|
||||
u({ document: 'CRA', section: 'Art. 14' }),
|
||||
])
|
||||
expect(m.hasBindingness).toBe(false)
|
||||
expect(m.groups).toHaveLength(2)
|
||||
expect(m.normProvisions).toBe(0)
|
||||
expect(m.guidanceCount).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('provisionSummary', () => {
|
||||
const g = (sections: string[], units = sections.length): FamilyGroup => ({
|
||||
key: 'k',
|
||||
label: 'L',
|
||||
sections,
|
||||
units,
|
||||
bindingness: 'binding',
|
||||
})
|
||||
|
||||
it('names Artikel, §§, single provisions and bare units', () => {
|
||||
expect(provisionSummary(g(['Art. 6', 'Art. 7', 'Art. 13']))).toBe('3 Artikel')
|
||||
expect(provisionSummary(g(['§ 25']))).toBe('§ 25')
|
||||
expect(provisionSummary(g(['§ 25', '§ 26']))).toBe('2 §§')
|
||||
expect(provisionSummary(g(['Art. 13', '§ 25', 'Anhang I']))).toBe('3 Fundstellen')
|
||||
expect(provisionSummary(g([], 3))).toBe('3 Fundstellen')
|
||||
expect(provisionSummary(g([], 1))).toBe('1 Fundstelle')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { resolveRegulation } from '../advisor/regulation-display'
|
||||
|
||||
describe('resolveRegulation', () => {
|
||||
it('groups DSK SDM building blocks under one family + extracts the chapter', () => {
|
||||
const b51 = resolveRegulation({ code: 'dsk_sdm_b51', short: 'DSK Sdm B51' })
|
||||
const b41 = resolveRegulation({ code: 'dsk_sdm_b41', short: 'DSK Sdm B41' })
|
||||
const v31 = resolveRegulation({ code: 'dsk_sdm_v31', short: 'DSK Sdm V31' })
|
||||
expect(b51.familyKey).toBe('dsk_sdm')
|
||||
expect(b41.familyKey).toBe('dsk_sdm')
|
||||
expect(v31.familyKey).toBe('dsk_sdm')
|
||||
expect(b51.familyLabel).toContain('Standard-Datenschutzmodell')
|
||||
expect(b51.chapter).toBe('B51')
|
||||
expect(v31.chapter).toBe('V31')
|
||||
})
|
||||
|
||||
it('maps known regulations to friendly family keys', () => {
|
||||
expect(resolveRegulation({ code: 'cra', short: 'CRA' }).familyKey).toBe('cra')
|
||||
expect(resolveRegulation({ code: 'nis2', short: 'NIS2' }).familyKey).toBe('nis2')
|
||||
expect(resolveRegulation({ code: 'dpf', short: 'DPF' }).familyKey).toBe('dpf')
|
||||
expect(resolveRegulation({ code: 'dsgvo', short: 'DS-GVO' }).familyKey).toBe('dsgvo')
|
||||
expect(resolveRegulation({ code: 'bdsg', short: 'BDSG' }).familyKey).toBe('bdsg')
|
||||
})
|
||||
|
||||
it('falls back to code as family + short as label for unknown regulations', () => {
|
||||
const r = resolveRegulation({ code: 'xyz_reg', short: 'XYZ' })
|
||||
expect(r.familyKey).toBe('xyz_reg')
|
||||
expect(r.familyLabel).toBe('XYZ')
|
||||
expect(r.chapter).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
resolveMode,
|
||||
mapClarity,
|
||||
mapFootnotes,
|
||||
buildCitations,
|
||||
numberedEvidenceForPrompt,
|
||||
isLegacyRequest,
|
||||
} from '../advisor/retrieve-mapping'
|
||||
import type { EvidenceUnit } from '../advisor/contract'
|
||||
|
||||
describe('resolveMode', () => {
|
||||
it('a chosen context always forces answer', () => expect(resolveMode('clarify', true)).toBe('answer'))
|
||||
it('clarify + no context -> clarify', () => expect(resolveMode('clarify', false)).toBe('clarify'))
|
||||
it('answer -> answer', () => expect(resolveMode('answer', false)).toBe('answer'))
|
||||
it('unknown/undefined -> answer', () => expect(resolveMode(undefined, false)).toBe('answer'))
|
||||
})
|
||||
|
||||
describe('mapClarity', () => {
|
||||
it('clarify maps candidate_contexts -> suggested_contexts', () => {
|
||||
const c = mapClarity(
|
||||
{ mode: 'clarify', concentration: 0.3, candidate_contexts: [{ id: 'ds', label: 'Datenschutz', hits: 5 }] },
|
||||
'clarify',
|
||||
)
|
||||
expect(c.is_underspecified).toBe(true)
|
||||
expect(c.suggested_contexts).toEqual([{ id: 'ds', label: 'Datenschutz' }])
|
||||
})
|
||||
it('answer keeps dominant_context, drops suggestions', () => {
|
||||
const c = mapClarity({ mode: 'answer', concentration: 0.88, dominant_context: 'ds' }, 'answer')
|
||||
expect(c.is_underspecified).toBe(false)
|
||||
expect(c.dominant_context).toBe('ds')
|
||||
expect(c.suggested_contexts).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
const ev: EvidenceUnit[] = [
|
||||
{ evidence_id: 'e1', document: 'DSGVO', section: 'Art. 30', paragraph: 'Abs. 1', snippet: 'x' },
|
||||
{ evidence_id: 'e2', document: 'BDSG', section: '§ 38' },
|
||||
]
|
||||
|
||||
describe('buildCitations', () => {
|
||||
it('numbers citations 1..n mapped to evidence', () => {
|
||||
const cs = buildCitations(ev)
|
||||
expect(cs).toHaveLength(2)
|
||||
expect(cs[0]).toMatchObject({ citation_id: 'c1', number: 1, evidence_id: 'e1' })
|
||||
expect(cs[1].number).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('numberedEvidenceForPrompt', () => {
|
||||
it('prefixes each unit with [n] + its location', () => {
|
||||
const s = numberedEvidenceForPrompt(ev)
|
||||
expect(s).toContain('[1] DSGVO Art. 30 Abs. 1')
|
||||
expect(s).toContain('[2] BDSG § 38')
|
||||
})
|
||||
})
|
||||
|
||||
describe('mapFootnotes', () => {
|
||||
it('remaps a /retrieve footnote to the contract footnote', () => {
|
||||
const fns = mapFootnotes([
|
||||
{ id: 'f1', number: 17, regulation_short: 'EDPB WP248', section: 'Kap III', text: 't' },
|
||||
])
|
||||
expect(fns[0]).toMatchObject({
|
||||
footnote_id: 'f1',
|
||||
ref: 'Fußnote 17',
|
||||
document: 'EDPB WP248',
|
||||
section: 'Kap III',
|
||||
text: 't',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('isLegacyRequest', () => {
|
||||
it('message-only (workspace) -> legacy stream', () => {
|
||||
expect(isLegacyRequest({ message: 'Ist meine DSE ausreichend?' })).toBe(true)
|
||||
})
|
||||
it('question present -> contract (JSON)', () => {
|
||||
expect(isLegacyRequest({ question: 'x', message: 'y' })).toBe(false)
|
||||
expect(isLegacyRequest({ question: 'x' })).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,83 @@
|
||||
// FE-facing contract for the Compliance Advisor "Case" (Clarity Gate).
|
||||
// Matches the SDK<->FE contract (board 2026-07-01 / memory advisor-clarity-gate-contract).
|
||||
// The FE renders ONLY these structured fields; it never extracts structure from the answer text.
|
||||
// The only exception is rendering the deliberate [n] citation markers, mapped via `citations`.
|
||||
|
||||
export interface SuggestedContext {
|
||||
id: string // e.g. "datenschutz"
|
||||
label: string // e.g. "Datenschutz"
|
||||
}
|
||||
|
||||
export interface ClarityInfo {
|
||||
is_underspecified: boolean
|
||||
concentration: number
|
||||
suggested_contexts?: SuggestedContext[] // clarify mode
|
||||
dominant_context?: string // answer mode
|
||||
}
|
||||
|
||||
/** A retrieved evidence unit. (`evidence[]` item shape — confirm with SDK; see board rückfrage.) */
|
||||
export interface EvidenceUnit {
|
||||
evidence_id: string
|
||||
document: string
|
||||
section?: string
|
||||
paragraph?: string
|
||||
snippet?: string
|
||||
url?: string
|
||||
regulation_code?: string // preferred key for family grouping (from /retrieve)
|
||||
context?: string // knowledge space / domain
|
||||
// Canonical Legal-KG fact (APEX rule): binding norm vs. soft-law guidance. Owned by the
|
||||
// Legal-KG/RAG, not derived in the FE. Absent until /retrieve populates it (board request 2026-07-01);
|
||||
// the FE degrades to a neutral per-regulation breakdown when it is missing.
|
||||
bindingness?: 'binding' | 'guidance'
|
||||
}
|
||||
|
||||
/** Numbered [n] <-> evidence coupling, produced by the SDK (not parsed from the answer). */
|
||||
export interface Citation {
|
||||
citation_id: string
|
||||
number?: number // 1-based marker number ([n])
|
||||
evidence_id: string
|
||||
document: string
|
||||
section?: string | null
|
||||
paragraph?: string | null
|
||||
footnote?: string | null
|
||||
figure?: string | null
|
||||
}
|
||||
|
||||
/** C8 / visual evidence — `visual_type` generalizes beyond figures (flowchart/bpmn/state_machine/...). */
|
||||
export interface VisualEvidence {
|
||||
visual_id: string
|
||||
visual_type: string
|
||||
caption?: string
|
||||
document: string
|
||||
context?: string
|
||||
image_ref?: string
|
||||
vision_summary?: string
|
||||
}
|
||||
|
||||
export interface Footnote {
|
||||
footnote_id?: string
|
||||
ref?: string
|
||||
document?: string
|
||||
section?: string
|
||||
text?: string
|
||||
}
|
||||
|
||||
export type AdvisorMode = 'clarify' | 'answer'
|
||||
|
||||
export interface AdvisorResponse {
|
||||
mode: AdvisorMode
|
||||
question: string
|
||||
clarity: ClarityInfo
|
||||
general_answer?: string | null // L1 (clarify mode)
|
||||
answer?: string | null // L2 (answer mode)
|
||||
scoped_query?: string | null
|
||||
evidence: EvidenceUnit[]
|
||||
citations: Citation[]
|
||||
visual_evidence: VisualEvidence[]
|
||||
footnotes: Footnote[]
|
||||
}
|
||||
|
||||
export interface AdvisorRequest {
|
||||
question: string
|
||||
context?: string | null
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// Pure grouping/counting for the "Diese Antwort stützt sich auf" evidence header. No React, testable.
|
||||
// Splits evidence into binding norms (Kernnormen) vs. soft-law guidance (Leitlinien) using the
|
||||
// Legal-KG-owned `bindingness` fact (APEX rule) — the FE never derives bindingness itself. When the
|
||||
// fact is absent it degrades to a neutral per-regulation breakdown (no norm/guidance labels, no
|
||||
// fabricated legal classification).
|
||||
|
||||
import type { EvidenceUnit } from './contract'
|
||||
import { resolveRegulation } from './regulation-display'
|
||||
|
||||
export type Bindingness = 'binding' | 'guidance' | 'unknown'
|
||||
|
||||
export interface FamilyGroup {
|
||||
key: string // stable family key (grouping)
|
||||
label: string // human-readable regulation name
|
||||
sections: string[] // distinct provisions in first-seen order (e.g. "Art. 13", "§ 25")
|
||||
units: number // raw evidence units in this family
|
||||
bindingness: Bindingness
|
||||
}
|
||||
|
||||
export interface EvidenceSummaryModel {
|
||||
groups: FamilyGroup[]
|
||||
norms: FamilyGroup[] // bindingness === 'binding'
|
||||
guidance: FamilyGroup[] // bindingness === 'guidance'
|
||||
other: FamilyGroup[] // bindingness unknown
|
||||
hasBindingness: boolean // at least one unit carries the Legal-KG fact
|
||||
normProvisions: number // distinct binding provisions (Kernnormen)
|
||||
guidanceCount: number // distinct guidance documents (Leitlinien)
|
||||
unitCount: number // total evidence units
|
||||
}
|
||||
|
||||
export function groupByFamily(evidence: EvidenceUnit[]): FamilyGroup[] {
|
||||
const byKey = new Map<string, FamilyGroup>()
|
||||
for (const e of evidence) {
|
||||
const { familyKey, familyLabel } = resolveRegulation({
|
||||
code: e.regulation_code || e.document,
|
||||
short: e.document,
|
||||
})
|
||||
let g = byKey.get(familyKey)
|
||||
if (!g) {
|
||||
g = { key: familyKey, label: familyLabel, sections: [], units: 0, bindingness: 'unknown' }
|
||||
byKey.set(familyKey, g)
|
||||
}
|
||||
g.units += 1
|
||||
if (e.section && !g.sections.includes(e.section)) g.sections.push(e.section)
|
||||
if (e.bindingness && g.bindingness === 'unknown') g.bindingness = e.bindingness
|
||||
}
|
||||
return [...byKey.values()]
|
||||
}
|
||||
|
||||
/** distinct provisions for a family; falls back to raw unit count when no section is known. */
|
||||
export function provisionCount(g: FamilyGroup): number {
|
||||
return g.sections.length || g.units
|
||||
}
|
||||
|
||||
/** "5 Artikel" / "§ 25" / "3 Fundstellen" — the noun follows the family's own citation style. */
|
||||
export function provisionSummary(g: FamilyGroup): string {
|
||||
const n = g.sections.length
|
||||
if (n === 0) return `${g.units} ${g.units === 1 ? 'Fundstelle' : 'Fundstellen'}`
|
||||
if (n === 1) return g.sections[0]
|
||||
if (g.sections.every((s) => /^\s*art/i.test(s))) return `${n} Artikel`
|
||||
if (g.sections.every((s) => s.trim().startsWith('§'))) return `${n} §§`
|
||||
return `${n} Fundstellen`
|
||||
}
|
||||
|
||||
export function summarizeEvidence(evidence: EvidenceUnit[]): EvidenceSummaryModel {
|
||||
const groups = groupByFamily(evidence)
|
||||
const norms = groups.filter((g) => g.bindingness === 'binding')
|
||||
const guidance = groups.filter((g) => g.bindingness === 'guidance')
|
||||
const other = groups.filter((g) => g.bindingness === 'unknown')
|
||||
return {
|
||||
groups,
|
||||
norms,
|
||||
guidance,
|
||||
other,
|
||||
hasBindingness: norms.length > 0 || guidance.length > 0,
|
||||
normProvisions: norms.reduce((n, g) => n + provisionCount(g), 0),
|
||||
guidanceCount: guidance.length,
|
||||
unitCount: evidence.length,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// Human-readable display for regulations. Maps messy codes/short-names to a stable FAMILY key +
|
||||
// friendly label (+ chapter for multi-part works like the DSK SDM). Presentation layer only:
|
||||
// it bridges G2 (clean RAG metadata) and keeps working once codes are clean. Extend the table freely.
|
||||
|
||||
export interface RegulationRef {
|
||||
code?: string
|
||||
name?: string
|
||||
short?: string
|
||||
}
|
||||
|
||||
export interface RegulationDisplay {
|
||||
familyKey: string // stable key used to GROUP evidence
|
||||
familyLabel: string // human-readable regulation name
|
||||
chapter?: string // e.g. "B51" for a DSK SDM building block
|
||||
}
|
||||
|
||||
interface Rule {
|
||||
test: RegExp
|
||||
key: string
|
||||
label: string
|
||||
chapter?: RegExp
|
||||
}
|
||||
|
||||
// Order matters: more specific patterns first.
|
||||
const RULES: Rule[] = [
|
||||
{
|
||||
test: /dsk.?sdm|standard.?datenschutzmodell|(^|[^a-z])sdm([^a-z]|$)/i,
|
||||
key: 'dsk_sdm',
|
||||
label: 'DSK Standard-Datenschutzmodell (SDM)',
|
||||
chapter: /\b([A-Z]\d{1,3})\b/,
|
||||
},
|
||||
{ test: /cyber.?resilience|(^|[^a-z])cra([^a-z]|$)/i, key: 'cra', label: 'Cyber Resilience Act (CRA)' },
|
||||
{ test: /(^|[^a-z])nis.?2([^a-z]|$)/i, key: 'nis2', label: 'NIS2-Richtlinie' },
|
||||
{ test: /data.?privacy.?framework|(^|[^a-z])dpf([^a-z]|$)/i, key: 'dpf', label: 'EU-US Data Privacy Framework' },
|
||||
{ test: /maschinen|2023.?1230/i, key: 'maschinenvo', label: 'Maschinenverordnung (EU) 2023/1230' },
|
||||
{ test: /ds.?gvo|gdpr/i, key: 'dsgvo', label: 'DSGVO – Datenschutz-Grundverordnung' },
|
||||
{ test: /(^|[^a-z])bdsg([^a-z]|$)/i, key: 'bdsg', label: 'BDSG – Bundesdatenschutzgesetz' },
|
||||
{ test: /tdddg|ttdsg/i, key: 'tddg', label: 'TDDDG (Digitale-Dienste-Datenschutz)' },
|
||||
{ test: /edpb|edsa|(^|[^a-z])wp\s?\d+/i, key: 'edpb', label: 'EDPB / DSK Leitlinien' },
|
||||
{ test: /(^|[^a-z])bsi([^a-z]|$)/i, key: 'bsi', label: 'BSI' },
|
||||
]
|
||||
|
||||
export function resolveRegulation(reg: RegulationRef): RegulationDisplay {
|
||||
const hay = `${reg.code || ''} ${reg.short || ''} ${reg.name || ''}`
|
||||
for (const r of RULES) {
|
||||
if (r.test.test(hay)) {
|
||||
const chapter = r.chapter
|
||||
? r.chapter.exec(reg.short || reg.code || '')?.[1] || undefined
|
||||
: undefined
|
||||
return { familyKey: r.key, familyLabel: r.label, chapter }
|
||||
}
|
||||
}
|
||||
return {
|
||||
familyKey: reg.code || reg.short || 'unknown',
|
||||
familyLabel: reg.short || reg.name || reg.code || 'Regelwerk',
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// Pure mappings from the Go /retrieve response (SDK/RAG-owned; board 2026-07-01 12:25)
|
||||
// to the FE-facing advisor contract. Kept pure + testable; the orchestration (route.ts) wires them.
|
||||
|
||||
import type { Citation, ClarityInfo, EvidenceUnit, Footnote, VisualEvidence } from './contract'
|
||||
|
||||
export interface RetrieveClarity {
|
||||
mode?: string // 'clarify' | 'answer'
|
||||
reason?: string // e.g. 'middle_band_llm_needed'
|
||||
concentration?: number
|
||||
domain_count?: number
|
||||
dominant_context?: string
|
||||
candidate_contexts?: { id: string; label: string; hits?: number }[]
|
||||
}
|
||||
|
||||
export interface RetrieveFootnote {
|
||||
id?: string
|
||||
ref?: string
|
||||
number?: number
|
||||
regulation_code?: string
|
||||
regulation_short?: string
|
||||
regulation_name?: string
|
||||
section?: string
|
||||
text?: string
|
||||
}
|
||||
|
||||
export interface RetrieveResponse {
|
||||
evidence?: EvidenceUnit[]
|
||||
visual_evidence?: VisualEvidence[]
|
||||
footnotes?: RetrieveFootnote[]
|
||||
clarity?: RetrieveClarity
|
||||
results?: unknown[]
|
||||
tables?: unknown[] // C6 — not in the FE contract yet (future TablesPane)
|
||||
}
|
||||
|
||||
/** clarify unless a context was chosen; /retrieve's clarity.mode decides for un-scoped queries. */
|
||||
export function resolveMode(clarityMode: string | undefined, hasContext: boolean): 'clarify' | 'answer' {
|
||||
if (hasContext) return 'answer'
|
||||
return clarityMode === 'clarify' ? 'clarify' : 'answer'
|
||||
}
|
||||
|
||||
export function mapClarity(c: RetrieveClarity | undefined, mode: 'clarify' | 'answer'): ClarityInfo {
|
||||
return {
|
||||
is_underspecified: mode === 'clarify',
|
||||
concentration: c?.concentration ?? 0,
|
||||
dominant_context: c?.dominant_context,
|
||||
suggested_contexts:
|
||||
mode === 'clarify' ? (c?.candidate_contexts ?? []).map((cc) => ({ id: cc.id, label: cc.label })) : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export function mapFootnotes(fns: RetrieveFootnote[] | undefined): Footnote[] {
|
||||
return (fns ?? []).map((f) => ({
|
||||
footnote_id: f.id,
|
||||
ref: f.ref ?? (f.number != null ? `Fußnote ${f.number}` : undefined),
|
||||
document: f.regulation_short || f.regulation_name || f.regulation_code,
|
||||
section: f.section,
|
||||
text: f.text,
|
||||
}))
|
||||
}
|
||||
|
||||
/** Citations are generated by the orchestration (not by /retrieve): [n] -> nth evidence unit. */
|
||||
export function buildCitations(evidence: EvidenceUnit[]): Citation[] {
|
||||
return evidence.map((e, i) => ({
|
||||
citation_id: `c${i + 1}`,
|
||||
number: i + 1,
|
||||
evidence_id: e.evidence_id,
|
||||
document: e.document,
|
||||
section: e.section ?? null,
|
||||
paragraph: e.paragraph ?? null,
|
||||
footnote: null,
|
||||
figure: null,
|
||||
}))
|
||||
}
|
||||
|
||||
/** Numbered evidence list injected into the L2 prompt so the LLM can cite [n]. */
|
||||
export function numberedEvidenceForPrompt(evidence: EvidenceUnit[]): string {
|
||||
return evidence
|
||||
.map((e, i) => {
|
||||
const loc = [e.document, e.section, e.paragraph].filter(Boolean).join(' ')
|
||||
return `[${i + 1}] ${loc}\n${e.snippet ?? ''}`.trim()
|
||||
})
|
||||
.join('\n\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Backward-compat discriminator: legacy consumers (e.g. breakpilot-workspace) send `{message}`
|
||||
* and read a plain-text stream; the new FE sends `{question}` and expects the JSON contract.
|
||||
*/
|
||||
export function isLegacyRequest(body: { question?: unknown; message?: unknown }): boolean {
|
||||
return body.question == null && typeof body.message === 'string'
|
||||
}
|
||||
@@ -138,3 +138,26 @@ export async function streamAdvisorAnswer(
|
||||
if (ollama) return textStream(ollama, parseOllamaLine)
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Nicht-streamende Variante: sammelt die vollstaendige LLM-Antwort als String (fuer die
|
||||
* JSON-Contract-Antwort der Advisor-Orchestrierung). null = kein LLM erreichbar.
|
||||
*/
|
||||
export async function completeAdvisorAnswer(messages: ChatMessage[]): Promise<string | null> {
|
||||
const stream = await streamAdvisorAnswer(messages)
|
||||
if (!stream) return null
|
||||
const reader = stream.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let out = ''
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
if (value) out += decoder.decode(value, { stream: true })
|
||||
}
|
||||
out += decoder.decode()
|
||||
} finally {
|
||||
reader.releaseLock()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -7,10 +7,13 @@
|
||||
* Advisor bleibt damit collection-agnostisch (Vertrag: Compiler -> Collections -> Retriever
|
||||
* -> Advisor); die fruehere Multi-Collection-Logik liegt jetzt im Retriever.
|
||||
*
|
||||
* Fehler werden geschluckt (graceful: Antwort ohne RAG-Kontext).
|
||||
* Fundstellen via article_label sind live ab dem Prod-Re-Ingest 2026-06.
|
||||
* `retrieveAdvisorEvidence` liefert die STRUKTURIERTEN Treffer (fuer das Evidence-Workspace-
|
||||
* Frontend, das nur strukturierte Daten rendert und nie den Antworttext parst) UND den
|
||||
* vorformatierten Kontext-Block fuer den LLM-Prompt. Fehler werden geschluckt (graceful).
|
||||
*/
|
||||
|
||||
import type { RetrieveResponse } from '@/lib/sdk/advisor/retrieve-mapping'
|
||||
|
||||
const SDK_URL =
|
||||
process.env.SDK_API_URL || process.env.SDK_URL || 'http://ai-compliance-sdk:8090'
|
||||
|
||||
@@ -18,7 +21,7 @@ const DEFAULT_USER = '00000000-0000-0000-0000-000000000001'
|
||||
const DEFAULT_TENANT =
|
||||
process.env.DEFAULT_TENANT_ID || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||
|
||||
interface SdkRagResult {
|
||||
export interface SdkRagResult {
|
||||
text?: string
|
||||
regulation_code?: string
|
||||
regulation_name?: string
|
||||
@@ -34,20 +37,27 @@ interface SdkRagResult {
|
||||
score?: number
|
||||
}
|
||||
|
||||
/** Raw RAG response. `figures`/`footnotes` (C8 / C-FN) are passed through untyped until the
|
||||
* RAG-ingestion contract is finalized (board), then mapped in the evidence-adapter. */
|
||||
interface SdkRagResponse {
|
||||
results?: SdkRagResult[]
|
||||
figures?: unknown[]
|
||||
footnotes?: unknown[]
|
||||
}
|
||||
|
||||
interface ScoredPassage {
|
||||
content: string
|
||||
source: string
|
||||
score: number
|
||||
}
|
||||
|
||||
/** Normalisiert eine ai-sdk-RAG-Antwort auf {content, source, score}. */
|
||||
/** Normalisiert eine ai-sdk-RAG-Antwort auf {content, source, score} (fuer den Prompt-Kontext). */
|
||||
export function mapSdkResults(results: SdkRagResult[] | undefined): ScoredPassage[] {
|
||||
return (results || [])
|
||||
.map((r) => ({
|
||||
content: r.text || '',
|
||||
// Fundstelle: article_label ist die fertig formatierte, druckbare Quelle aus der
|
||||
// Ingestion ("BDSG § 38 Abs. 1"); Fallback baut sie aus den strukturierten Feldern
|
||||
// (bzw. alt-ingestierte Chunks ohne Legal-Metadaten). Siehe rag_reingest_spec.md §2/§7.
|
||||
// Ingestion ("BDSG § 38 Abs. 1"); Fallback baut sie aus den strukturierten Feldern.
|
||||
source:
|
||||
(r.article_label && r.article_label.trim()) ||
|
||||
[r.regulation_short || r.regulation_name || r.regulation_code, r.article, r.paragraph, r.sub]
|
||||
@@ -59,15 +69,16 @@ export function mapSdkResults(results: SdkRagResult[] | undefined): ScoredPassag
|
||||
.filter((p) => p.content)
|
||||
}
|
||||
|
||||
/**
|
||||
* Authority Router: EIN collection-agnostischer Aufruf an die ai-sdk (`/sdk/v1/rag/retrieve`).
|
||||
* Der Router waehlt die Collections (Broad-Authority-Base + KB-2026.1-Slice bei in-scope),
|
||||
* merged + authority-ranked sie und liefert die Top-Passagen. Der Advisor weiss damit nichts
|
||||
* mehr ueber einzelne Collections — die fruehere Multi-Collection-Logik liegt jetzt im Retriever.
|
||||
* Fehler werden geschluckt (graceful: Antwort ohne RAG-Kontext).
|
||||
*/
|
||||
export async function queryAdvisorRAG(query: string): Promise<string> {
|
||||
let passages: ScoredPassage[] = []
|
||||
/** Formatiert die Top-Passagen als Kontext-Block fuer den System-Prompt. */
|
||||
function formatContext(passages: ScoredPassage[]): string {
|
||||
if (passages.length === 0) return ''
|
||||
return passages
|
||||
.map((r, i) => `[Quelle ${i + 1}: ${r.source}]\n${r.content}`)
|
||||
.join('\n\n---\n\n')
|
||||
}
|
||||
|
||||
/** EIN collection-agnostischer Aufruf an die ai-sdk. Fehler -> leeres Ergebnis (graceful). */
|
||||
async function fetchRag(query: string): Promise<SdkRagResponse> {
|
||||
try {
|
||||
const res = await fetch(`${SDK_URL}/sdk/v1/rag/retrieve`, {
|
||||
method: 'POST',
|
||||
@@ -79,16 +90,61 @@ export async function queryAdvisorRAG(query: string): Promise<string> {
|
||||
body: JSON.stringify({ query, top_k: 8 }),
|
||||
signal: AbortSignal.timeout(15000),
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
passages = mapSdkResults(data.results)
|
||||
}
|
||||
if (res.ok) return ((await res.json()) as SdkRagResponse) || {}
|
||||
} catch {
|
||||
// graceful: keine Verbindung -> Antwort ohne RAG-Kontext
|
||||
}
|
||||
// Der Router liefert bereits authority-geordnete Top-K; Reihenfolge bewahren.
|
||||
if (passages.length === 0) return ''
|
||||
return passages
|
||||
.map((r, i) => `[Quelle ${i + 1}: ${r.source}]\n${r.content}`)
|
||||
.join('\n\n---\n\n')
|
||||
return {}
|
||||
}
|
||||
|
||||
export interface AdvisorEvidenceRaw {
|
||||
contextText: string
|
||||
results: SdkRagResult[]
|
||||
figures?: unknown[]
|
||||
footnotes?: unknown[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Strukturierte Evidence + Prompt-Kontext aus EINEM Retrieval. Das Frontend bekommt die
|
||||
* `results` (und kuenftig `figures`/`footnotes`) als Daten; der `contextText` geht in den
|
||||
* LLM-Prompt. Reihenfolge der authority-geordneten Top-K bleibt erhalten.
|
||||
*/
|
||||
export async function retrieveAdvisorEvidence(query: string): Promise<AdvisorEvidenceRaw> {
|
||||
const data = await fetchRag(query)
|
||||
const results = data.results || []
|
||||
return {
|
||||
contextText: formatContext(mapSdkResults(results)),
|
||||
results,
|
||||
figures: Array.isArray(data.figures) ? data.figures : undefined,
|
||||
footnotes: Array.isArray(data.footnotes) ? data.footnotes : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
/** Abwaertskompatibel: nur der Prompt-Kontext als String. */
|
||||
export async function queryAdvisorRAG(query: string): Promise<string> {
|
||||
return (await retrieveAdvisorEvidence(query)).contextText
|
||||
}
|
||||
|
||||
/**
|
||||
* Voller `/retrieve`-Aufruf fuer die Clarity-Gate-Orchestrierung: liefert die strukturierte
|
||||
* SDK/RAG-Response (evidence/visual_evidence/footnotes/tables/clarity/results). `context` scopet
|
||||
* den 2. Aufruf auf die gewaehlte Domaene. Fehler -> leeres Ergebnis (graceful).
|
||||
*/
|
||||
export async function retrieveFull(query: string, context?: string | null): Promise<RetrieveResponse> {
|
||||
try {
|
||||
const res = await fetch(`${SDK_URL}/sdk/v1/rag/retrieve`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-User-ID': DEFAULT_USER,
|
||||
'X-Tenant-ID': DEFAULT_TENANT,
|
||||
},
|
||||
body: JSON.stringify({ query, top_k: 8, ...(context ? { context } : {}) }),
|
||||
signal: AbortSignal.timeout(15000),
|
||||
})
|
||||
if (res.ok) return ((await res.json()) as RetrieveResponse) || {}
|
||||
} catch {
|
||||
// graceful: keine Verbindung -> leeres Ergebnis
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user