191 lines
7.5 KiB
TypeScript
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' });
|
|
}
|