feat(advisor): Evidence Workspace — structured panes, markdown, sources as knowledge units
Rebuilds the Compliance Advisor floating widget from a plain chat into an Evidence
Workspace: pinned last question, markdown-rendered answer (clean prose), and separate
panes for Sources (hierarchical Knowledge Units), Figures (C8, conditional) and
Footnotes (C-FN), plus a stats bar (Quellen/Regelwerke/Diagramme/Fußnoten). Scrollable
turn history; stays a floating icon on every SDK page.
Architecture (user direction): the frontend renders ONLY structured evidence and NEVER
parses the answer text. The proxy now returns a JSON AdvisorEvidenceMeta line followed
by the streamed markdown answer; advisor-rag exposes structured results; an adapter maps
RAG/compiler output to the frontend envelope. Figures/footnotes wire in once the
RAG-ingestion contract lands (requested on the board) — figures pane is conditional.
- lib/sdk/advisor/{evidence,evidence-adapter}.ts (+ adapter test, 7 cases)
- components/sdk/advisor/* panes + in-house safe Markdown (no new dep, no dangerouslySetInnerHTML) + test
- useAdvisorStream (meta-line parse + streamed answer) + useAdvisorEmail (escaped)
- proxy: evidence-meta-v1 envelope + clean-prose prompt (no inline citations)
- tsc clean, 11 vitest pass, check-loc 0. ESLint not installed in this node_modules -> CI lints on push.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,18 +1,21 @@
|
||||
/**
|
||||
* Compliance Advisor Chat API
|
||||
* Compliance Advisor Chat API — Evidence Workspace envelope.
|
||||
*
|
||||
* Verbindet das ComplianceAdvisorWidget mit:
|
||||
* 1. Multi-Collection-RAG ueber die ai-compliance-sdk (bge-m3) — siehe advisor-rag
|
||||
* 1. Strukturierter RAG-Evidence ueber die ai-compliance-sdk — siehe advisor-rag
|
||||
* 2. Strukturierten Controls zum erkannten Thema — buildControlsContext
|
||||
* 3. LLM-Kaskade OVH (prod) -> Ollama (Dev) — siehe advisor-llm
|
||||
*
|
||||
* Laenderspezifische Filterung (DE, AT, CH, EU). Streamt die Antwort als Text.
|
||||
* Antwort-Format (evidence-meta-v1): ERSTE Zeile = JSON `AdvisorEvidenceMeta`
|
||||
* (Quellen/Abbildungen/Fussnoten/Stats), danach streamt die Antwort als Markdown-Text.
|
||||
* Das Frontend rendert NUR diese strukturierten Daten und parst NIE den Antworttext.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { readSoulFile } from '@/lib/sdk/agents/soul-reader'
|
||||
import { buildControlsContext } from '@/lib/sdk/agents/controls-augmentation'
|
||||
import { queryAdvisorRAG } from '@/lib/sdk/agents/advisor-rag'
|
||||
import { retrieveAdvisorEvidence } from '@/lib/sdk/agents/advisor-rag'
|
||||
import { adaptEvidence, type RawFigure, type RawFootnote } from '@/lib/sdk/advisor/evidence-adapter'
|
||||
import { streamAdvisorAnswer, type ChatMessage } from '@/lib/sdk/agents/advisor-llm'
|
||||
|
||||
type Country = 'DE' | 'AT' | 'CH' | 'EU'
|
||||
@@ -24,11 +27,19 @@ Du bist der BreakPilot Compliance-Berater. Du hilfst Nutzern des AI Compliance S
|
||||
Datenschutz- und Compliance-Fragen in verstaendlicher Sprache zu beantworten.
|
||||
|
||||
## Kernprinzipien
|
||||
- Quellenbasiert: Verweise auf DSGVO-Artikel, BDSG-Paragraphen
|
||||
- Quellenbasiert: Stuetze dich auf die bereitgestellten Rechtsquellen
|
||||
- Verstaendlich: Einfache, praxisnahe Sprache
|
||||
- Ehrlich: Bei Unsicherheit empfehle Rechtsberatung
|
||||
- Deutsch als Hauptsprache`
|
||||
|
||||
// Antwort = saubere Prosa OHNE Inline-Fundstellen; die Quellen zeigt das Frontend separat an.
|
||||
const FORMAT_GUIDANCE = `\n\n## Antwortformat (WICHTIG)
|
||||
- Schreibe gut strukturiertes **Markdown**: kurze Abschnittsueberschriften (##), Aufzaehlungen (-),
|
||||
nummerierte Schritte und **Fettung** fuer Schluesselbegriffe. Halte Absaetze kurz.
|
||||
- Nenne Fundstellen/Quellen NICHT im Fliesstext (kein "(Art. 30 DSGVO)", keine "[Quelle 1]").
|
||||
Die Quellen werden dem Nutzer in einem EIGENEN Bereich neben der Antwort angezeigt.
|
||||
- Schreibe so, dass die Antwort auch ohne eingebettete Zitate vollstaendig verstaendlich ist.`
|
||||
|
||||
const COUNTRY_LABELS: Record<Country, string> = {
|
||||
DE: 'Deutschland',
|
||||
AT: 'Oesterreich',
|
||||
@@ -56,6 +67,29 @@ Der Nutzer hat "${label} (${c})" gewaehlt.
|
||||
- Bei ${guidance}`
|
||||
}
|
||||
|
||||
/** Stellt der gestreamten Antwort eine JSON-Meta-Zeile voran (evidence-meta-v1). */
|
||||
function withEvidenceMeta(meta: unknown, answer: ReadableStream<Uint8Array>): ReadableStream<Uint8Array> {
|
||||
const encoder = new TextEncoder()
|
||||
const metaLine = JSON.stringify(meta) + '\n'
|
||||
return new ReadableStream<Uint8Array>({
|
||||
async start(controller) {
|
||||
controller.enqueue(encoder.encode(metaLine))
|
||||
const reader = answer.getReader()
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
if (value) controller.enqueue(value)
|
||||
}
|
||||
} catch (e) {
|
||||
controller.error(e)
|
||||
return
|
||||
}
|
||||
controller.close()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
@@ -69,23 +103,31 @@ export async function POST(request: NextRequest) {
|
||||
? (country as Country)
|
||||
: undefined
|
||||
|
||||
// 1. RAG (ai-sdk, bge-m3) + strukturierte Controls zum Thema — beide parallel
|
||||
const [ragContext, controlsContext] = await Promise.all([
|
||||
queryAdvisorRAG(message),
|
||||
// 1. Strukturierte RAG-Evidence + Controls zum Thema — parallel
|
||||
const [evidence, controlsContext] = await Promise.all([
|
||||
retrieveAdvisorEvidence(message),
|
||||
buildControlsContext(message),
|
||||
])
|
||||
|
||||
// 2. System-Prompt zusammenbauen
|
||||
// 2. Evidence-Meta fuer das Frontend (strukturiert, nicht geparst)
|
||||
const meta = adaptEvidence({
|
||||
results: evidence.results,
|
||||
figures: evidence.figures as RawFigure[] | undefined,
|
||||
footnotes: evidence.footnotes as RawFootnote[] | undefined,
|
||||
})
|
||||
|
||||
// 3. System-Prompt
|
||||
const soulPrompt = await readSoulFile('compliance-advisor')
|
||||
let systemContent = soulPrompt || FALLBACK_SYSTEM_PROMPT
|
||||
if (validCountry) systemContent += countryBlock(validCountry)
|
||||
if (ragContext) {
|
||||
systemContent += `\n\n## Relevanter Kontext aus dem RAG-System (deine EINZIGEN Rechtsquellen)\n\nDies sind deine einzigen zulaessigen Rechtsquellen. Triff keine konkrete Rechtsaussage (Zahl, Frist, Schwelle, Pflicht, Fundstelle), die nicht hier oder im Controls-Block belegt ist — sonst sage offen, dass du sie aus deinen Quellen nicht belegen kannst. Verweise in deiner Antwort auf die jeweilige Quelle:\n\n${ragContext}`
|
||||
if (evidence.contextText) {
|
||||
systemContent += `\n\n## Relevanter Kontext aus dem RAG-System (deine EINZIGEN Rechtsquellen)\n\nDies sind deine einzigen zulaessigen Rechtsquellen. Triff keine konkrete Rechtsaussage (Zahl, Frist, Schwelle, Pflicht, Fundstelle), die nicht hier oder im Controls-Block belegt ist — sonst sage offen, dass du sie aus deinen Quellen nicht belegen kannst.\n\n${evidence.contextText}`
|
||||
}
|
||||
if (controlsContext) systemContent += `\n\n${controlsContext}`
|
||||
systemContent += FORMAT_GUIDANCE
|
||||
systemContent += `\n\n## Aktueller SDK-Schritt\nDer Nutzer befindet sich im SDK-Schritt: ${currentStep}`
|
||||
|
||||
// 3. Nachrichten (History auf die letzten 6 begrenzen)
|
||||
// 4. Nachrichten (History auf die letzten 6 begrenzen)
|
||||
const messages: ChatMessage[] = [
|
||||
{ role: 'system', content: systemContent },
|
||||
...history.slice(-6).map((h: { role: string; content: string }) => ({
|
||||
@@ -95,7 +137,7 @@ export async function POST(request: NextRequest) {
|
||||
{ role: 'user', content: message },
|
||||
]
|
||||
|
||||
// 4. LLM-Kaskade -> Plain-Text-Stream
|
||||
// 5. LLM-Kaskade -> Meta-Zeile + Text-Stream
|
||||
const stream = await streamAdvisorAnswer(messages)
|
||||
if (!stream) {
|
||||
return NextResponse.json(
|
||||
@@ -104,11 +146,12 @@ export async function POST(request: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
return new NextResponse(stream, {
|
||||
return new NextResponse(withEvidenceMeta(meta, stream), {
|
||||
headers: {
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
'X-Advisor-Format': 'evidence-meta-v1',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
|
||||
Reference in New Issue
Block a user