Files
breakpilot-compliance/admin-compliance/app/api/sdk/compliance-advisor/chat/route.ts
T
Benjamin Admin e8ea179228 feat(advisor): topic threads, per-question delete/copy, fullscreen
Adds case management to the Compliance Advisor widget.

- topic threads: cases group into threads; the left menu shows each
  thread's first question as the Thema with expandable follow-ups.
  Send = follow-up to the active thread (carries the thread's prior Q&A
  as history for contextual answers); "+" starts a new topic.
- delete: a trash action per question (menu + stacked view).
- copy: single Q&A (question + answer + evidence + footnotes) or a whole
  thread, as Markdown to the clipboard (pure formatters in copy.ts).
- fullscreen: compact -> panel -> fullscreen view.
- route.ts consumes an optional bounded `history` so follow-ups are
  contextual for both the widget and the workspace consumer.

Tests: copy formatter unit tests + Playwright specs (threads/new-topic,
delete, fullscreen, copy affordance). No deploy.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-01 18:51:17 +02:00

186 lines
7.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Compliance Advisor Chat API — Clarity-Gate orchestration.
*
* 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 { 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 COUNTRY_LABELS: Record<Country, string> = {
DE: 'Deutschland',
AT: 'Oesterreich',
CH: 'Schweiz',
EU: 'EU-weit',
}
function countryBlock(c: Country): string {
const label = COUNTRY_LABELS[c]
return `\n\n## Laenderspezifische Auskunft
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** (12 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
}
// Prior thread turns for contextual follow-ups. Validated + bounded (last 8 turns ~ 4 Q&A).
function parseHistory(raw: unknown): ChatMessage[] {
if (!Array.isArray(raw)) return []
const turns: ChatMessage[] = []
for (const t of raw) {
if (!t || typeof t !== 'object') continue
const role = (t as { role?: unknown }).role
const content = (t as { content?: unknown }).content
if ((role === 'user' || role === 'assistant') && typeof content === 'string' && content.trim()) {
turns.push({ role, content })
}
}
return turns.slice(-8)
}
export async function POST(request: NextRequest) {
try {
const body = await request.json()
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 history = parseHistory(body.history)
const country = (['DE', 'AT', 'CH', 'EU'] as const).includes(body.country)
? (body.country as Country)
: undefined
if (!question) {
return NextResponse.json({ error: 'Question is required' }, { status: 400 })
}
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) },
...history,
{ 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: answerSystem(soul, country, numberedEvidenceForPrompt(evidence), true, audience) },
...history,
{ role: 'user', content: question },
]
const answer = await completeAdvisorAnswer(messages)
if (answer === null) {
return NextResponse.json({ error: 'LLM nicht erreichbar.' }, { status: 502 })
}
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 Advisor fehlgeschlagen.' }, { status: 503 })
}
}