fix(drafting): Drafting-Engine auf prod reparieren — RAG via ai-sdk + OVH-LLM-Kaskade
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
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
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>
This commit is contained in:
@@ -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: {
|
||||
// 4. LLM-Kaskade (OVH -> Ollama) -> Plain-Text-Stream
|
||||
const stream = await cascadeStream(messages, {
|
||||
temperature: mode === 'draft' ? 0.2 : 0.3,
|
||||
num_predict: mode === 'draft' ? 16384 : 8192,
|
||||
num_ctx: 8192,
|
||||
},
|
||||
}),
|
||||
signal: AbortSignal.timeout(120000),
|
||||
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',
|
||||
|
||||
@@ -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<string> {
|
||||
const response = await fetch(`${OLLAMA_URL}/api/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: LLM_MODEL,
|
||||
messages: [
|
||||
const llm = await cascadeComplete(
|
||||
[
|
||||
{ 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 || ''
|
||||
{ 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<string, unknown>): Promise<NextResponse> {
|
||||
|
||||
@@ -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<string, unknown>): Promise<Next
|
||||
{ role: 'user', content: draftPrompt },
|
||||
]
|
||||
|
||||
const ollamaResponse = await fetch(`${OLLAMA_URL}/api/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: LLM_MODEL,
|
||||
messages,
|
||||
stream: false,
|
||||
think: false,
|
||||
options: { temperature: 0.15, num_predict: 16384, num_ctx: 8192 },
|
||||
format: 'json',
|
||||
}),
|
||||
signal: AbortSignal.timeout(180000),
|
||||
const llm = await cascadeComplete(messages, {
|
||||
json: true,
|
||||
temperature: 0.15,
|
||||
maxTokens: 16384,
|
||||
timeoutMs: 180000,
|
||||
})
|
||||
|
||||
if (!ollamaResponse.ok) {
|
||||
if (!llm) {
|
||||
return NextResponse.json(
|
||||
{ error: `LLM nicht erreichbar (Status ${ollamaResponse.status})` },
|
||||
{ error: 'LLM nicht erreichbar (weder OVH noch Ollama)' },
|
||||
{ status: 502 }
|
||||
)
|
||||
}
|
||||
|
||||
const result = await ollamaResponse.json()
|
||||
const content = result.message?.content || ''
|
||||
const content = llm.content
|
||||
|
||||
let sections: DraftSection[] = []
|
||||
try {
|
||||
@@ -153,7 +143,7 @@ export async function handleV1Draft(body: Record<string, unknown>): Promise<Next
|
||||
return NextResponse.json({
|
||||
draft,
|
||||
constraintCheck,
|
||||
tokensUsed: result.eval_count || 0,
|
||||
tokensUsed: llm.tokensUsed,
|
||||
} satisfies DraftResponse)
|
||||
}
|
||||
|
||||
|
||||
@@ -10,9 +10,7 @@ import { DOCUMENT_SCOPE_MATRIX_CORE, DOCUMENT_TYPE_LABELS, getDepthLevelNumeric
|
||||
import type { ScopeDocumentType, ComplianceDepthLevel } from '@/lib/sdk/compliance-scope-types'
|
||||
import type { ValidationContext, ValidationResult, ValidationFinding } from '@/lib/sdk/drafting-engine/types'
|
||||
import { buildCrossCheckPrompt } from '@/lib/sdk/drafting-engine/prompts/validate-cross-check'
|
||||
|
||||
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'
|
||||
|
||||
/**
|
||||
* Anti-Fake-Evidence: Verbotene Formulierungen
|
||||
@@ -244,30 +242,17 @@ export async function POST(request: NextRequest) {
|
||||
context: validationContext,
|
||||
})
|
||||
|
||||
const ollamaResponse = await fetch(`${OLLAMA_URL}/api/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: LLM_MODEL,
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: 'Du bist ein DSGVO-Compliance-Validator. Antworte NUR im JSON-Format.',
|
||||
},
|
||||
const llm = await cascadeComplete(
|
||||
[
|
||||
{ role: 'system', content: 'Du bist ein DSGVO-Compliance-Validator. Antworte NUR im JSON-Format.' },
|
||||
{ role: 'user', content: crossCheckPrompt },
|
||||
],
|
||||
stream: false,
|
||||
think: false,
|
||||
options: { temperature: 0.1, num_predict: 8192, num_ctx: 8192 },
|
||||
format: 'json',
|
||||
}),
|
||||
signal: AbortSignal.timeout(120000),
|
||||
})
|
||||
{ json: true, temperature: 0.1, maxTokens: 8192, timeoutMs: 120000 },
|
||||
)
|
||||
|
||||
if (ollamaResponse.ok) {
|
||||
const result = await ollamaResponse.json()
|
||||
if (llm) {
|
||||
try {
|
||||
const parsed = JSON.parse(result.message?.content || '{}')
|
||||
const parsed = JSON.parse(llm.content || '{}')
|
||||
llmFindings = [
|
||||
...(parsed.errors || []),
|
||||
...(parsed.warnings || []),
|
||||
|
||||
@@ -6,9 +6,7 @@ import {
|
||||
} from '@/lib/sdk/vendor-compliance'
|
||||
import { queryRAG } from '@/lib/sdk/drafting-engine/rag-query'
|
||||
import { transformAnalysisResponse } from '@/lib/sdk/vendor-compliance/contract-review/analyzer'
|
||||
|
||||
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'
|
||||
|
||||
/**
|
||||
* POST /api/sdk/v1/vendor-compliance/contracts/[id]/review
|
||||
@@ -47,29 +45,19 @@ export async function POST(
|
||||
}
|
||||
|
||||
// Call Ollama
|
||||
const ollamaResponse = await fetch(`${OLLAMA_URL}/api/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: LLM_MODEL,
|
||||
messages: [
|
||||
const llm = await cascadeComplete(
|
||||
[
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: `Analysiere den folgenden Vertrag auf DSGVO-Konformitaet:\n\n${documentText}` },
|
||||
],
|
||||
stream: false,
|
||||
options: { temperature: 0.1, num_predict: 16384 },
|
||||
format: 'json',
|
||||
}),
|
||||
signal: AbortSignal.timeout(180000),
|
||||
})
|
||||
{ json: true, temperature: 0.1, maxTokens: 16384, timeoutMs: 180000 },
|
||||
)
|
||||
|
||||
if (!ollamaResponse.ok) {
|
||||
throw new Error(`LLM nicht erreichbar (Status ${ollamaResponse.status})`)
|
||||
if (!llm) {
|
||||
throw new Error('LLM nicht erreichbar (weder OVH noch Ollama)')
|
||||
}
|
||||
|
||||
const result = await ollamaResponse.json()
|
||||
const content = result.message?.content || ''
|
||||
const llmResponse = JSON.parse(content)
|
||||
const llmResponse = JSON.parse(llm.content)
|
||||
|
||||
// Transform LLM response to typed findings
|
||||
const analysisResult = transformAnalysisResponse(llmResponse, {
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Tests fuer die Drafting-Engine LLM-Kaskade (OVH -> 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()
|
||||
})
|
||||
})
|
||||
@@ -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: [] }),
|
||||
mockFetch.mockResolvedValueOnce({ ok: true, json: async () => ({ results: [] }) })
|
||||
expect(await queryRAG('test query')).toBe('')
|
||||
})
|
||||
|
||||
const result = await queryRAG('test query')
|
||||
|
||||
expect(result).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')
|
||||
|
||||
@@ -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<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
|
||||
}
|
||||
@@ -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<string> {
|
||||
try {
|
||||
const body: Record<string, unknown> = {
|
||||
query,
|
||||
top_k: topK,
|
||||
}
|
||||
if (collection) {
|
||||
body.collection = collection
|
||||
}
|
||||
const body: Record<string, unknown> = { 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 || ''}`
|
||||
)
|
||||
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')
|
||||
}
|
||||
return ''
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user