ГЛОБАЛЬНЫЙ РЕФАКТОРИНГ АРХИТЕКТУРЫ - Архитектура маршрутов v2: baseline ожиданий intentrecipe/result_mode с runtime-аудитом
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
import {
|
||||
FEATURE_ASSISTANT_CAPABILITY_ROUTE_GUARD_V1,
|
||||
FEATURE_ASSISTANT_ROUTE_EXPECTATION_AUDIT_V1,
|
||||
FEATURE_ASSISTANT_ROUTE_EXPECTATION_HARD_GUARD_V1,
|
||||
FEATURE_ASSISTANT_ADDRESS_QUERY_V1,
|
||||
FEATURE_ASSISTANT_ADDRESS_QUERY_LIVE_V1
|
||||
} from "../config";
|
||||
@@ -34,6 +36,7 @@ import {
|
||||
resolveAddressCapabilityRouteDecision,
|
||||
resolveShadowRouteIntent
|
||||
} from "./addressCapabilityPolicy";
|
||||
import { evaluateAddressRouteExpectation, type AddressRouteExpectationAudit } from "./addressRouteExpectations";
|
||||
|
||||
interface NormalizedAddressRow {
|
||||
period: string | null;
|
||||
@@ -63,6 +66,14 @@ interface AddressShadowRouteAudit {
|
||||
status: AddressShadowRouteStatus;
|
||||
}
|
||||
|
||||
interface AddressRouteExpectationAuditState {
|
||||
status: AddressRouteExpectationAudit["status"];
|
||||
reason: string;
|
||||
expectedSelectedRecipes: string[];
|
||||
expectedRequestedResultModes: AddressResultMode[];
|
||||
expectedResultModes: AddressResultMode[];
|
||||
}
|
||||
|
||||
const ACCOUNT_SCOPE_FIELDS_CHECKED = ["account_dt", "account_kt", "registrator", "analytics"] as const;
|
||||
const ACCOUNT_SCOPE_MATCH_STRATEGY = "account_code_regex_plus_alias_map_v1" as const;
|
||||
const ADDRESS_ANCHOR_RECOVERY_LIMIT = 1000;
|
||||
@@ -966,6 +977,36 @@ function buildShadowRouteAudit(input: {
|
||||
};
|
||||
}
|
||||
|
||||
function buildRouteExpectationAudit(input: {
|
||||
intent: AddressIntent;
|
||||
selectedRecipe: string | null;
|
||||
requestedResultMode?: AddressResultMode;
|
||||
resultMode?: AddressResultMode;
|
||||
}): AddressRouteExpectationAuditState {
|
||||
if (!FEATURE_ASSISTANT_ROUTE_EXPECTATION_AUDIT_V1) {
|
||||
return {
|
||||
status: "not_found",
|
||||
reason: "route_expectation_audit_disabled",
|
||||
expectedSelectedRecipes: [],
|
||||
expectedRequestedResultModes: [],
|
||||
expectedResultModes: []
|
||||
};
|
||||
}
|
||||
const audit = evaluateAddressRouteExpectation({
|
||||
intent: input.intent,
|
||||
selectedRecipe: input.selectedRecipe,
|
||||
requestedResultMode: input.requestedResultMode,
|
||||
resultMode: input.resultMode
|
||||
});
|
||||
return {
|
||||
status: audit.status,
|
||||
reason: audit.reason,
|
||||
expectedSelectedRecipes: audit.expected_selected_recipes,
|
||||
expectedRequestedResultModes: audit.expected_requested_result_modes,
|
||||
expectedResultModes: audit.expected_result_modes
|
||||
};
|
||||
}
|
||||
|
||||
function enforceStrictAccountScopeForIntent(
|
||||
plan: AddressRecipeExecutionPlan,
|
||||
intent: AddressIntent
|
||||
@@ -1765,6 +1806,7 @@ function buildLimitedExecutionResult(input: {
|
||||
category: AddressLimitedReasonCategory;
|
||||
capabilityAudit?: AddressCapabilityAudit;
|
||||
shadowRouteAudit?: AddressShadowRouteAudit;
|
||||
routeExpectationAudit?: AddressRouteExpectationAuditState;
|
||||
}): AddressExecutionResult {
|
||||
const accountScopeAudit = input.accountScopeAudit ?? buildDefaultAccountScopeAudit(input.filters);
|
||||
const resultSemantics = deriveAddressResultSemantics({
|
||||
@@ -1786,6 +1828,14 @@ function buildLimitedExecutionResult(input: {
|
||||
!reasonsWithConfirmedFallback.includes("exact_payables_mode_limited_response")
|
||||
? [...reasonsWithConfirmedFallback, "exact_payables_mode_limited_response"]
|
||||
: reasonsWithConfirmedFallback;
|
||||
const routeExpectationAudit =
|
||||
input.routeExpectationAudit ??
|
||||
buildRouteExpectationAudit({
|
||||
intent: input.intent.intent,
|
||||
selectedRecipe: input.selectedRecipe,
|
||||
requestedResultMode: requestedResultMode,
|
||||
resultMode: resultSemantics.result_mode
|
||||
});
|
||||
return {
|
||||
handled: true,
|
||||
reply_text: composeLimitedReply({
|
||||
@@ -1844,6 +1894,11 @@ function buildLimitedExecutionResult(input: {
|
||||
shadow_route_intent: input.shadowRouteAudit?.intent ?? null,
|
||||
shadow_route_selected_recipe: input.shadowRouteAudit?.selectedRecipe ?? null,
|
||||
shadow_route_status: input.shadowRouteAudit?.status ?? "skipped",
|
||||
route_expectation_status: routeExpectationAudit.status,
|
||||
route_expectation_reason: routeExpectationAudit.reason,
|
||||
route_expectation_expected_selected_recipes: routeExpectationAudit.expectedSelectedRecipes,
|
||||
route_expectation_expected_requested_result_modes: routeExpectationAudit.expectedRequestedResultModes,
|
||||
route_expectation_expected_result_modes: routeExpectationAudit.expectedResultModes,
|
||||
...resultSemantics,
|
||||
limitations: input.limitations,
|
||||
reasons
|
||||
@@ -2991,6 +3046,45 @@ export class AddressQueryService {
|
||||
}),
|
||||
factual.semantics
|
||||
);
|
||||
const finalRouteExpectationAudit = buildRouteExpectationAudit({
|
||||
intent: intent.intent,
|
||||
selectedRecipe: effectiveRecipeId,
|
||||
requestedResultMode,
|
||||
resultMode: factualResultSemantics.result_mode
|
||||
});
|
||||
if (finalRouteExpectationAudit.status === "mismatch" && FEATURE_ASSISTANT_ROUTE_EXPECTATION_HARD_GUARD_V1) {
|
||||
return buildLimitedExecutionResult({
|
||||
mode,
|
||||
shape,
|
||||
intent,
|
||||
filters: filters.extracted_filters,
|
||||
missingRequiredFilters: [],
|
||||
selectedRecipe: effectiveRecipeId,
|
||||
accountScopeMode: plan.account_scope_mode,
|
||||
accountScopeFallbackApplied,
|
||||
accountScopeAudit,
|
||||
anchor,
|
||||
matchFailureStage,
|
||||
matchFailureReason,
|
||||
mcpCallStatus: stageStatus,
|
||||
rowsFetched: mcp.fetched_rows,
|
||||
rawRowsReceived: mcp.raw_rows.length,
|
||||
rowsAfterAccountScope: normalizedRows.length,
|
||||
rowsAfterRecipeFilter: filterByAnchors.length,
|
||||
rowsMaterialized: normalizedRows.length,
|
||||
rowsMatched: filteredRows.length,
|
||||
rawRowKeysSample: rowDiagnostics.rawRowKeysSample,
|
||||
materializationDropReason: rowDiagnostics.materializationDropReason,
|
||||
category: "recipe_visibility_gap",
|
||||
reasonText: "маршрут не прошел baseline route expectation contract",
|
||||
nextStep: "проверьте intent/recipe mapping или отключите FEATURE_ASSISTANT_ROUTE_EXPECTATION_HARD_GUARD_V1 для безопасного rollout",
|
||||
limitations: ["route_expectation_mismatch_guard_blocked"],
|
||||
reasons: [...baseReasons, `route_expectation_mismatch:${finalRouteExpectationAudit.reason}`],
|
||||
capabilityAudit,
|
||||
shadowRouteAudit,
|
||||
routeExpectationAudit: finalRouteExpectationAudit
|
||||
});
|
||||
}
|
||||
if (intent.intent === "payables_confirmed_as_of_date" && factualResultSemantics.balance_confirmed !== true) {
|
||||
return buildLimitedExecutionResult({
|
||||
mode,
|
||||
@@ -3020,9 +3114,14 @@ export class AddressQueryService {
|
||||
limitations: ["exact_payables_mode_unconfirmed_output_blocked"],
|
||||
reasons: [...baseReasons, "exact_payables_mode_unconfirmed_output_blocked"],
|
||||
capabilityAudit,
|
||||
shadowRouteAudit
|
||||
shadowRouteAudit,
|
||||
routeExpectationAudit: finalRouteExpectationAudit
|
||||
});
|
||||
}
|
||||
const reasonsWithRouteExpectation =
|
||||
finalRouteExpectationAudit.status === "mismatch"
|
||||
? [...baseReasons, `route_expectation_mismatch:${finalRouteExpectationAudit.reason}`]
|
||||
: baseReasons;
|
||||
return {
|
||||
handled: true,
|
||||
reply_text: factual.text,
|
||||
@@ -3073,10 +3172,15 @@ export class AddressQueryService {
|
||||
shadow_route_intent: shadowRouteAudit.intent,
|
||||
shadow_route_selected_recipe: shadowRouteAudit.selectedRecipe,
|
||||
shadow_route_status: shadowRouteAudit.status,
|
||||
route_expectation_status: finalRouteExpectationAudit.status,
|
||||
route_expectation_reason: finalRouteExpectationAudit.reason,
|
||||
route_expectation_expected_selected_recipes: finalRouteExpectationAudit.expectedSelectedRecipes,
|
||||
route_expectation_expected_requested_result_modes: finalRouteExpectationAudit.expectedRequestedResultModes,
|
||||
route_expectation_expected_result_modes: finalRouteExpectationAudit.expectedResultModes,
|
||||
...factualResultSemantics,
|
||||
limitations: filters.warnings,
|
||||
reasons: withConfirmedBalanceFallbackReason(
|
||||
baseReasons,
|
||||
reasonsWithRouteExpectation,
|
||||
requestedResultMode,
|
||||
factual.semantics,
|
||||
factualResultSemantics.result_mode
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import type { AddressIntent, AddressResultMode } from "../types/addressQuery";
|
||||
|
||||
export type AddressRouteExpectationStatus = "matched" | "mismatch" | "not_found";
|
||||
|
||||
export interface AddressRouteExpectationEntry {
|
||||
intent: AddressIntent;
|
||||
expected_selected_recipes: string[];
|
||||
expected_requested_result_modes?: AddressResultMode[];
|
||||
expected_result_modes?: AddressResultMode[];
|
||||
}
|
||||
|
||||
export interface AddressRouteExpectationsContract {
|
||||
schema_version: "address_route_expectations_v1";
|
||||
updated_at: string;
|
||||
entries: AddressRouteExpectationEntry[];
|
||||
}
|
||||
|
||||
export interface AddressRouteExpectationAudit {
|
||||
status: AddressRouteExpectationStatus;
|
||||
reason: string;
|
||||
expected_selected_recipes: string[];
|
||||
expected_requested_result_modes: AddressResultMode[];
|
||||
expected_result_modes: AddressResultMode[];
|
||||
}
|
||||
|
||||
const EXPECTATIONS_FILE = path.resolve(__dirname, "..", "..", "..", "..", "docs", "TECH", "address_route_expectations_v1.json");
|
||||
|
||||
function toObject(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 (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function toStringArray(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
return value.map((item) => toNonEmptyString(item)).filter((item): item is string => Boolean(item));
|
||||
}
|
||||
|
||||
function parseResultModes(value: unknown): AddressResultMode[] {
|
||||
const raw = toStringArray(value);
|
||||
return raw.filter((mode): mode is AddressResultMode => mode === "heuristic_candidates" || mode === "confirmed_balance");
|
||||
}
|
||||
|
||||
function parseEntry(value: unknown): AddressRouteExpectationEntry | null {
|
||||
const object = toObject(value);
|
||||
if (!object) {
|
||||
return null;
|
||||
}
|
||||
const intent = toNonEmptyString(object.intent) as AddressIntent | null;
|
||||
const expectedSelectedRecipes = toStringArray(object.expected_selected_recipes);
|
||||
if (!intent || expectedSelectedRecipes.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const expectedRequestedResultModes = parseResultModes(object.expected_requested_result_modes);
|
||||
const expectedResultModes = parseResultModes(object.expected_result_modes);
|
||||
return {
|
||||
intent,
|
||||
expected_selected_recipes: expectedSelectedRecipes,
|
||||
...(expectedRequestedResultModes.length > 0 ? { expected_requested_result_modes: expectedRequestedResultModes } : {}),
|
||||
...(expectedResultModes.length > 0 ? { expected_result_modes: expectedResultModes } : {})
|
||||
};
|
||||
}
|
||||
|
||||
export function loadAddressRouteExpectationsContract(): AddressRouteExpectationsContract {
|
||||
const raw = fs.readFileSync(EXPECTATIONS_FILE, "utf-8");
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
const root = toObject(parsed);
|
||||
if (!root) {
|
||||
throw new Error("address_route_expectations_v1: invalid root payload");
|
||||
}
|
||||
const schemaVersion = toNonEmptyString(root.schema_version);
|
||||
if (schemaVersion !== "address_route_expectations_v1") {
|
||||
throw new Error(`address_route_expectations_v1: unexpected schema version '${schemaVersion ?? "null"}'`);
|
||||
}
|
||||
const updatedAt = toNonEmptyString(root.updated_at) ?? new Date().toISOString();
|
||||
const entriesRaw = Array.isArray(root.entries) ? root.entries : [];
|
||||
const entries = entriesRaw.map(parseEntry).filter((entry): entry is AddressRouteExpectationEntry => entry !== null);
|
||||
if (entries.length === 0) {
|
||||
throw new Error("address_route_expectations_v1: no valid entries");
|
||||
}
|
||||
return {
|
||||
schema_version: "address_route_expectations_v1",
|
||||
updated_at: updatedAt,
|
||||
entries
|
||||
};
|
||||
}
|
||||
|
||||
export function evaluateAddressRouteExpectation(input: {
|
||||
intent: AddressIntent;
|
||||
selectedRecipe: string | null;
|
||||
requestedResultMode?: AddressResultMode;
|
||||
resultMode?: AddressResultMode;
|
||||
}): AddressRouteExpectationAudit {
|
||||
const contract = loadAddressRouteExpectationsContract();
|
||||
const entry = contract.entries.find((item) => item.intent === input.intent);
|
||||
if (!entry) {
|
||||
return {
|
||||
status: "not_found",
|
||||
reason: "route_expectation_not_defined_for_intent",
|
||||
expected_selected_recipes: [],
|
||||
expected_requested_result_modes: [],
|
||||
expected_result_modes: []
|
||||
};
|
||||
}
|
||||
if (input.selectedRecipe && !entry.expected_selected_recipes.includes(input.selectedRecipe)) {
|
||||
return {
|
||||
status: "mismatch",
|
||||
reason: "selected_recipe_mismatch",
|
||||
expected_selected_recipes: entry.expected_selected_recipes,
|
||||
expected_requested_result_modes: entry.expected_requested_result_modes ?? [],
|
||||
expected_result_modes: entry.expected_result_modes ?? []
|
||||
};
|
||||
}
|
||||
if (
|
||||
input.requestedResultMode &&
|
||||
Array.isArray(entry.expected_requested_result_modes) &&
|
||||
entry.expected_requested_result_modes.length > 0 &&
|
||||
!entry.expected_requested_result_modes.includes(input.requestedResultMode)
|
||||
) {
|
||||
return {
|
||||
status: "mismatch",
|
||||
reason: "requested_result_mode_mismatch",
|
||||
expected_selected_recipes: entry.expected_selected_recipes,
|
||||
expected_requested_result_modes: entry.expected_requested_result_modes,
|
||||
expected_result_modes: entry.expected_result_modes ?? []
|
||||
};
|
||||
}
|
||||
if (
|
||||
input.resultMode &&
|
||||
Array.isArray(entry.expected_result_modes) &&
|
||||
entry.expected_result_modes.length > 0 &&
|
||||
!entry.expected_result_modes.includes(input.resultMode)
|
||||
) {
|
||||
return {
|
||||
status: "mismatch",
|
||||
reason: "result_mode_mismatch",
|
||||
expected_selected_recipes: entry.expected_selected_recipes,
|
||||
expected_requested_result_modes: entry.expected_requested_result_modes ?? [],
|
||||
expected_result_modes: entry.expected_result_modes
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: "matched",
|
||||
reason: "route_expectation_matched",
|
||||
expected_selected_recipes: entry.expected_selected_recipes,
|
||||
expected_requested_result_modes: entry.expected_requested_result_modes ?? [],
|
||||
expected_result_modes: entry.expected_result_modes ?? []
|
||||
};
|
||||
}
|
||||
@@ -1427,6 +1427,12 @@ function buildAddressDebugPayload(addressDebug, llmPreDecomposeMeta = null) {
|
||||
shadow_route_intent: addressDebug.shadow_route_intent ?? undefined,
|
||||
shadow_route_selected_recipe: addressDebug.shadow_route_selected_recipe ?? undefined,
|
||||
shadow_route_status: addressDebug.shadow_route_status ?? undefined,
|
||||
route_expectation_status: addressDebug.route_expectation_status ?? undefined,
|
||||
route_expectation_reason: addressDebug.route_expectation_reason ?? undefined,
|
||||
route_expectation_expected_selected_recipes: addressDebug.route_expectation_expected_selected_recipes ?? undefined,
|
||||
route_expectation_expected_requested_result_modes:
|
||||
addressDebug.route_expectation_expected_requested_result_modes ?? undefined,
|
||||
route_expectation_expected_result_modes: addressDebug.route_expectation_expected_result_modes ?? undefined,
|
||||
execution_lane: "address_query",
|
||||
llm_decomposition_applied: Boolean(llmMeta?.applied),
|
||||
llm_decomposition_attempted: Boolean(llmMeta?.attempted),
|
||||
|
||||
Reference in New Issue
Block a user