e8ea179228
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>
186 lines
7.5 KiB
TypeScript
186 lines
7.5 KiB
TypeScript
/**
|
||
* 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** (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
|
||
}
|
||
|
||
// 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 })
|
||
}
|
||
}
|