NODEDC_1C/llm_normalizer/backend/src/services/addressTruthGatePolicy.ts

326 lines
11 KiB
TypeScript

import type {
AssistantCarryoverDepth,
AssistantTruthGateContractStatus
} from "../types/assistantRuntimeContracts";
import type {
AddressFilterSet,
AddressIntent,
AddressLimitedReasonCategory,
AddressRuntimeReadiness,
AddressSemanticFrame
} from "../types/addressQuery";
export const ADDRESS_TRUTH_GATE_SCHEMA_VERSION = "address_truth_gate_v1" as const;
export interface AddressTruthGateContract {
schema_version: typeof ADDRESS_TRUTH_GATE_SCHEMA_VERSION;
policy_owner: "addressTruthGatePolicy";
truth_gate_status: AssistantTruthGateContractStatus;
carryover_eligibility: AssistantCarryoverDepth;
limited_reason_category: AddressLimitedReasonCategory | null;
runtime_readiness: AddressRuntimeReadiness;
reason_codes: string[];
blocked_or_limited_explanation: string | null;
}
export interface ResolveAddressTruthGateInput {
intent: AddressIntent;
filters?: AddressFilterSet | null;
semanticFrame?: AddressSemanticFrame | null;
selectedRecipe?: string | null;
truthGateStatusHint?: AssistantTruthGateContractStatus | null;
rowsMatched?: number;
limitedReasonCategory?: AddressLimitedReasonCategory | null;
runtimeReadiness?: AddressRuntimeReadiness | null;
missingRequiredFilters?: string[];
limitations?: string[];
reasons?: string[];
routeExpectationStatus?: "matched" | "mismatch" | "not_found" | null;
routeExpectationReason?: string | null;
temporalGuardOutcome?: string | null;
temporalAlignmentStatus?: string | null;
replyType?: "factual" | "partial_coverage" | "deep_analysis" | "unknown" | null;
}
function toRecordObject(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
return value as Record<string, unknown>;
}
function toNonEmptyString(value: unknown): string | null {
if (value === null || value === undefined) {
return null;
}
const text = String(value).trim();
return text.length > 0 ? text : null;
}
function toStringList(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
return value
.map((item) => toNonEmptyString(item))
.filter((item): item is string => Boolean(item));
}
function isTruthGateStatus(value: string | null): value is AssistantTruthGateContractStatus {
return (
value === "full_confirmed" ||
value === "partial_supported" ||
value === "blocked_missing_anchor" ||
value === "blocked_route_expectation_failure" ||
value === "blocked_execution_error" ||
value === "limited_temporal_or_contextual" ||
value === "unknown"
);
}
function isCarryoverDepth(value: string | null): value is AssistantCarryoverDepth {
return value === "full" || value === "root_only" || value === "object_only" || value === "meta_only" || value === "none";
}
function isLimitedReasonCategory(value: string | null): value is AddressLimitedReasonCategory {
return (
value === "empty_match" ||
value === "missing_anchor" ||
value === "recipe_visibility_gap" ||
value === "execution_error" ||
value === "unsupported"
);
}
function isRuntimeReadiness(value: string | null): value is AddressRuntimeReadiness {
return (
value === "LIVE_QUERYABLE" ||
value === "LIVE_QUERYABLE_WITH_LIMITS" ||
value === "REQUIRES_SPECIALIZED_RECIPE" ||
value === "DEEP_ONLY" ||
value === "UNKNOWN"
);
}
function normalizeReasonCode(value: string): string | null {
const normalized = value
.trim()
.replace(/[^\p{L}\p{N}_.:-]+/gu, "_")
.replace(/^_+|_+$/g, "")
.toLowerCase();
return normalized.length > 0 ? normalized.slice(0, 120) : null;
}
function pushReason(target: string[], value: unknown): void {
const text = toNonEmptyString(value);
if (!text) {
return;
}
const normalized = normalizeReasonCode(text);
if (normalized && !target.includes(normalized)) {
target.push(normalized);
}
}
function hasFilterScope(filters: AddressFilterSet | null | undefined): boolean {
if (!filters) {
return false;
}
const scopeKeys: Array<keyof AddressFilterSet> = [
"period_from",
"period_to",
"as_of_date",
"organization",
"counterparty",
"contract",
"account",
"item",
"warehouse",
"document_type",
"document_ref",
"status"
];
return scopeKeys.some((key) => toNonEmptyString(filters[key]) !== null);
}
function hasObjectFocus(input: ResolveAddressTruthGateInput): boolean {
if (toNonEmptyString(input.filters?.item)) {
return true;
}
if (input.semanticFrame?.selected_object_scope_detected) {
return true;
}
if (input.semanticFrame?.anchor_kind === "selected_object" || input.semanticFrame?.anchor_kind === "item") {
return true;
}
return (
input.intent === "inventory_purchase_provenance_for_item" ||
input.intent === "inventory_purchase_documents_for_item" ||
input.intent === "inventory_sale_trace_for_item" ||
input.intent === "inventory_profitability_for_item" ||
input.intent === "inventory_purchase_to_sale_chain" ||
input.intent === "inventory_aging_by_purchase_date"
);
}
function hasReusableRootScope(input: ResolveAddressTruthGateInput): boolean {
if (hasFilterScope(input.filters)) {
return true;
}
return Boolean(input.semanticFrame && input.semanticFrame.scope_kind !== "none");
}
function truthGateStatusFrom(input: ResolveAddressTruthGateInput): AssistantTruthGateContractStatus {
if (input.truthGateStatusHint) {
return input.truthGateStatusHint;
}
const missingRequiredFilters = input.missingRequiredFilters ?? [];
if (input.routeExpectationStatus === "mismatch") {
return "blocked_route_expectation_failure";
}
if (input.limitedReasonCategory === "execution_error") {
return "blocked_execution_error";
}
if (missingRequiredFilters.length > 0 || input.limitedReasonCategory === "missing_anchor") {
return "blocked_missing_anchor";
}
if (input.temporalGuardOutcome === "ambiguous_limited" || input.temporalAlignmentStatus === "conflicting") {
return "limited_temporal_or_contextual";
}
if (input.replyType === "factual" && input.limitedReasonCategory === "empty_match") {
return "full_confirmed";
}
if (
input.limitedReasonCategory === "empty_match" ||
input.limitedReasonCategory === "recipe_visibility_gap" ||
input.limitedReasonCategory === "unsupported" ||
input.replyType === "partial_coverage"
) {
return "partial_supported";
}
if ((input.rowsMatched ?? 0) > 0) {
return "full_confirmed";
}
return "unknown";
}
function carryoverEligibilityFor(
input: ResolveAddressTruthGateInput,
truthGateStatus: AssistantTruthGateContractStatus
): AssistantCarryoverDepth {
if (truthGateStatus.startsWith("blocked")) {
return "none";
}
if (input.limitedReasonCategory === "recipe_visibility_gap" || input.limitedReasonCategory === "unsupported") {
return "meta_only";
}
if (hasObjectFocus(input)) {
return "object_only";
}
if (truthGateStatus === "limited_temporal_or_contextual") {
return hasReusableRootScope(input) ? "root_only" : "meta_only";
}
if (truthGateStatus === "full_confirmed" || truthGateStatus === "partial_supported") {
return hasReusableRootScope(input) ? "root_only" : "meta_only";
}
return "none";
}
function explanationFor(
truthGateStatus: AssistantTruthGateContractStatus,
limitedReasonCategory: AddressLimitedReasonCategory | null
): string | null {
if (truthGateStatus === "full_confirmed") {
return null;
}
if (truthGateStatus === "blocked_missing_anchor") {
return "required_anchor_missing";
}
if (truthGateStatus === "blocked_route_expectation_failure") {
return "route_expectation_failed";
}
if (truthGateStatus === "blocked_execution_error") {
return "execution_failed";
}
if (truthGateStatus === "limited_temporal_or_contextual") {
return "temporal_or_contextual_limit";
}
if (limitedReasonCategory === "recipe_visibility_gap") {
return "specialized_recipe_required";
}
if (limitedReasonCategory === "unsupported") {
return "unsupported_in_current_contour";
}
return truthGateStatus === "partial_supported" ? "evidence_or_coverage_is_partial" : "truth_gate_not_confirmed";
}
function collectReasonCodes(
input: ResolveAddressTruthGateInput,
truthGateStatus: AssistantTruthGateContractStatus
): string[] {
const reasons: string[] = [];
pushReason(reasons, `address_truth_gate_${truthGateStatus}`);
pushReason(reasons, input.routeExpectationReason);
pushReason(reasons, input.limitedReasonCategory ? `limited_category_${input.limitedReasonCategory}` : null);
(input.missingRequiredFilters ?? []).forEach((item) => pushReason(reasons, `missing_filter_${item}`));
(input.limitations ?? []).forEach((item) => pushReason(reasons, item));
(input.reasons ?? []).forEach((item) => pushReason(reasons, item));
return reasons.slice(0, 32);
}
export function resolveAddressTruthGate(input: ResolveAddressTruthGateInput): AddressTruthGateContract {
const truthGateStatus = truthGateStatusFrom(input);
return {
schema_version: ADDRESS_TRUTH_GATE_SCHEMA_VERSION,
policy_owner: "addressTruthGatePolicy",
truth_gate_status: truthGateStatus,
carryover_eligibility: carryoverEligibilityFor(input, truthGateStatus),
limited_reason_category: input.limitedReasonCategory ?? null,
runtime_readiness: input.runtimeReadiness ?? "UNKNOWN",
reason_codes: collectReasonCodes(input, truthGateStatus),
blocked_or_limited_explanation: explanationFor(truthGateStatus, input.limitedReasonCategory ?? null)
};
}
export function toAddressTruthGateContract(value: unknown): AddressTruthGateContract | null {
const record = toRecordObject(value);
if (!record) {
return null;
}
const truthGateStatus = toNonEmptyString(record.truth_gate_status);
const carryoverEligibility = toNonEmptyString(record.carryover_eligibility);
const limitedReasonCategory = toNonEmptyString(record.limited_reason_category);
const runtimeReadiness = toNonEmptyString(record.runtime_readiness);
if (!isTruthGateStatus(truthGateStatus) || !isCarryoverDepth(carryoverEligibility) || !isRuntimeReadiness(runtimeReadiness)) {
return null;
}
return {
schema_version: ADDRESS_TRUTH_GATE_SCHEMA_VERSION,
policy_owner: "addressTruthGatePolicy",
truth_gate_status: truthGateStatus,
carryover_eligibility: carryoverEligibility,
limited_reason_category: isLimitedReasonCategory(limitedReasonCategory) ? limitedReasonCategory : null,
runtime_readiness: runtimeReadiness,
reason_codes: toStringList(record.reason_codes).slice(0, 32),
blocked_or_limited_explanation: toNonEmptyString(record.blocked_or_limited_explanation)
};
}
export function buildAddressTruthGateDebugFields(input: ResolveAddressTruthGateInput): {
address_truth_gate_v1: AddressTruthGateContract;
} {
return {
address_truth_gate_v1: resolveAddressTruthGate(input)
};
}
export function attachAddressTruthGate<T extends Record<string, unknown>>(
debugPayload: T,
input: ResolveAddressTruthGateInput
): T & { address_truth_gate_v1: AddressTruthGateContract } {
return {
...debugPayload,
...buildAddressTruthGateDebugFields(input)
};
}