Assessor_2.0/tests/compliance/regulatory-hybrid-search.te...

143 lines
11 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest';
import { RegulatoryMappingService } from '../../server/compliance/regulatory-mapping-service';
import { RegulatoryFindingRetrievalService } from '../../server/compliance/regulatory-finding-retrieval';
import { fuseControlRankings, lexicalTokens, RegulatoryLexicalIndex } from '../../server/compliance/regulatory-lexical-index';
import { bsiCorpusVersion } from '../../server/compliance/bsi-corpus';
import type { BsiCatalogs } from '../../server/compliance/bsi-snapshots';
import { LocalComplianceStore } from '../../src/compliance/local-compliance-store';
import type { ComplianceReadPort } from '../../src/compliance/compliance-store';
import { BSI_CORPUS_ID, REGULATORY_RETRIEVAL_VERSION } from '../../src/compliance/regulatory-mapping';
import type { RegulatoryChunk, RegulatoryRetrievalFilters, RegulatoryRetrievalRequest } from '../../src/compliance/regulatory-rag';
import type { UnifiedResultProjection } from '../../src/domain/unified-result';
// Hybrid search (user decision 2026-10-09): dense English and German queries plus German keywords, fused by rank;
// BSI leaves out procurement, service-provider, building and personnel requirements. Synthetic corpus only.
const stamp = '2026-10-09T12:00:00.000Z';
function isoChunk(controlId: string, text: string, sourceStandard: 'ISO/IEC 27001' | 'ISO/IEC 27002' = 'ISO/IEC 27002'): RegulatoryChunk {
const reference = sourceStandard === 'ISO/IEC 27001';
const documentId = `${reference ? '27001' : '27002'}-${controlId}-guidance`;
return { schemaVersion: 2, corpusId: 'iso-test', corpusVersion: 'v2', documentId, framework: 'ISO/IEC 27002', frameworkVersion: '2022',
controlId, annexAControlId: `A.${controlId}`, controlTheme: 'technological', sourceStandard, sourceEdition: '2022', nationalEdition: 'DIN 2024',
sectionKind: reference ? 'control' : 'guidance', sourcePageStart: 1, sourcePageEnd: 1, retrievalRole: reference ? 'reference' : 'semantic',
parserVersion: 'test', sourceSha256: '1'.repeat(64), chunkId: `${documentId}:0`, chunkIndex: 0, language: 'de', title: `Synthetic ${controlId}`,
text, textSha256: 'a'.repeat(64) };
}
describe('keyword index and rank fusion', () => {
it('stems German and English words so inflected forms meet', () => {
expect(lexicalTokens('Berechtigungen der Dienste')).toEqual(lexicalTokens('Berechtigung Dienst'));
expect(lexicalTokens('services')).toEqual(lexicalTokens('service'));
expect(lexicalTokens('die und the RC4')).toEqual(['rc4']);
});
it('ranks by BM25 and fuses lists at control level deterministically', () => {
const chunks = [isoChunk('8.9', 'Nicht benötigte Dienste und Funktionen deaktivieren.'), isoChunk('5.1', 'Richtlinien für Informationssicherheit festlegen.'),
isoChunk('8.24', 'Kryptographische Verfahren und Algorithmen festlegen.')];
const index = new RegulatoryLexicalIndex(chunks);
expect(index.search('Dienste deaktivieren', 10).map(hit => hit.chunk.controlId)).toEqual(['8.9']);
const dense = [{ score: 0.9, chunk: chunks[1] }, { score: 0.8, chunk: chunks[0] }];
const lexical = [{ score: 3, chunk: chunks[0] }, { score: 1, chunk: chunks[2] }];
// 8.9 is second in one list and first in the other; it overtakes 5.1, which only one list returned.
expect(fuseControlRankings([dense, lexical]).map(control => control.controlId)).toEqual(['8.9', '5.1', '8.24']);
expect(fuseControlRankings([lexical, dense]).map(control => control.controlId)).toEqual(['8.9', '5.1', '8.24']);
expect(fuseControlRankings([dense, lexical], id => id === '8.9').map(control => control.controlId)).toEqual(['5.1', '8.24']);
});
});
const finding: UnifiedResultProjection = {
resultProjectionId: 'scanner-finding:dummy', sourceKind: 'scanner-finding', sourceResultId: 'dummy', projectId: 'project-1', assessmentId: 'assessment-1',
collectionRunId: 'run-1', scanRunId: 'run-1', sourceTool: 'purple-knight-ad', sourceRuleId: 'SI000077', title: 'Synthetic print service enabled on a domain controller',
description: 'A synthetic service that is not needed runs on a domain controller.', severityOriginal: 'high', severityNormalized: 'high',
outcomeOriginal: 'fail', outcomeNormalized: 'fail', technicalExecutionState: 'completed', resultType: 'compliance_check', isIoe: false, isDeviation: true,
lifecycleState: 'new', mappingConfidence: 1, reviewStatus: 'unreviewed', importedAt: stamp, createdAt: stamp, frameworkReferences: [], categories: []
};
const read: ComplianceReadPort = {
getProject: async () => ({ id: 'project-1', name: 'Synthetic' }) as never, listProjects: async () => [] as never,
listAssessments: async () => [] as never, listProjectScanRuns: async () => [] as never,
queryUnifiedResults: async () => ({ total: 0, items: [] }) as never, getUnifiedResultById: async () => finding
};
const requirement = (id: string, practice: string) => ({ id, title: `Synthetische Anforderung ${id}`, practice, statement: `Synthetische Aussage ${id}.` });
const catalogs: BsiCatalogs = {
catalog: { schemaVersion: 1, framework: 'bsi-grundschutz-plus-plus', catalogClass: 'BSI-Stand-der-Technik-Kernel-G0', title: 'Synthetic',
version: '2026-01-01T00:00:00+00:00', sourceUrl: 'https://example.invalid/catalog', sourceSha256: 'c'.repeat(64), license: 'Synthetic',
practices: [{ id: 'KONF', title: 'Konfiguration' }, { id: 'DLS', title: 'Dienstleister' }],
requirements: [requirement('KONF.2.4', 'KONF'), requirement('KONF.2.1', 'KONF'), requirement('DLS.3.2', 'DLS')] },
crosswalk: { schemaVersion: 1, framework: 'bsi-iso27001-annex-a-to-grundschutz-plus-plus', title: 'Synthetic', version: '0.1', sourceUrl: 'https://example.invalid/m',
sourceSha256: 'd'.repeat(64), license: 'Synthetic', targetCatalog: 'synthetic', entries: [] }
};
const bsiVersion = bsiCorpusVersion(catalogs.catalog);
const bsiChunk = (controlId: string, text: string): RegulatoryChunk => ({ schemaVersion: 1, corpusId: BSI_CORPUS_ID, corpusVersion: bsiVersion,
documentId: `bsi-kernel:${controlId}`, framework: 'BSI Grundschutz++', frameworkVersion: catalogs.catalog.version, controlId, chunkId: `bsi-kernel:${controlId}:0`,
chunkIndex: 0, language: 'de', title: `Synthetische Anforderung ${controlId}`, text, textSha256: 'b'.repeat(64) });
function setup(expansion: () => Promise<unknown>) {
const isoCorpus = [isoChunk('5.1', 'Richtlinien festlegen.'), isoChunk('8.2', 'Privilegierte Zugangsrechte einschränken.'),
isoChunk('8.9', 'Nicht benötigte Dienste deaktivieren.')];
const bsiCorpus = [bsiChunk('DLS.3.2', 'Dienstleister überprüfen.'), bsiChunk('KONF.2.1', 'Grundkonfiguration dokumentieren.'),
bsiChunk('KONF.2.4', 'Nicht benötigte Systemfunktionen und Dienste deaktivieren.')];
const isBsi = (filters?: RegulatoryRetrievalFilters) => filters?.corpusId === BSI_CORPUS_ID;
// English query: 5.1 / DLS.3.2 only. German query: 8.2 / KONF.2.1. Keywords find 8.9 / KONF.2.4.
const retrieve = vi.fn(async (request: RegulatoryRetrievalRequest) => {
const german = request.query.startsWith('Deaktivierung');
const pick = isBsi(request.filters) ? (german ? bsiCorpus[1] : bsiCorpus[0]) : (german ? isoCorpus[1] : isoCorpus[0]);
return [{ score: 0.9, chunk: pick }];
});
const listAll = vi.fn(async (filters: RegulatoryRetrievalFilters) => isBsi(filters) ? bsiCorpus : isoCorpus);
const list = vi.fn(async (filters: RegulatoryRetrievalFilters) => isBsi(filters)
? bsiCorpus.filter(item => item.controlId === filters.controlId)
: filters.sourceStandard === 'ISO/IEC 27001' ? [isoChunk(filters.controlId!, 'Synthetic statement.', 'ISO/IEC 27001')]
: isoCorpus.filter(item => item.controlId === filters.controlId));
const complete = vi.fn(async (_system: string, input: string) => {
const candidates = JSON.parse(input).candidates as Array<{ controlId: string; evidence: Array<{ chunkId: string }> }>;
return { value: { schemaVersion: 1, outcome: 'mapped', mappings: [{ controlId: candidates[0].controlId, relationship: 'primary', confidence: 'high',
rationale: 'Synthetic rationale.', evidenceChunkIds: [candidates[0].evidence.at(-1)!.chunkId] }] } };
});
const expand = vi.fn(async () => ({ value: await expansion() }));
const svc = new RegulatoryMappingService(read, new LocalComplianceStore(), {
findingRetrieval: new RegulatoryFindingRetrievalService({ retrieve }),
index: { ensureCollection: vi.fn(), replaceDocuments: vi.fn(), searchDense: vi.fn(), list, listAll } as never,
client: { model: 'work-model', complete: complete as never }, queryExpansionClient: { model: 'work-model', complete: expand as never },
corpusId: 'iso-test', corpusVersion: 'v2', embeddingModel: 'bge-m3', now: () => new Date(stamp),
bsiCatalogs: async () => catalogs, bsiCorpus: { configured: true, ensure: vi.fn(async () => bsiVersion) } as never
});
return { svc, expand, retrieve, listAll };
}
const germanRestatement = { query: 'Deaktivierung nicht benötigter Dienste auf Domänencontrollern', terms: ['Dienste deaktivieren', 'Systemfunktionen', 'Härtung'] };
describe('hybrid search in the mapping pipeline', () => {
it('fuses the English, German and keyword lists, records the restatement and shares it between ISO and BSI', async () => {
const { svc, expand, listAll } = setup(async () => germanRestatement);
await svc.start('project-1', { resultProjectionId: finding.resultProjectionId });
await svc.start('project-1', { resultProjectionId: finding.resultProjectionId, framework: 'bsi-kernel' });
await svc.close();
const iso = (await svc.get('project-1', finding.resultProjectionId)).run!;
const bsi = (await svc.get('project-1', finding.resultProjectionId, 'bsi-kernel')).run!;
expect(iso.retrievalVersion).toBe(REGULATORY_RETRIEVAL_VERSION);
// Each list contributes its control; the keyword search finds the control the dense searches miss.
expect(iso.candidates.map(candidate => candidate.controlId).sort()).toEqual(['5.1', '8.2', '8.9']);
expect(iso.preparedQuery.expansion).toMatchObject({ language: 'de', query: germanRestatement.query, searches: ['dense-en', 'dense-de', 'keyword-de'] });
// DLS (service providers) is left out for a technical finding even though the English search ranked it first.
expect(bsi.candidates.map(candidate => candidate.controlId).sort()).toEqual(['KONF.2.1', 'KONF.2.4']);
expect(expand).toHaveBeenCalledTimes(1);
expect(listAll).toHaveBeenCalledTimes(2);
});
it('falls back to the English dense search when the restatement fails, and retries it for the next run', async () => {
let calls = 0;
const { svc, expand } = setup(async () => (calls++ === 0 ? { unexpected: true } : germanRestatement));
await svc.start('project-1', { resultProjectionId: finding.resultProjectionId });
await svc.close();
const first = (await svc.get('project-1', finding.resultProjectionId)).run!;
expect(first.status).toBe('succeeded');
expect(first.preparedQuery.expansion).toBeUndefined();
expect(first.candidates.map(candidate => candidate.controlId)).toEqual(['5.1']);
await svc.start('project-1', { resultProjectionId: finding.resultProjectionId, framework: 'bsi-kernel' });
await svc.close();
expect(expand).toHaveBeenCalledTimes(2);
expect((await svc.get('project-1', finding.resultProjectionId, 'bsi-kernel')).run!.preparedQuery.expansion?.query).toBe(germanRestatement.query);
});
});