// Regulation mapping — configuration, capability model and runtime factory. // // This is the "ingestion + retrieval" slice: it consumes a Qdrant collection of // embedded regulation-catalog chunks and, for a given finding definition, // retrieves the best-matching atomic regulation units. It performs no LLM // selection and persists nothing. The feature stays DISABLED unless an internal // Qdrant endpoint and an OpenAI-compatible embedding endpoint are configured. // // Security: every call is backend-only, aimed at the explicitly configured // internal endpoints. Query text, catalog text and vector values are never // logged. Only ids, counts, versions, durations and safe error codes are safe // to surface. import type { RegulationDefinitionInput, RegulationRetrievalResult, RegulationCapability, RegulationCapabilityReport } from './regulation-retrieval-types'; export type { RegulationDefinitionInput, RegulationRetrievalResult, RegulationCapability, RegulationCapabilityReport } from './regulation-retrieval-types'; export interface RegulationMappingConfig { qdrantUrl: string; qdrantApiKey?: string; collection: string; embeddingBaseUrl: string; embeddingModel: string; embeddingApiKey?: string; embeddingDimensions: number; catalogVersion: string; queryPrefix?: string; passagePrefix?: string; requestTimeoutMs: number; retrievalChunkLimit: number; candidateUnitLimit: number; maximumQueryCharacters: number; } export type RegulationMappingConfigResult = | { state: 'disabled' } | { state: 'error'; detailCode: string; message: string } | { state: 'ready'; config: RegulationMappingConfig }; /** Thrown by retrieve() when the feature is not in a ready state. */ export class RegulationMappingUnavailableError extends Error { constructor(readonly report: RegulationCapabilityReport) { super('regulation mapping unavailable'); this.name = 'RegulationMappingUnavailableError'; } } /** Runtime contract used by the HTTP layer. Two implementations exist: a static * disabled/misconfigured stub, and the real retrieval service. */ export interface RegulationMapping { /** Last known capability without performing any network call. */ cachedCapability(): RegulationCapabilityReport; /** Validate the live backend state and refresh the cached capability. */ refreshCapability(): Promise; /** Retrieve candidate regulation units; throws RegulationMappingUnavailableError when not ready. */ retrieve(input: RegulationDefinitionInput): Promise; } export interface RegulationMappingEnvironment { ASSESSOR_QDRANT_URL?: string; ASSESSOR_QDRANT_API_KEY?: string; ASSESSOR_QDRANT_COLLECTION?: string; ASSESSOR_EMBEDDING_BASE_URL?: string; ASSESSOR_EMBEDDING_MODEL?: string; ASSESSOR_EMBEDDING_API_KEY?: string; ASSESSOR_EMBEDDING_DIMENSIONS?: string; ASSESSOR_EMBEDDING_QUERY_PREFIX?: string; ASSESSOR_EMBEDDING_PASSAGE_PREFIX?: string; ASSESSOR_REGMAP_CATALOG_VERSION?: string; ASSESSOR_REGMAP_TIMEOUT_MS?: string; ASSESSOR_REGMAP_CHUNK_LIMIT?: string; ASSESSOR_REGMAP_CANDIDATE_LIMIT?: string; ASSESSOR_REGMAP_MAX_QUERY_CHARS?: string; } function trimmed(value: string | undefined): string | undefined { const result = value?.trim(); return result ? result : undefined; } function boundedInteger( value: string | undefined, fallback: number, minimum: number, maximum: number, name: string ): number { if (value === undefined || value.trim() === '') return fallback; const parsed = Number(value); if (!Number.isInteger(parsed) || parsed < minimum || parsed > maximum) { throw new Error(`${name} muss eine Ganzzahl zwischen ${minimum} und ${maximum} sein.`); } return parsed; } function assertUrl(value: string, name: string): string { let url: URL; try { url = new URL(value); } catch { throw new Error(`${name} ist keine gültige URL.`); } if (url.protocol !== 'http:' && url.protocol !== 'https:') { throw new Error(`${name} muss http:// oder https:// verwenden.`); } return value.replace(/\/+$/, ''); } const DEFAULT_COLLECTION = 'regulation_catalog_v1'; /** * Parse the regulation-mapping configuration from environment variables. * - The embedding base URL is the opt-in signal: without it the feature is * intentionally disabled (the Qdrant URL may be pre-wired by deployment * compose and is harmless on its own). * - When the embedding endpoint is set but other required values are missing or * invalid, the feature is misconfigured (error) rather than silently off. */ export function readRegulationMappingConfig( env: RegulationMappingEnvironment = process.env as RegulationMappingEnvironment ): RegulationMappingConfigResult { const embeddingBaseUrl = trimmed(env.ASSESSOR_EMBEDDING_BASE_URL); if (!embeddingBaseUrl) return { state: 'disabled' }; const qdrantUrl = trimmed(env.ASSESSOR_QDRANT_URL); try { if (!qdrantUrl) throw new Error('ASSESSOR_QDRANT_URL ist erforderlich.'); const embeddingModel = trimmed(env.ASSESSOR_EMBEDDING_MODEL); if (!embeddingModel) throw new Error('ASSESSOR_EMBEDDING_MODEL ist erforderlich.'); const catalogVersion = trimmed(env.ASSESSOR_REGMAP_CATALOG_VERSION); if (!catalogVersion) throw new Error('ASSESSOR_REGMAP_CATALOG_VERSION ist erforderlich.'); const embeddingDimensions = boundedInteger( env.ASSESSOR_EMBEDDING_DIMENSIONS, 0, 8, 8192, 'ASSESSOR_EMBEDDING_DIMENSIONS' ); if (embeddingDimensions === 0) throw new Error('ASSESSOR_EMBEDDING_DIMENSIONS ist erforderlich.'); const config: RegulationMappingConfig = { qdrantUrl: assertUrl(qdrantUrl, 'ASSESSOR_QDRANT_URL'), qdrantApiKey: trimmed(env.ASSESSOR_QDRANT_API_KEY), collection: trimmed(env.ASSESSOR_QDRANT_COLLECTION) ?? DEFAULT_COLLECTION, embeddingBaseUrl: assertUrl(embeddingBaseUrl, 'ASSESSOR_EMBEDDING_BASE_URL'), embeddingModel, embeddingApiKey: trimmed(env.ASSESSOR_EMBEDDING_API_KEY), embeddingDimensions, catalogVersion, queryPrefix: trimmed(env.ASSESSOR_EMBEDDING_QUERY_PREFIX), passagePrefix: trimmed(env.ASSESSOR_EMBEDDING_PASSAGE_PREFIX), requestTimeoutMs: boundedInteger(env.ASSESSOR_REGMAP_TIMEOUT_MS, 30_000, 1_000, 120_000, 'ASSESSOR_REGMAP_TIMEOUT_MS'), retrievalChunkLimit: boundedInteger(env.ASSESSOR_REGMAP_CHUNK_LIMIT, 20, 1, 200, 'ASSESSOR_REGMAP_CHUNK_LIMIT'), candidateUnitLimit: boundedInteger(env.ASSESSOR_REGMAP_CANDIDATE_LIMIT, 5, 1, 50, 'ASSESSOR_REGMAP_CANDIDATE_LIMIT'), maximumQueryCharacters: boundedInteger(env.ASSESSOR_REGMAP_MAX_QUERY_CHARS, 4_000, 200, 20_000, 'ASSESSOR_REGMAP_MAX_QUERY_CHARS') }; return { state: 'ready', config }; } catch (error) { return { state: 'error', detailCode: 'config_incomplete', message: error instanceof Error ? error.message : 'Regulation-Mapping-Konfiguration ist ungültig.' }; } } /** A static, non-networking runtime used when the feature is disabled or its * configuration is invalid. */ class InertRegulationMapping implements RegulationMapping { constructor(private readonly report: RegulationCapabilityReport) {} cachedCapability(): RegulationCapabilityReport { return this.report; } async refreshCapability(): Promise { return this.report; } async retrieve(): Promise { throw new RegulationMappingUnavailableError(this.report); } } export function disabledRegulationMapping(): RegulationMapping { return new InertRegulationMapping({ capability: 'disabled' }); }