From 90a70c8404c1aeaeb9f9324f11ff66cf30aa0b12 Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Fri, 19 Jun 2026 10:02:06 +0200 Subject: [PATCH] =?UTF-8?q?fix(drafting):=20Drafting-Engine=20auf=20prod?= =?UTF-8?q?=20reparieren=20=E2=80=94=20RAG=20via=20ai-sdk=20+=20OVH-LLM-Ka?= =?UTF-8?q?skade?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../app/api/sdk/drafting-engine/chat/route.ts | 64 +----- .../drafting-engine/draft/draft-helpers-v2.ts | 34 +-- .../drafting-engine/draft/draft-helpers.ts | 30 +-- .../api/sdk/drafting-engine/validate/route.ts | 35 +-- .../contracts/[id]/review/route.ts | 34 +-- .../__tests__/llm-cascade.test.ts | 81 +++++++ .../__tests__/rag-query.test.ts | 47 ++-- .../lib/sdk/drafting-engine/llm-cascade.ts | 210 ++++++++++++++++++ .../lib/sdk/drafting-engine/rag-query.ts | 66 ++++-- 9 files changed, 398 insertions(+), 203 deletions(-) create mode 100644 admin-compliance/lib/sdk/drafting-engine/__tests__/llm-cascade.test.ts create mode 100644 admin-compliance/lib/sdk/drafting-engine/llm-cascade.ts diff --git a/admin-compliance/app/api/sdk/drafting-engine/chat/route.ts b/admin-compliance/app/api/sdk/drafting-engine/chat/route.ts index 04071cd6..8693cec8 100644 --- a/admin-compliance/app/api/sdk/drafting-engine/chat/route.ts +++ b/admin-compliance/app/api/sdk/drafting-engine/chat/route.ts @@ -11,9 +11,7 @@ import { queryRAG } from '@/lib/sdk/drafting-engine/rag-query' import { DOCUMENT_RAG_CONFIG } from '@/lib/sdk/drafting-engine/rag-config' import { readSoulFile } from '@/lib/sdk/agents/soul-reader' import type { ScopeDocumentType } from '@/lib/sdk/compliance-scope-types' - -const OLLAMA_URL = process.env.OLLAMA_URL || 'http://host.docker.internal:11434' -const LLM_MODEL = process.env.COMPLIANCE_LLM_MODEL || 'qwen2.5vl:32b' +import { cascadeStream } from '@/lib/sdk/drafting-engine/llm-cascade' // Fallback SOUL prompt (used when .soul.md file is unavailable) const FALLBACK_DRAFTING_PROMPT = `# Drafting Agent - Compliance-Dokumententwurf @@ -81,66 +79,20 @@ export async function POST(request: NextRequest) { ] // 4. Call LLM with streaming - const ollamaResponse = await fetch(`${OLLAMA_URL}/api/chat`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - model: LLM_MODEL, - messages, - stream: true, - think: false, - options: { - temperature: mode === 'draft' ? 0.2 : 0.3, - num_predict: mode === 'draft' ? 16384 : 8192, - num_ctx: 8192, - }, - }), - signal: AbortSignal.timeout(120000), + // 4. LLM-Kaskade (OVH -> Ollama) -> Plain-Text-Stream + const stream = await cascadeStream(messages, { + temperature: mode === 'draft' ? 0.2 : 0.3, + maxTokens: mode === 'draft' ? 16384 : 8192, + timeoutMs: 120000, }) - if (!ollamaResponse.ok) { - const errorText = await ollamaResponse.text() - console.error('LLM error:', ollamaResponse.status, errorText) + if (!stream) { return NextResponse.json( - { error: `LLM nicht erreichbar (Status ${ollamaResponse.status})` }, + { error: 'LLM nicht erreichbar (weder OVH noch Ollama)' }, { status: 502 } ) } - // 5. Stream response back - const encoder = new TextEncoder() - const stream = new ReadableStream({ - async start(controller) { - const reader = ollamaResponse.body!.getReader() - const decoder = new TextDecoder() - - try { - while (true) { - const { done, value } = await reader.read() - if (done) break - - const chunk = decoder.decode(value, { stream: true }) - const lines = chunk.split('\n').filter((l) => l.trim()) - - for (const line of lines) { - try { - const json = JSON.parse(line) - if (json.message?.content) { - controller.enqueue(encoder.encode(json.message.content)) - } - } catch { - // Partial JSON, skip - } - } - } - } catch (error) { - console.error('Stream error:', error) - } finally { - controller.close() - } - }, - }) - return new NextResponse(stream, { headers: { 'Content-Type': 'text/plain; charset=utf-8', diff --git a/admin-compliance/app/api/sdk/drafting-engine/draft/draft-helpers-v2.ts b/admin-compliance/app/api/sdk/drafting-engine/draft/draft-helpers-v2.ts index e9800f88..99e4d43f 100644 --- a/admin-compliance/app/api/sdk/drafting-engine/draft/draft-helpers-v2.ts +++ b/admin-compliance/app/api/sdk/drafting-engine/draft/draft-helpers-v2.ts @@ -17,6 +17,7 @@ import { executeRepairLoop, type ProseBlockOutput, type RepairAudit } from '@/li import { computeChecksumSync, type CacheKeyParams } from '@/lib/sdk/drafting-engine/cache' import { queryRAG } from '@/lib/sdk/drafting-engine/rag-query' import { DOCUMENT_RAG_CONFIG } from '@/lib/sdk/drafting-engine/rag-config' +import { cascadeComplete } from '@/lib/sdk/drafting-engine/llm-cascade' import { constraintEnforcer, proseCache, @@ -27,7 +28,6 @@ import { buildPromptForDocumentType, } from './draft-helpers' -const OLLAMA_URL = process.env.OLLAMA_URL || 'http://host.docker.internal:11434' const LLM_MODEL = process.env.COMPLIANCE_LLM_MODEL || 'qwen2.5vl:32b' // ============================================================================ @@ -171,29 +171,15 @@ Keine neuen Fakten erfinden — nur das Profil wuerdigen.` } export async function callOllama(systemPrompt: string, userPrompt: string): Promise { - const response = await fetch(`${OLLAMA_URL}/api/chat`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - model: LLM_MODEL, - messages: [ - { role: 'system', content: systemPrompt }, - { role: 'user', content: userPrompt }, - ], - stream: false, - think: false, - options: { temperature: 0.15, num_predict: 4096, num_ctx: 8192 }, - format: 'json', - }), - signal: AbortSignal.timeout(120000), - }) - - if (!response.ok) { - throw new Error(`Ollama error: ${response.status}`) - } - - const result = await response.json() - return result.message?.content || '' + const llm = await cascadeComplete( + [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userPrompt }, + ], + { json: true, temperature: 0.15, maxTokens: 8192, timeoutMs: 120000 }, + ) + if (!llm) throw new Error('LLM nicht erreichbar (weder OVH noch Ollama)') + return llm.content } export async function handleV2Draft(body: Record): Promise { diff --git a/admin-compliance/app/api/sdk/drafting-engine/draft/draft-helpers.ts b/admin-compliance/app/api/sdk/drafting-engine/draft/draft-helpers.ts index ce422ad9..1d54697e 100644 --- a/admin-compliance/app/api/sdk/drafting-engine/draft/draft-helpers.ts +++ b/admin-compliance/app/api/sdk/drafting-engine/draft/draft-helpers.ts @@ -17,9 +17,7 @@ import { ConstraintEnforcer } from '@/lib/sdk/drafting-engine/constraint-enforce import { ProseCacheManager } from '@/lib/sdk/drafting-engine/cache' import { queryRAG } from '@/lib/sdk/drafting-engine/rag-query' import { DOCUMENT_RAG_CONFIG } from '@/lib/sdk/drafting-engine/rag-config' - -const OLLAMA_URL = process.env.OLLAMA_URL || 'http://host.docker.internal:11434' -const LLM_MODEL = process.env.COMPLIANCE_LLM_MODEL || 'qwen2.5vl:32b' +import { cascadeComplete } from '@/lib/sdk/drafting-engine/llm-cascade' export const constraintEnforcer = new ConstraintEnforcer() export const proseCache = new ProseCacheManager({ maxEntries: 200, ttlHours: 24 }) @@ -105,29 +103,21 @@ export async function handleV1Draft(body: Record): Promise): Promise Ollama) + Stream-Parser. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' + +const mockFetch = vi.fn() +vi.stubGlobal('fetch', mockFetch) + +describe('llm-cascade parser', () => { + it('parseOllamaLine extrahiert message.content', async () => { + const { parseOllamaLine } = await import('../llm-cascade') + expect(parseOllamaLine('{"message":{"content":"X"}}')).toBe('X') + expect(parseOllamaLine('')).toBeNull() + expect(parseOllamaLine('kaputt')).toBeNull() + }) + + it('parseSSELine extrahiert delta.content', async () => { + const { parseSSELine } = await import('../llm-cascade') + expect(parseSSELine('data: {"choices":[{"delta":{"content":"Y"}}]}')).toBe('Y') + expect(parseSSELine('data: [DONE]')).toBeNull() + expect(parseSSELine('event: ping')).toBeNull() + }) +}) + +describe('cascadeComplete', () => { + beforeEach(() => { + vi.resetModules() + mockFetch.mockReset() + }) + afterEach(() => { + vi.unstubAllEnvs() + }) + + it('nutzt OVH zuerst wenn konfiguriert (json + response_format)', async () => { + vi.stubEnv('OVH_LLM_URL', 'https://ovh.test') + vi.stubEnv('OVH_LLM_MODEL', 'gpt-oss-120b') + vi.stubEnv('OVH_LLM_KEY', 'k') + const { cascadeComplete } = await import('../llm-cascade') + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ choices: [{ message: { content: '{"ok":1}' } }], usage: { completion_tokens: 42 } }), + }) + const r = await cascadeComplete([{ role: 'user', content: 'hi' }], { json: true, maxTokens: 1000 }) + expect(r).toEqual({ content: '{"ok":1}', tokensUsed: 42, provider: 'ovh' }) + const [url, opts] = mockFetch.mock.calls[0] + expect(url).toContain('/v1/chat/completions') + const body = JSON.parse(opts.body) + expect(body.response_format).toEqual({ type: 'json_object' }) + expect(body.stream).toBe(false) + }) + + it('faellt auf Ollama zurueck wenn OVH nicht konfiguriert ist', async () => { + const { cascadeComplete } = await import('../llm-cascade') + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ message: { content: 'hallo' }, eval_count: 7 }), + }) + const r = await cascadeComplete([{ role: 'user', content: 'hi' }]) + expect(r).toEqual({ content: 'hallo', tokensUsed: 7, provider: 'ollama' }) + expect(mockFetch.mock.calls[0][0]).toContain('/api/chat') + }) + + it('faellt auf Ollama zurueck wenn OVH einen Fehler liefert', async () => { + vi.stubEnv('OVH_LLM_URL', 'https://ovh.test') + vi.stubEnv('OVH_LLM_MODEL', 'gpt-oss-120b') + const { cascadeComplete } = await import('../llm-cascade') + mockFetch + .mockResolvedValueOnce({ ok: false, status: 502 }) + .mockResolvedValueOnce({ ok: true, json: async () => ({ message: { content: 'fallback' }, eval_count: 3 }) }) + const r = await cascadeComplete([{ role: 'user', content: 'hi' }]) + expect(r?.provider).toBe('ollama') + expect(r?.content).toBe('fallback') + }) + + it('liefert null wenn weder OVH noch Ollama antworten', async () => { + const { cascadeComplete } = await import('../llm-cascade') + mockFetch.mockResolvedValue({ ok: false, status: 500 }) + expect(await cascadeComplete([{ role: 'user', content: 'hi' }])).toBeNull() + }) +}) diff --git a/admin-compliance/lib/sdk/drafting-engine/__tests__/rag-query.test.ts b/admin-compliance/lib/sdk/drafting-engine/__tests__/rag-query.test.ts index 8d27e020..8d5ca0d4 100644 --- a/admin-compliance/lib/sdk/drafting-engine/__tests__/rag-query.test.ts +++ b/admin-compliance/lib/sdk/drafting-engine/__tests__/rag-query.test.ts @@ -1,5 +1,5 @@ /** - * Tests for the shared queryRAG utility. + * Tests for the shared queryRAG utility (ai-sdk /sdk/v1/rag/search, bge-m3). */ import { describe, it, expect, beforeEach, vi } from 'vitest' @@ -19,13 +19,13 @@ describe('queryRAG', () => { queryRAG = mod.queryRAG }) - it('should return formatted results on success', async () => { + it('should return formatted results on success (ai-sdk shape)', async () => { mockFetch.mockResolvedValueOnce({ ok: true, json: async () => ({ results: [ - { source_name: 'DSGVO', content: 'Art. 35 regelt die DSFA...' }, - { source_code: 'EU_2016_679', content: 'Risikobewertung erforderlich' }, + { text: 'Art. 35 regelt die DSFA...', regulation_short: 'DSGVO' }, + { text: 'Risikobewertung erforderlich', regulation_code: 'EU_2016_679' }, ], }), }) @@ -38,7 +38,7 @@ describe('queryRAG', () => { expect(mockFetch).toHaveBeenCalledTimes(1) }) - it('should send POST request to RAG_SERVICE_URL', async () => { + it('should POST to the ai-sdk /sdk/v1/rag/search endpoint', async () => { mockFetch.mockResolvedValueOnce({ ok: true, json: async () => ({ results: [] }), @@ -47,10 +47,10 @@ describe('queryRAG', () => { await queryRAG('test query') expect(mockFetch).toHaveBeenCalledWith( - expect.stringContaining('/api/v1/search'), + expect.stringContaining('/sdk/v1/rag/search'), expect.objectContaining({ method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: expect.objectContaining({ 'Content-Type': 'application/json' }), }) ) }) @@ -99,43 +99,24 @@ describe('queryRAG', () => { }) it('should return empty string on HTTP error', async () => { - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 500, - }) - - const result = await queryRAG('test query') - - expect(result).toBe('') + mockFetch.mockResolvedValueOnce({ ok: false, status: 500 }) + expect(await queryRAG('test query')).toBe('') }) it('should return empty string on network error', async () => { mockFetch.mockRejectedValueOnce(new Error('Connection refused')) - - const result = await queryRAG('test query') - - expect(result).toBe('') + expect(await queryRAG('test query')).toBe('') }) it('should return empty string when no results', async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ results: [] }), - }) - - const result = await queryRAG('test query') - - expect(result).toBe('') + mockFetch.mockResolvedValueOnce({ ok: true, json: async () => ({ results: [] }) }) + expect(await queryRAG('test query')).toBe('') }) - it('should handle results with missing fields gracefully', async () => { + it('should handle results with missing source fields gracefully', async () => { mockFetch.mockResolvedValueOnce({ ok: true, - json: async () => ({ - results: [ - { content: 'Some content without source' }, - ], - }), + json: async () => ({ results: [{ text: 'Some content without source' }] }), }) const result = await queryRAG('test') diff --git a/admin-compliance/lib/sdk/drafting-engine/llm-cascade.ts b/admin-compliance/lib/sdk/drafting-engine/llm-cascade.ts new file mode 100644 index 00000000..5dd2334e --- /dev/null +++ b/admin-compliance/lib/sdk/drafting-engine/llm-cascade.ts @@ -0,0 +1,210 @@ +/** + * 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 { + if (!OVH_URL || !OVH_MODEL) return null + try { + const headers: Record = { 'Content-Type': 'application/json' } + if (OVH_KEY) headers['Authorization'] = `Bearer ${OVH_KEY}` + const payload: Record = { + 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 { + try { + const body: Record = { + 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 { + 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 { + 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 { + if (!OVH_URL || !OVH_MODEL) return null + try { + const headers: Record = { '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 { + 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 | 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 +} diff --git a/admin-compliance/lib/sdk/drafting-engine/rag-query.ts b/admin-compliance/lib/sdk/drafting-engine/rag-query.ts index 1d114b77..56184393 100644 --- a/admin-compliance/lib/sdk/drafting-engine/rag-query.ts +++ b/admin-compliance/lib/sdk/drafting-engine/rag-query.ts @@ -1,12 +1,28 @@ /** * Shared RAG query utility for the Drafting Engine. * - * Queries the bp-core-rag-service for relevant legal context. - * Supports multi-collection search via POST /api/v1/search. - * Used by both chat and draft routes. + * Fragt die ai-compliance-sdk (`/sdk/v1/rag/search`, bge-m3) nach Rechtskontext. + * Frueher: bp-core-rag-service:8097 — der existiert auf prod NICHT (nur macmini/dev), + * dadurch lieferte die Drafting-Engine dort keinen RAG-Kontext. Die ai-sdk embeddet + * mit bge-m3 und ist prod-erreichbar. Genutzt von draft-, chat- und vendor-review-Routes. */ -const RAG_SERVICE_URL = process.env.RAG_SERVICE_URL || 'http://bp-core-rag-service:8097' +const SDK_URL = + process.env.SDK_API_URL || process.env.SDK_URL || 'http://ai-compliance-sdk:8090' +const DEFAULT_USER = '00000000-0000-0000-0000-000000000001' +const DEFAULT_TENANT = + process.env.DEFAULT_TENANT_ID || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e' + +interface SdkRagResult { + text?: string + regulation_code?: string + regulation_name?: string + regulation_short?: string + // Rueckwaerts-kompatibel, falls eine Quelle noch das alte rag-service-Format liefert: + content?: string + source_name?: string + source_code?: string +} /** * Query the RAG corpus for relevant legal documents. @@ -18,17 +34,16 @@ const RAG_SERVICE_URL = process.env.RAG_SERVICE_URL || 'http://bp-core-rag-servi */ export async function queryRAG(query: string, topK = 3, collection?: string): Promise { try { - const body: Record = { - query, - top_k: topK, - } - if (collection) { - body.collection = collection - } + const body: Record = { query, top_k: topK } + if (collection) body.collection = collection - const res = await fetch(`${RAG_SERVICE_URL}/api/v1/search`, { + const res = await fetch(`${SDK_URL}/sdk/v1/rag/search`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + 'X-User-ID': DEFAULT_USER, + 'X-Tenant-ID': DEFAULT_TENANT, + }, body: JSON.stringify(body), signal: AbortSignal.timeout(10000), }) @@ -36,15 +51,22 @@ export async function queryRAG(query: string, topK = 3, collection?: string): Pr if (!res.ok) return '' const data = await res.json() - if (data.results?.length > 0) { - return data.results - .map( - (r: { source_name?: string; source_code?: string; content?: string }, i: number) => - `[Quelle ${i + 1}: ${r.source_name || r.source_code || 'Unbekannt'}]\n${r.content || ''}` - ) - .join('\n\n---\n\n') - } - return '' + const results: SdkRagResult[] = data.results || [] + if (results.length === 0) return '' + + return results + .map((r, i) => { + const source = + r.regulation_short || + r.regulation_name || + r.regulation_code || + r.source_name || + r.source_code || + 'Unbekannt' + const content = r.text || r.content || '' + return `[Quelle ${i + 1}: ${source}]\n${content}` + }) + .join('\n\n---\n\n') } catch { return '' }