import type { RegulatoryControlDocument } from '../../src/compliance/regulatory-rag'; import { BSI_CORPUS_ID, BSI_SOURCE_STANDARD } from '../../src/compliance/regulatory-mapping'; import { bsiRequirementText, type BsiKernelCatalog } from '../../src/compliance/bsi-grundschutz'; import type { BsiSearchIndexStatus } from '../../src/compliance/bsi-catalog-management'; import type { BsiCatalogs } from './bsi-snapshots'; import type { RegulatoryIngestionService } from './regulatory-rag-service'; export interface BsiCorpusOptions { catalogs: () => Promise; ingestion?: RegulatoryIngestionService; countIndexed?: (corpusId: string, corpusVersion: string) => Promise; pruneIndexed?: (corpusId: string, keepCorpusVersion: string) => Promise; /** Chunks of every catalog state, so the Data Hub can tell an outdated index from a missing one. */ countCorpus?: (corpusId: string) => Promise; } /** The corpus version of a Kernel catalog: its date and the start of its source hash, so every catalog state indexes once. */ export function bsiCorpusVersion(catalog: Pick): string { return `kernel-${catalog.version.slice(0, 10)}-${catalog.sourceSha256.slice(0, 12)}`; } /** * One retrieval document per Kernel requirement: its statement and guidance with the parameters filled in, in the * published German. The BSI text may be indexed and shown under CC BY-SA 4.0, unlike the ISO text. */ export function bsiCorpusDocuments(catalog: BsiKernelCatalog): RegulatoryControlDocument[] { const corpusVersion = bsiCorpusVersion(catalog); return catalog.requirements.map(requirement => ({ schemaVersion: 1, corpusId: BSI_CORPUS_ID, corpusVersion, documentId: `bsi-kernel:${requirement.id}`, framework: BSI_SOURCE_STANDARD, frameworkVersion: catalog.version, controlId: requirement.id, language: 'de', title: requirement.title, text: [bsiRequirementText(requirement.statement, requirement.params), requirement.guidance ? bsiRequirementText(requirement.guidance, requirement.params) : ''].filter(Boolean).join('\n\n'), sourceUri: catalog.sourceUrl })); } /** * The BSI Kernel corpus in the shared Qdrant/BGE-M3 path under its own corpus id (Phase 13F, BSI stage 2). It is * built from the active catalog from the Data Hub BSI tile or, when nobody did, when a BSI mapping first needs it, * once per catalog state; older catalog states are pruned afterwards. Concurrent callers share one build. */ export class BsiCorpus { private building?: { corpusVersion: string; task: Promise; done: boolean; progress?: { completed: number; total: number } }; private failure?: { corpusVersion: string; error: string }; private builtAt?: { corpusVersion: string; at: string }; constructor(private readonly options: BsiCorpusOptions) {} get configured(): boolean { return Boolean(this.options.ingestion && this.options.countIndexed); } async corpusVersion(): Promise { return bsiCorpusVersion((await this.options.catalogs()).catalog); } /** The indexed corpus version of the active catalog, building it first when it is missing. */ async ensure(): Promise { if (!this.configured) throw new Error('Embedding or Qdrant is not configured.'); const { catalog } = await this.options.catalogs(); const corpusVersion = bsiCorpusVersion(catalog); if (this.building?.corpusVersion !== corpusVersion) this.track(catalog, corpusVersion, false); return this.building!.task; } /** * Starts building the index of the active catalog from the Data Hub (user decision 2026-10-09) without waiting for * it; `force` embeds every requirement again even when the index is complete. A running build is not started twice. */ async startBuild(force: boolean): Promise { if (!this.configured) return this.status(); const { catalog } = await this.options.catalogs(); const corpusVersion = bsiCorpusVersion(catalog); if (!(this.building?.corpusVersion === corpusVersion && !this.building.done)) this.track(catalog, corpusVersion, force).catch(() => undefined); return this.status(); } async status(): Promise { const { catalog } = await this.options.catalogs(); const corpusVersion = bsiCorpusVersion(catalog); const base = { version: 1 as const, catalogVersion: catalog.version, corpusVersion, requirementCount: catalog.requirements.length }; if (!this.configured) return { ...base, state: 'unavailable', indexedChunks: 0, disabledReason: 'Embedding or Qdrant is not configured.' }; const indexedChunks = await this.options.countIndexed!(BSI_CORPUS_ID, corpusVersion).catch(() => 0); const building = this.building?.corpusVersion === corpusVersion ? this.building : undefined; if (building && !building.done) return { ...base, state: 'building', indexedChunks, ...(building.progress ? { progress: { ...building.progress } } : {}) }; if (this.failure?.corpusVersion === corpusVersion) return { ...base, state: 'failed', indexedChunks, error: this.failure.error }; if (indexedChunks >= catalog.requirements.length) { return { ...base, state: 'built', indexedChunks, ...(this.builtAt?.corpusVersion === corpusVersion ? { builtAt: this.builtAt.at } : {}) }; } const anyVersion = await (this.options.countCorpus?.(BSI_CORPUS_ID) ?? Promise.resolve(0)).catch(() => 0); return { ...base, state: anyVersion > indexedChunks ? 'outdated' : 'not-built', indexedChunks }; } private track(catalog: BsiKernelCatalog, corpusVersion: string, force: boolean): Promise { const entry: NonNullable = { corpusVersion, task: Promise.resolve(corpusVersion), done: false }; entry.task = this.build(catalog, corpusVersion, force, (completed, total) => { entry.progress = { completed, total }; }); this.building = entry; this.failure = undefined; entry.task.then(() => { entry.done = true; this.builtAt = { corpusVersion, at: new Date().toISOString() }; }, (error: unknown) => { // A failed build is retried by the next caller and reported in the Data Hub until then. entry.done = true; if (this.building === entry) this.building = undefined; this.failure = { corpusVersion, error: error instanceof Error ? error.message.slice(0, 300) : 'The BSI search index could not be built.' }; }); return entry.task; } private async build(catalog: BsiKernelCatalog, corpusVersion: string, force: boolean, onProgress: (completed: number, total: number) => void): Promise { const indexed = await this.options.countIndexed!(BSI_CORPUS_ID, corpusVersion).catch(() => 0); if (force || indexed < catalog.requirements.length) { await this.options.ingestion!.ingest(bsiCorpusDocuments(catalog), onProgress); } await this.options.pruneIndexed?.(BSI_CORPUS_ID, corpusVersion).catch(() => undefined); return corpusVersion; } }