Files
breakpilot-compliance/admin-compliance/lib/sdk/drafting-engine/llm-cascade.ts
T
Benjamin Admin 90a70c8404
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
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 7s
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 5s
CI / validate-canonical-controls (push) Successful in 4s
CI / loc-budget (push) Successful in 17s
CI / go-lint (push) Has been skipped
CI / nodejs-build (push) Successful in 3m2s
CI / test-go (push) Has been skipped
fix(drafting): Drafting-Engine auf prod reparieren — RAG via ai-sdk + OVH-LLM-Kaskade
Die Drafting-Engine (Dokument-Entwurf, v2-Pipeline, Validierung, Drafting-Chat,
Vendor-Vertragspruefung) war auf prod doppelt tot:
- RAG ueber bp-core-rag-service:8097 (existiert auf prod nicht)
- LLM ueber OLLAMA_URL/api/chat mit qwen2.5vl (prod = ollama-embed, kein Chat-Modell)

Fix (analog zum Compliance-Advisor):
- rag-query.ts -> ai-compliance-sdk /sdk/v1/rag/search (bge-m3, prod-erreichbar).
- Neue lib/sdk/drafting-engine/llm-cascade.ts: OVH/LiteLLM (gpt-oss-120b) zuerst,
  Ollama als Dev-Fallback; cascadeComplete (JSON) + cascadeStream. Das Backend nutzt
  OVH+JSON bereits erfolgreich auf prod (extract-datasheet).
- 5 Aufrufstellen (draft-helpers, draft-helpers-v2, validate, chat, vendor-review)
  auf die Kaskade umgestellt; keine direkten Ollama-Calls mehr.
- Tests: llm-cascade + rag-query aktualisiert.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-19 10:02:06 +02:00

211 lines
6.7 KiB
TypeScript

/**
* Gemeinsame LLM-Kaskade fuer die Drafting-Engine.
*
* Reihenfolge: OVH/LiteLLM (gpt-oss-120b) zuerst — der prod-Chat-LLM; Ollama als
* Dev-Fallback. Auf prod ist OLLAMA_URL embedding-only (kein Chat-Modell), daher
* ist OVH dort der einzige funktionierende Pfad — genau wie beim Compliance-Advisor
* (siehe lib/sdk/agents/advisor-llm). Das Backend nutzt OVH + JSON-Output bereits
* erfolgreich auf prod (extract-datasheet), dieselbe Technik wird hier gespiegelt.
*/
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
}
export interface CascadeOpts {
json?: boolean
maxTokens?: number
temperature?: number
timeoutMs?: number
}
export interface CascadeResult {
content: string
tokensUsed: number
provider: 'ovh' | 'ollama'
}
// --- Stream-Parser (pure, testbar) ---
/** 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 {
return JSON.parse(t)?.message?.content || null
} catch {
return null
}
}
/** Text-Delta aus einer OpenAI/OVH-SSE-Zeile (choices[0].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 {
return JSON.parse(payload)?.choices?.[0]?.delta?.content || null
} catch {
return null
}
}
// --- Non-Streaming (cascadeComplete) ---
async function ovhComplete(messages: ChatMessage[], o: CascadeOpts): Promise<CascadeResult | 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 payload: Record<string, unknown> = {
model: OVH_MODEL,
messages,
stream: false,
temperature: o.temperature ?? 0.15,
max_tokens: o.maxTokens ?? 4096,
}
if (o.json) payload.response_format = { type: 'json_object' }
const r = await fetch(`${OVH_URL}/v1/chat/completions`, {
method: 'POST',
headers,
body: JSON.stringify(payload),
signal: AbortSignal.timeout(o.timeoutMs ?? 120000),
})
if (!r.ok) return null
const d = await r.json()
const content = d?.choices?.[0]?.message?.content || ''
if (!content) return null
const usage = d?.usage || {}
return { content, tokensUsed: usage.completion_tokens ?? usage.total_tokens ?? 0, provider: 'ovh' }
} catch {
return null
}
}
async function ollamaComplete(messages: ChatMessage[], o: CascadeOpts): Promise<CascadeResult | null> {
try {
const body: Record<string, unknown> = {
model: OLLAMA_MODEL,
messages,
stream: false,
think: false,
options: { temperature: o.temperature ?? 0.15, num_predict: o.maxTokens ?? 4096, num_ctx: 8192 },
}
if (o.json) body.format = 'json'
const r = await fetch(`${OLLAMA_URL}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: AbortSignal.timeout(o.timeoutMs ?? 120000),
})
if (!r.ok) return null
const d = await r.json()
const content = d?.message?.content || ''
if (!content) return null
return { content, tokensUsed: d?.eval_count ?? 0, provider: 'ollama' }
} catch {
return null
}
}
/**
* Nicht-streamender LLM-Aufruf mit Kaskade. Liefert Inhalt + Token + Provider,
* oder null wenn weder OVH noch Ollama antworten.
*/
export async function cascadeComplete(messages: ChatMessage[], opts: CascadeOpts = {}): Promise<CascadeResult | null> {
return (await ovhComplete(messages, opts)) ?? (await ollamaComplete(messages, opts))
}
// --- Streaming (cascadeStream) ---
const encoder = new TextEncoder()
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 ovhStream(messages: ChatMessage[], o: CascadeOpts): 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: o.temperature ?? 0.3,
max_tokens: o.maxTokens ?? 4096,
}),
signal: AbortSignal.timeout(o.timeoutMs ?? 120000),
})
return r.ok && r.body ? r : null
} catch {
return null
}
}
async function ollamaStream(messages: ChatMessage[], o: CascadeOpts): 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,
options: { temperature: o.temperature ?? 0.3, num_predict: o.maxTokens ?? 4096, num_ctx: 8192 },
}),
signal: AbortSignal.timeout(o.timeoutMs ?? 120000),
})
return r.ok && r.body ? r : null
} catch {
return null
}
}
/**
* Streamender LLM-Aufruf mit Kaskade -> Plain-Text-Stream. null = kein LLM erreichbar.
*/
export async function cascadeStream(messages: ChatMessage[], opts: CascadeOpts = {}): Promise<ReadableStream<Uint8Array> | null> {
const ovh = await ovhStream(messages, opts)
if (ovh) return textStream(ovh, parseSSELine)
const ollama = await ollamaStream(messages, opts)
if (ollama) return textStream(ollama, parseOllamaLine)
return null
}