/** * Controls-Augmentation für den Compliance-Advisor. * * Erkennt aus der Nutzerfrage das Compliance-Thema (Use-Case) und holt die dazu * hinterlegten strukturierten Controls aus der geteilten Backend-API, damit der * Agent aus den ECHTEN Controls antworten kann — nicht nur aus RAG-Gesetzestext. * * Alles lokal: deterministische Erkennung + lokale Postgres-API + lokales Ollama. * KEIN externer LLM-Aufruf zur Laufzeit. */ const BACKEND_URL = process.env.BACKEND_API_URL || 'http://backend-compliance:8002' export interface UseCaseLite { key: string label: string regulations?: string[] mapped_controls?: number } interface ControlLite { control_id?: string master_control_id?: string title: string sub_topic?: string | null source_regulation?: string | null primary_regulation?: string | null } interface ControlsResponse { total?: number granularity?: string controls?: ControlLite[] } function norm(s: string): string { return s.toLowerCase().replace(/[^a-z0-9äöüß ]+/g, ' ') } /** * Deterministische Themen-Erkennung (pure → testbar). Punktet Key-Phrase (3), * Label-Wort exakt/Präfix (2) und Quell-Regulierung (1); Schwelle >=2. * Gleichstand → das stärker befüllte Thema. */ export function detectUseCase( message: string, useCases: UseCaseLite[], ): UseCaseLite | null { const full = norm(message) const words = new Set(full.split(/\s+/).filter((w) => w.length >= 3)) let best: { uc: UseCaseLite; score: number } | null = null for (const uc of useCases) { let score = 0 if (full.includes(uc.key.replace(/_/g, ' '))) score += 3 for (const lw of norm(uc.label).split(/\s+/)) { if (lw.length < 4) continue for (const mw of words) { if (mw === lw) { score += 2; break } if (mw.length >= 5 && lw.length >= 5 && (mw.startsWith(lw) || lw.startsWith(mw))) { score += 2 break } } } for (const r of uc.regulations || []) { if (r.length >= 3 && full.includes(r.toLowerCase())) score += 1 } const better = !best || score > best.score || (score === best.score && (uc.mapped_controls || 0) > (best.uc.mapped_controls || 0)) if (score >= 2 && better) best = { uc, score } } return best ? best.uc : null } async function getJson(path: string): Promise { try { const res = await fetch(`${BACKEND_URL}${path}`, { signal: AbortSignal.timeout(8000), }) if (!res.ok) return null return (await res.json()) as T } catch { return null } } /** * Baut den Controls-Kontext-Block für den System-Prompt — oder '' wenn kein * Thema erkannt wird bzw. das Backend nicht erreichbar ist (graceful degradation * → der Agent fällt auf RAG-only zurück). */ export async function buildControlsContext(message: string): Promise { const useCases = await getJson('/api/compliance/v1/controls/use-cases') if (!useCases || !Array.isArray(useCases)) return '' const uc = detectUseCase(message, useCases) if (!uc) return '' const data = await getJson( `/api/compliance/v1/controls/use-cases/${encodeURIComponent(uc.key)}/controls?limit=15`, ) const controls = data?.controls ?? [] if (!controls.length) return '' const total = data?.total ?? controls.length const lines = controls.map((c, i) => { const cid = c.control_id || c.master_control_id || '?' const sub = c.sub_topic ? ` · ${c.sub_topic}` : '' const src = c.source_regulation || c.primary_regulation const reg = src ? ` — Quelle: ${src}` : '' return `${i + 1}. [${cid}]${sub} ${c.title}${reg}` }) return `## Strukturierte Controls aus der Datenbank — Thema: ${uc.label} Die folgenden ${controls.length} von insgesamt ${total} hinterlegten Controls zu diesem Thema kommen direkt aus der Control-Datenbank (nach Relevanz sortiert). Nutze sie als verbindliche Quelle für konkrete Prüfaspekte/Pflichten und verweise auf die Control-ID. Erfinde KEINE Control-IDs; wirkt die Liste unvollständig, sage das offen. ${lines.join('\n')}` }