7f03ffadcc
Prompt-augments the RAG-only advisor with the shared use-case->controls API: deterministic topic detection -> local controls API -> context block, so the agent can answer from real Control-IDs. 100% local at runtime (no Anthropic). NOT pushed/deployed: the shared API currently returns MASTER-grain controls, whose composition is broken (gpre2 object-only clustering -> mega-clusters). Pending the atom-grain rework of the API. tsc + vitest green. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
118 lines
3.8 KiB
TypeScript
118 lines
3.8 KiB
TypeScript
/**
|
|
* 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<T>(path: string): Promise<T | null> {
|
|
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<string> {
|
|
const useCases = await getJson<UseCaseLite[]>('/api/compliance/v1/controls/use-cases')
|
|
if (!useCases || !Array.isArray(useCases)) return ''
|
|
|
|
const uc = detectUseCase(message, useCases)
|
|
if (!uc) return ''
|
|
|
|
const data = await getJson<ControlsResponse>(
|
|
`/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')}`
|
|
}
|