Files
breakpilot-compliance/admin-compliance/lib/sdk/agents/controls-augmentation.ts
T
Benjamin Admin 05bd0418f8 feat(advisor): atom-grain controls in agent context
controls-augmentation now renders control_id + sub_topic + source_regulation
(handles both atom-grain and master-grain API shapes). The compliance-advisor
answers from atom_classification (precise, sub-topic-organized, framework-
traceable) via the shared get_controls_for_use_case API. 100% local at runtime.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-14 09:52:50 +02:00

124 lines
4.0 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 {
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<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 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')}`
}