fix(advisor): Compliance-Advisor auf prod reparieren — RAG via ai-sdk (bge-m3) + OVH-LLM
CI / detect-changes (push) Successful in 6s
CI / branch-name (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 / guardrail-integrity (push) Has been skipped
CI / build-sha-integrity (push) Successful in 7s
CI / validate-canonical-controls (push) Successful in 6s
CI / loc-budget (push) Successful in 19s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Successful in 3m4s
CI / test-go (push) Successful in 58s
CI / iace-gt-coverage (push) Successful in 16s
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 6s
CI / branch-name (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 / guardrail-integrity (push) Has been skipped
CI / build-sha-integrity (push) Successful in 7s
CI / validate-canonical-controls (push) Successful in 6s
CI / loc-budget (push) Successful in 19s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Successful in 3m4s
CI / test-go (push) Successful in 58s
CI / iace-gt-coverage (push) Successful in 16s
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
Der Floating-Compliance-Advisor war auf prod kaputt (502): RAG ging ueber rag-service:8097 (auf prod nicht vorhanden) und der Chat ueber OLLAMA_URL=ollama-embed (embedding-only, kein qwen2.5vl). - RAG laeuft jetzt ueber die ai-compliance-sdk /sdk/v1/rag/search (bge-m3, prod-erreichbar) statt rag-service -> profitiert vom reicheren Embedding. (lib/sdk/agents/advisor-rag.ts) - LLM-Kaskade: OVH/LiteLLM (gpt-oss-120b) zuerst, Ollama als Dev-Fallback. (lib/sdk/agents/advisor-llm.ts; OVH-Env via orca-infra admin-Block) - ai-sdk: bp_compliance_recht in AllowedCollections ergaenzt (Whitelist war inkonsistent — die Fehlermeldung listete es bereits als erlaubt). - Route auf die Module umgestellt (duenn); Controls-Augmentation unveraendert. - Tests: advisor-rag + advisor-llm. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Tests fuer die LLM-Stream-Parser des Advisors (Ollama-NDJSON + OVH/OpenAI-SSE).
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { parseOllamaLine, parseSSELine } from '../advisor-llm'
|
||||
|
||||
describe('parseOllamaLine', () => {
|
||||
it('extrahiert message.content', () => {
|
||||
expect(parseOllamaLine('{"message":{"content":"Hallo"}}')).toBe('Hallo')
|
||||
})
|
||||
it('ignoriert leere/kaputte Zeilen', () => {
|
||||
expect(parseOllamaLine('')).toBeNull()
|
||||
expect(parseOllamaLine(' ')).toBeNull()
|
||||
expect(parseOllamaLine('not-json')).toBeNull()
|
||||
expect(parseOllamaLine('{"message":{}}')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseSSELine', () => {
|
||||
it('extrahiert choices[0].delta.content aus data:-Zeilen', () => {
|
||||
expect(parseSSELine('data: {"choices":[{"delta":{"content":"Hi"}}]}')).toBe('Hi')
|
||||
})
|
||||
it('ignoriert [DONE], Nicht-data-Zeilen und kaputtes JSON', () => {
|
||||
expect(parseSSELine('data: [DONE]')).toBeNull()
|
||||
expect(parseSSELine('event: message')).toBeNull()
|
||||
expect(parseSSELine('')).toBeNull()
|
||||
expect(parseSSELine('data: {bad json')).toBeNull()
|
||||
expect(parseSSELine('data: {"choices":[{"delta":{}}]}')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Tests fuer die Advisor-RAG-Suche (ai-sdk, bge-m3).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
|
||||
const mockFetch = vi.fn()
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
|
||||
describe('advisor-rag', () => {
|
||||
let mod: typeof import('../advisor-rag')
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules()
|
||||
mockFetch.mockReset()
|
||||
mod = await import('../advisor-rag')
|
||||
})
|
||||
|
||||
describe('mapSdkResults', () => {
|
||||
it('mappt ai-sdk-Felder auf {content, source, score}', () => {
|
||||
const out = mod.mapSdkResults([
|
||||
{ text: 'Art. 35 DSGVO ...', regulation_short: 'DSGVO', score: 0.91 },
|
||||
])
|
||||
expect(out).toEqual([{ content: 'Art. 35 DSGVO ...', source: 'DSGVO', score: 0.91 }])
|
||||
})
|
||||
|
||||
it('faellt auf regulation_name/code zurueck und filtert leere Inhalte', () => {
|
||||
const out = mod.mapSdkResults([
|
||||
{ text: '', regulation_short: 'X' },
|
||||
{ text: 'Inhalt', regulation_name: 'BDSG' },
|
||||
{ text: 'Inhalt2', regulation_code: 'EU_2016_679' },
|
||||
])
|
||||
expect(out).toEqual([
|
||||
{ content: 'Inhalt', source: 'BDSG', score: 0 },
|
||||
{ content: 'Inhalt2', source: 'EU_2016_679', score: 0 },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('queryAdvisorRAG', () => {
|
||||
it('fragt alle 6 Collections ab und formatiert die Treffer', async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ results: [{ text: 'Inhalt A', regulation_short: 'DSGVO', score: 0.9 }] }),
|
||||
})
|
||||
const result = await mod.queryAdvisorRAG('Was ist eine DSFA?')
|
||||
expect(result).toContain('[Quelle 1: DSGVO]')
|
||||
expect(result).toContain('Inhalt A')
|
||||
expect(mockFetch).toHaveBeenCalledTimes(mod.COMPLIANCE_COLLECTIONS.length)
|
||||
})
|
||||
|
||||
it('ruft die ai-sdk /sdk/v1/rag/search mit collection + top_k auf', async () => {
|
||||
mockFetch.mockResolvedValue({ ok: true, json: async () => ({ results: [] }) })
|
||||
await mod.queryAdvisorRAG('test')
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/sdk/v1/rag/search'),
|
||||
expect.objectContaining({ method: 'POST' }),
|
||||
)
|
||||
const body = JSON.parse(mockFetch.mock.calls[0][1].body)
|
||||
expect(body).toMatchObject({ query: 'test', top_k: 3 })
|
||||
expect(mod.COMPLIANCE_COLLECTIONS).toContain(body.collection)
|
||||
})
|
||||
|
||||
it('liefert leeren String wenn das RAG-Backend nicht erreichbar ist (graceful)', async () => {
|
||||
mockFetch.mockRejectedValue(new Error('connection refused'))
|
||||
const result = await mod.queryAdvisorRAG('test')
|
||||
expect(result).toBe('')
|
||||
})
|
||||
|
||||
it('umfasst genau die 6 Compliance-Collections', () => {
|
||||
expect(mod.COMPLIANCE_COLLECTIONS).toHaveLength(6)
|
||||
expect(mod.COMPLIANCE_COLLECTIONS).toContain('bp_compliance_recht')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Compliance-Advisor RAG-Suche.
|
||||
*
|
||||
* Fragt die ai-compliance-sdk (`/sdk/v1/rag/search`) ab statt des frueheren
|
||||
* `rag-service:8097` (auf prod nicht erreichbar). Die ai-sdk embeddet die Query
|
||||
* mit bge-m3 (prod: ollama-embed) und sucht in den Qdrant-Compliance-Collections
|
||||
* — damit profitiert der Advisor vom reicheren Embedding.
|
||||
*
|
||||
* Fehler je Collection werden geschluckt (graceful: Antwort ohne diesen Treffer).
|
||||
*/
|
||||
|
||||
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'
|
||||
|
||||
// Compliance-relevante Collections (ai-sdk-Whitelist `AllowedCollections`).
|
||||
export const COMPLIANCE_COLLECTIONS = [
|
||||
'bp_compliance_gesetze',
|
||||
'bp_compliance_ce',
|
||||
'bp_compliance_datenschutz',
|
||||
'bp_dsfa_corpus',
|
||||
'bp_compliance_recht',
|
||||
'bp_legal_templates',
|
||||
] as const
|
||||
|
||||
interface SdkRagResult {
|
||||
text?: string
|
||||
regulation_code?: string
|
||||
regulation_name?: string
|
||||
regulation_short?: string
|
||||
category?: string
|
||||
source_url?: string
|
||||
score?: number
|
||||
}
|
||||
|
||||
interface ScoredPassage {
|
||||
content: string
|
||||
source: string
|
||||
score: number
|
||||
}
|
||||
|
||||
/** Normalisiert eine ai-sdk-RAG-Antwort auf {content, source, score}. */
|
||||
export function mapSdkResults(results: SdkRagResult[] | undefined): ScoredPassage[] {
|
||||
return (results || [])
|
||||
.map((r) => ({
|
||||
content: r.text || '',
|
||||
source: r.regulation_short || r.regulation_name || r.regulation_code || 'Unbekannt',
|
||||
score: typeof r.score === 'number' ? r.score : 0,
|
||||
}))
|
||||
.filter((p) => p.content)
|
||||
}
|
||||
|
||||
async function searchCollection(collection: string, query: string): Promise<ScoredPassage[]> {
|
||||
try {
|
||||
const res = await fetch(`${SDK_URL}/sdk/v1/rag/search`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-User-ID': DEFAULT_USER,
|
||||
'X-Tenant-ID': DEFAULT_TENANT,
|
||||
},
|
||||
body: JSON.stringify({ query, collection, top_k: 3 }),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
})
|
||||
if (!res.ok) return []
|
||||
const data = await res.json()
|
||||
return mapSdkResults(data.results)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fragt alle Compliance-Collections parallel ab und liefert die Top-8-Passagen
|
||||
* als formatierten Kontextblock (oder '' wenn nichts erreichbar/gefunden).
|
||||
*/
|
||||
export async function queryAdvisorRAG(query: string): Promise<string> {
|
||||
const settled = await Promise.all(
|
||||
COMPLIANCE_COLLECTIONS.map((c) => searchCollection(c, query)),
|
||||
)
|
||||
const all = settled.flat()
|
||||
if (all.length === 0) return ''
|
||||
all.sort((a, b) => b.score - a.score)
|
||||
return all
|
||||
.slice(0, 8)
|
||||
.map((r, i) => `[Quelle ${i + 1}: ${r.source}]\n${r.content}`)
|
||||
.join('\n\n---\n\n')
|
||||
}
|
||||
Reference in New Issue
Block a user