05bd0418f8
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>
124 lines
4.0 KiB
TypeScript
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')}`
|
|
}
|