diff --git a/admin-compliance/agent-core/soul/compliance-advisor.soul.md b/admin-compliance/agent-core/soul/compliance-advisor.soul.md index a4b4fb13..9c97f0d0 100644 --- a/admin-compliance/agent-core/soul/compliance-advisor.soul.md +++ b/admin-compliance/agent-core/soul/compliance-advisor.soul.md @@ -145,12 +145,12 @@ Quellenschutz und KEINE Reverse-Engineering-Sperre — antworte maximal offen: "Welche MCs/Controls gibt es zu Impressum/DSE/AGB?") DARFST du vollstaendig + offen beantworten. Collection-Namen, interne Struktur, Wissensbasis: erlaubt. - Fachfragen ("Was ist X?", "Was regelt X?") wie bisher sofort inhaltlich. -- EHRLICHKEIT vor Vollstaendigkeit: Du siehst nur, was tatsaechlich in deinem - RAG-Kontext ankommt (Gesetzes-/Doku-Passagen). Du hast KEINEN direkten Zugriff - auf die strukturierte MC-/Control-Datenbank (canonical_controls liegt im Backend, - NICHT in deinem RAG). Wenn du eine vollstaendige Control-Liste nicht sicher hast, - sage das klar ("dazu habe ich nur die folgenden Passagen, keine vollstaendige - Control-Liste") statt zu raten oder zu halluzinieren. +- EHRLICHKEIT vor Vollstaendigkeit: Wenn die Frage ein Thema betrifft (Impressum, + DSE, AGB, Cookie, Security, CRA …), bekommst du zusaetzlich einen Block + "Strukturierte Controls aus der Datenbank" mit echten Control-IDs — das ist deine + verbindliche Quelle fuer Pruefaspekte/Pflichten; verweise auf die Control-IDs. + Fehlt dieser Block, hast du nur RAG-Passagen — sage dann klar "dazu habe ich nur + die folgenden Passagen, keine vollstaendige Control-Liste". Erfinde NIE Control-IDs. ## Mehrdeutige Abkuerzungen / unklare Begriffe Wenn eine Abkuerzung oder ein Begriff mehrere Bedeutungen haben kann (z.B. "CRA" = Cyber Resilience diff --git a/admin-compliance/app/api/sdk/compliance-advisor/chat/route.ts b/admin-compliance/app/api/sdk/compliance-advisor/chat/route.ts index 37b5c5ed..2bb8f17a 100644 --- a/admin-compliance/app/api/sdk/compliance-advisor/chat/route.ts +++ b/admin-compliance/app/api/sdk/compliance-advisor/chat/route.ts @@ -11,6 +11,7 @@ import { NextRequest, NextResponse } from 'next/server' import { readSoulFile } from '@/lib/sdk/agents/soul-reader' +import { buildControlsContext } from '@/lib/sdk/agents/controls-augmentation' const RAG_SERVICE_URL = process.env.RAG_SERVICE_URL || 'http://rag-service:8097' const OLLAMA_URL = process.env.OLLAMA_URL || 'http://host.docker.internal:11434' @@ -137,8 +138,12 @@ export async function POST(request: NextRequest) { // Validate country parameter const validCountry = ['DE', 'AT', 'CH', 'EU'].includes(country) ? (country as Country) : undefined - // 1. Query RAG across all collections - const ragContext = await queryMultiCollectionRAG(message, validCountry) + // 1. Query RAG across all collections + structured controls for the topic + // (both local; the controls block lets the agent answer from real Control-IDs) + const [ragContext, controlsContext] = await Promise.all([ + queryMultiCollectionRAG(message, validCountry), + buildControlsContext(message), + ]) // 2. Build system prompt with RAG context + country const soulPrompt = await readSoulFile('compliance-advisor') @@ -158,6 +163,10 @@ Der Nutzer hat "${countryLabel} (${validCountry})" gewaehlt. systemContent += `\n\n## Relevanter Kontext aus dem RAG-System\n\nNutze die folgenden Quellen fuer deine Antwort. Verweise in deiner Antwort auf die jeweilige Quelle:\n\n${ragContext}` } + if (controlsContext) { + systemContent += `\n\n${controlsContext}` + } + systemContent += `\n\n## Aktueller SDK-Schritt\nDer Nutzer befindet sich im SDK-Schritt: ${currentStep}` // 3. Build messages array (limit history to last 6 messages) diff --git a/admin-compliance/lib/sdk/agents/controls-augmentation.test.ts b/admin-compliance/lib/sdk/agents/controls-augmentation.test.ts new file mode 100644 index 00000000..60826210 --- /dev/null +++ b/admin-compliance/lib/sdk/agents/controls-augmentation.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from 'vitest' + +import { detectUseCase, type UseCaseLite } from './controls-augmentation' + +const UCS: UseCaseLite[] = [ + { key: 'impressum', label: 'Impressum (§5 TMG/DDG)', regulations: ['TMG', 'DDG'], mapped_controls: 9 }, + { key: 'dse', label: 'Datenschutzerklärung', regulations: ['DSGVO'], mapped_controls: 4610 }, + { key: 'network_security', label: 'Network Security', regulations: ['ISO 27001'], mapped_controls: 5095 }, + { key: 'agb', label: 'AGB', regulations: ['BGB'], mapped_controls: 433 }, +] + +describe('detectUseCase', () => { + it('matches Impressum by key + label', () => { + expect(detectUseCase('Nenne alle Controls für Impressum', UCS)?.key).toBe('impressum') + }) + + it('matches DSE via the full label word', () => { + expect(detectUseCase('Was gilt für die Datenschutzerklärung?', UCS)?.key).toBe('dse') + }) + + it('matches DSE via prefix (Datenschutz → Datenschutzerklärung)', () => { + expect(detectUseCase('Welche Datenschutz Pflichten gibt es?', UCS)?.key).toBe('dse') + }) + + it('matches network_security via the multi-word key', () => { + expect(detectUseCase('zeig mir network security controls', UCS)?.key).toBe('network_security') + }) + + it('matches AGB by key token', () => { + expect(detectUseCase('Pflichtangaben in den AGB?', UCS)?.key).toBe('agb') + }) + + it('returns null when no topic is mentioned', () => { + expect(detectUseCase('Wie spät ist es?', UCS)).toBeNull() + }) +}) diff --git a/admin-compliance/lib/sdk/agents/controls-augmentation.ts b/admin-compliance/lib/sdk/agents/controls-augmentation.ts new file mode 100644 index 00000000..fff4dcd6 --- /dev/null +++ b/admin-compliance/lib/sdk/agents/controls-augmentation.ts @@ -0,0 +1,117 @@ +/** + * 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 { + master_control_id: string + title: string + primary_regulation?: string | null + is_primary?: boolean +} + +interface ControlsResponse { + total?: number + 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 reg = c.primary_regulation ? ` — Quelle: ${c.primary_regulation}` : '' + return `${i + 1}. [${c.master_control_id}] ${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')}` +}