5a513181cc
Completes the advisor stack (FE + orchestration; /retrieve is SDK/RAG-owned). The route
now returns the FE contract instead of a text stream:
- retrieveFull() calls /retrieve with {query, context}; consumes clarity/evidence/
visual_evidence/footnotes (exact shape per board 2026-07-01 12:25).
- mode-routing (resolveMode): clarify unless a context was chosen and /retrieve's
clarity.mode says so. clarify -> L1 general answer (completeAdvisorAnswer, ungrounded,
no sources). answer -> L2 answer over numbered evidence with [n] markers.
- citations generated here ([n] -> nth evidence unit); footnotes remapped; evidence /
visual_evidence passed through.
- advisor-llm: non-streaming completeAdvisorAnswer(). Pure mappings in retrieve-mapping.ts
(+ tests). Removed the dead v2 evidence.ts/evidence-adapter (RegulationRef moved to
regulation-display). controls-augmentation kept (tested; re-integrable later).
NOT deployed: joint deploy with the SDK /retrieve endpoint (deploy-coupling). tsc clean,
25 vitest (mapping/clarify/answer/markdown/registry/rag), check-loc 0.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
164 lines
4.8 KiB
TypeScript
164 lines
4.8 KiB
TypeScript
/**
|
|
* Compliance-Advisor LLM-Kaskade.
|
|
*
|
|
* Reihenfolge:
|
|
* 1. OVH / LiteLLM (OpenAI-kompatibel, SSE-Streaming) — prod-LLM, wenn
|
|
* OVH_LLM_URL + OVH_LLM_MODEL gesetzt sind.
|
|
* 2. Ollama-Chat (NDJSON-Streaming) — lokale Entwicklung / Fallback.
|
|
*
|
|
* Auf prod zeigt OLLAMA_URL auf den Embedding-only-Dienst (kein Chat-Modell),
|
|
* deshalb ist OVH dort der einzige funktionierende Pfad. Lokal (ohne OVH-Env)
|
|
* laeuft der Advisor weiter ueber Ollama. Beide Quellen werden auf einen
|
|
* einheitlichen Plain-Text-Stream normalisiert.
|
|
*/
|
|
|
|
const OLLAMA_URL = process.env.OLLAMA_URL || 'http://host.docker.internal:11434'
|
|
const OLLAMA_MODEL = process.env.COMPLIANCE_LLM_MODEL || 'qwen2.5vl:32b'
|
|
const OVH_URL = (process.env.OVH_LLM_URL || '').replace(/\/+$/, '')
|
|
const OVH_MODEL = process.env.OVH_LLM_MODEL || ''
|
|
const OVH_KEY = process.env.OVH_LLM_KEY || ''
|
|
|
|
export interface ChatMessage {
|
|
role: string
|
|
content: string
|
|
}
|
|
|
|
const encoder = new TextEncoder()
|
|
|
|
/** Extrahiert den Text-Delta aus einer Ollama-NDJSON-Zeile (message.content). */
|
|
export function parseOllamaLine(line: string): string | null {
|
|
const t = line.trim()
|
|
if (!t) return null
|
|
try {
|
|
const j = JSON.parse(t)
|
|
return j?.message?.content || null
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
/** Extrahiert den Text-Delta aus einer OpenAI/OVH-SSE-Zeile (choices[].delta.content). */
|
|
export function parseSSELine(line: string): string | null {
|
|
const t = line.trim()
|
|
if (!t.startsWith('data:')) return null
|
|
const payload = t.slice(5).trim()
|
|
if (!payload || payload === '[DONE]') return null
|
|
try {
|
|
const j = JSON.parse(payload)
|
|
return j?.choices?.[0]?.delta?.content || null
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
function textStream(
|
|
upstream: Response,
|
|
parseLine: (line: string) => string | null,
|
|
): ReadableStream<Uint8Array> {
|
|
return new ReadableStream({
|
|
async start(controller) {
|
|
const reader = upstream.body!.getReader()
|
|
const decoder = new TextDecoder()
|
|
let buf = ''
|
|
try {
|
|
for (;;) {
|
|
const { done, value } = await reader.read()
|
|
if (done) break
|
|
buf += decoder.decode(value, { stream: true })
|
|
const lines = buf.split('\n')
|
|
buf = lines.pop() || ''
|
|
for (const line of lines) {
|
|
const delta = parseLine(line)
|
|
if (delta) controller.enqueue(encoder.encode(delta))
|
|
}
|
|
}
|
|
const tail = parseLine(buf)
|
|
if (tail) controller.enqueue(encoder.encode(tail))
|
|
} finally {
|
|
controller.close()
|
|
}
|
|
},
|
|
})
|
|
}
|
|
|
|
async function tryOVH(messages: ChatMessage[]): Promise<Response | null> {
|
|
if (!OVH_URL || !OVH_MODEL) return null
|
|
try {
|
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
|
if (OVH_KEY) headers['Authorization'] = `Bearer ${OVH_KEY}`
|
|
const r = await fetch(`${OVH_URL}/v1/chat/completions`, {
|
|
method: 'POST',
|
|
headers,
|
|
body: JSON.stringify({
|
|
model: OVH_MODEL,
|
|
messages,
|
|
stream: true,
|
|
temperature: 0.3,
|
|
max_tokens: 4096,
|
|
}),
|
|
signal: AbortSignal.timeout(120000),
|
|
})
|
|
return r.ok && r.body ? r : null
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
async function tryOllama(messages: ChatMessage[]): Promise<Response | null> {
|
|
try {
|
|
const r = await fetch(`${OLLAMA_URL}/api/chat`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
model: OLLAMA_MODEL,
|
|
messages,
|
|
stream: true,
|
|
think: false,
|
|
keep_alive: '30m',
|
|
options: { temperature: 0.3, num_predict: 4096, num_ctx: 8192 },
|
|
}),
|
|
signal: AbortSignal.timeout(120000),
|
|
})
|
|
return r.ok && r.body ? r : null
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Liefert einen Plain-Text-Stream der LLM-Antwort. OVH zuerst (prod), dann
|
|
* Ollama (Dev/Fallback). null = kein LLM erreichbar (Caller antwortet mit 502).
|
|
*/
|
|
export async function streamAdvisorAnswer(
|
|
messages: ChatMessage[],
|
|
): Promise<ReadableStream<Uint8Array> | null> {
|
|
const ovh = await tryOVH(messages)
|
|
if (ovh) return textStream(ovh, parseSSELine)
|
|
const ollama = await tryOllama(messages)
|
|
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
|
|
}
|