Assessor_2.0/server/regmap/regulation-mapping.ts

191 lines
7.5 KiB
TypeScript

// 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<RegulationCapabilityReport>;
/** Retrieve candidate regulation units; throws RegulationMappingUnavailableError when not ready. */
retrieve(input: RegulationDefinitionInput): Promise<RegulationRetrievalResult>;
}
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<RegulationCapabilityReport> {
return this.report;
}
async retrieve(): Promise<RegulationRetrievalResult> {
throw new RegulationMappingUnavailableError(this.report);
}
}
export function disabledRegulationMapping(): RegulationMapping {
return new InertRegulationMapping({ capability: 'disabled' });
}