ГЛОБАЛЬНЫЙ РЕФАКТОРИНГ АРХИТЕКТУРЫ - Архитектурный фундамент: capability-guard, navigation state и трассируемый route-контракт

This commit is contained in:
2026-04-12 14:40:48 +03:00
parent fbd156e58e
commit c10260dedf
22 changed files with 1842 additions and 23 deletions
@@ -0,0 +1,150 @@
import {
FEATURE_ASSISTANT_CAPABILITY_ROUTE_GUARD_V1,
FEATURE_ASSISTANT_ROUTE_ADDRESS_GENERIC_V1,
FEATURE_ASSISTANT_ROUTE_BALANCE_EXACT_V1,
FEATURE_ASSISTANT_ROUTE_DRILLDOWN_V1,
FEATURE_ASSISTANT_ROUTE_PAYABLES_CONFIRMED_V1,
FEATURE_ASSISTANT_ROUTE_PAYABLES_HEURISTIC_V1,
FEATURE_ASSISTANT_ROUTE_RECEIVABLES_HEURISTIC_V1,
FEATURE_ASSISTANT_ROUTE_SHADOW_PAYABLES_EXACT_V1
} from "../config";
import type { AddressIntent, AddressResultMode } from "../types/addressQuery";
export type AddressCapabilityLayer = "compute" | "navigation" | "conversational";
export type AddressCapabilityRouteMode = "exact" | "heuristic";
export type AddressShadowRouteStatus = "skipped" | "planned" | "unavailable";
export interface AddressCapabilityRouteDecision {
capability_id: string;
capability_layer: AddressCapabilityLayer;
capability_route_mode: AddressCapabilityRouteMode;
capability_route_enabled: boolean;
capability_route_reason: string;
}
const COMPUTE_EXACT_INTENTS = new Set<AddressIntent>(["account_balance_snapshot", "documents_forming_balance", "payables_confirmed_as_of_date"]);
const NAVIGATION_INTENTS = new Set<AddressIntent>([
"list_documents_by_counterparty",
"bank_operations_by_counterparty",
"list_contracts_by_counterparty",
"list_documents_by_contract",
"bank_operations_by_contract"
]);
const HEURISTIC_LIST_INTENTS = new Set<AddressIntent>([
"list_payables_counterparties",
"list_receivables_counterparties",
"open_items_by_counterparty_or_contract",
"list_open_contracts"
]);
function isExactComputeIntent(intent: AddressIntent): boolean {
return COMPUTE_EXACT_INTENTS.has(intent);
}
function isNavigationIntent(intent: AddressIntent): boolean {
return NAVIGATION_INTENTS.has(intent);
}
function isHeuristicListIntent(intent: AddressIntent): boolean {
return HEURISTIC_LIST_INTENTS.has(intent);
}
function defaultCapabilityId(intent: AddressIntent): string {
if (intent === "payables_confirmed_as_of_date") {
return "confirmed_payables_as_of_date";
}
if (intent === "list_payables_counterparties") {
return "payables_candidates_list";
}
if (intent === "list_receivables_counterparties") {
return "receivables_candidates_list";
}
if (intent === "account_balance_snapshot" || intent === "documents_forming_balance") {
return "account_balance_exact";
}
if (intent === "list_documents_by_counterparty" || intent === "list_documents_by_contract") {
return "documents_drilldown";
}
if (intent === "list_contracts_by_counterparty") {
return "contracts_drilldown";
}
if (intent === "bank_operations_by_counterparty" || intent === "bank_operations_by_contract") {
return "bank_operations_drilldown";
}
return `address_${intent}`;
}
function resolveCapabilityEnabled(intent: AddressIntent): { enabled: boolean; reason: string } {
if (intent === "payables_confirmed_as_of_date") {
return {
enabled: FEATURE_ASSISTANT_ROUTE_PAYABLES_CONFIRMED_V1,
reason: FEATURE_ASSISTANT_ROUTE_PAYABLES_CONFIRMED_V1 ? "payables_confirmed_route_enabled" : "payables_confirmed_route_disabled_by_flag"
};
}
if (intent === "list_payables_counterparties") {
return {
enabled: FEATURE_ASSISTANT_ROUTE_PAYABLES_HEURISTIC_V1,
reason: FEATURE_ASSISTANT_ROUTE_PAYABLES_HEURISTIC_V1 ? "payables_heuristic_route_enabled" : "payables_heuristic_route_disabled_by_flag"
};
}
if (intent === "list_receivables_counterparties" || intent === "open_items_by_counterparty_or_contract" || intent === "list_open_contracts") {
return {
enabled: FEATURE_ASSISTANT_ROUTE_RECEIVABLES_HEURISTIC_V1,
reason: FEATURE_ASSISTANT_ROUTE_RECEIVABLES_HEURISTIC_V1 ? "receivables_heuristic_route_enabled" : "receivables_heuristic_route_disabled_by_flag"
};
}
if (intent === "account_balance_snapshot" || intent === "documents_forming_balance") {
return {
enabled: FEATURE_ASSISTANT_ROUTE_BALANCE_EXACT_V1,
reason: FEATURE_ASSISTANT_ROUTE_BALANCE_EXACT_V1 ? "balance_exact_route_enabled" : "balance_exact_route_disabled_by_flag"
};
}
if (isNavigationIntent(intent)) {
return {
enabled: FEATURE_ASSISTANT_ROUTE_DRILLDOWN_V1,
reason: FEATURE_ASSISTANT_ROUTE_DRILLDOWN_V1 ? "drilldown_route_enabled" : "drilldown_route_disabled_by_flag"
};
}
return {
enabled: FEATURE_ASSISTANT_ROUTE_ADDRESS_GENERIC_V1,
reason: FEATURE_ASSISTANT_ROUTE_ADDRESS_GENERIC_V1 ? "generic_address_route_enabled" : "generic_address_route_disabled_by_flag"
};
}
export function resolveAddressCapabilityRouteDecision(intent: AddressIntent): AddressCapabilityRouteDecision {
const capability = defaultCapabilityId(intent);
const enabled = resolveCapabilityEnabled(intent);
const layer: AddressCapabilityLayer = isNavigationIntent(intent)
? "navigation"
: isExactComputeIntent(intent) || isHeuristicListIntent(intent)
? "compute"
: "conversational";
const routeMode: AddressCapabilityRouteMode = isHeuristicListIntent(intent) ? "heuristic" : "exact";
return {
capability_id: capability,
capability_layer: layer,
capability_route_mode: routeMode,
capability_route_enabled: enabled.enabled,
capability_route_reason: enabled.reason
};
}
export function isCapabilityRouteBlocked(decision: AddressCapabilityRouteDecision): boolean {
return FEATURE_ASSISTANT_CAPABILITY_ROUTE_GUARD_V1 && !decision.capability_route_enabled;
}
export function resolveShadowRouteIntent(
intent: AddressIntent,
requestedResultMode: AddressResultMode | undefined
): AddressIntent | null {
if (!FEATURE_ASSISTANT_ROUTE_SHADOW_PAYABLES_EXACT_V1) {
return null;
}
if (intent === "payables_confirmed_as_of_date") {
return "list_payables_counterparties";
}
if (intent === "list_payables_counterparties" && requestedResultMode === "confirmed_balance") {
return "payables_confirmed_as_of_date";
}
return null;
}
@@ -0,0 +1,441 @@
import { nanoid } from "nanoid";
import type { AssistantConversationItem } from "../types/assistant";
import type { AddressIntent } from "../types/addressQuery";
import {
ADDRESS_NAVIGATION_STATE_SCHEMA_VERSION,
type AddressFocusObject,
type AddressFocusObjectType,
type AddressNavigationAction,
type AddressNavigationEvent,
type AddressNavigationState,
type AddressResultEntityRef,
type AddressResultSet,
type AddressResultSetType
} from "../types/addressNavigation";
const MAX_RESULT_SETS = 40;
const MAX_NAVIGATION_EVENTS = 120;
const MAX_ENTITY_REFS_PER_RESULT_SET = 40;
const DISPLAY_ENTITY_TYPE_BY_INTENT: Partial<Record<AddressIntent, AddressFocusObjectType>> = {
counterparty_activity_lifecycle: "counterparty",
customer_revenue_and_payments: "counterparty",
supplier_payouts_profile: "counterparty",
list_payables_counterparties: "counterparty",
list_receivables_counterparties: "counterparty",
list_contracts_by_counterparty: "contract",
list_documents_by_counterparty: "document_ref",
list_documents_by_contract: "document_ref",
bank_operations_by_counterparty: "document_ref",
bank_operations_by_contract: "document_ref",
open_items_by_counterparty_or_contract: "counterparty"
};
const RESULT_SET_TYPE_BY_INTENT: Partial<Record<AddressIntent, AddressResultSetType>> = {
counterparty_activity_lifecycle: "counterparty_list",
customer_revenue_and_payments: "counterparty_list",
supplier_payouts_profile: "counterparty_list",
list_payables_counterparties: "counterparty_list",
payables_confirmed_as_of_date: "balance_snapshot",
list_receivables_counterparties: "counterparty_list",
list_contracts_by_counterparty: "contract_list",
list_documents_by_counterparty: "document_list",
list_documents_by_contract: "document_list",
bank_operations_by_counterparty: "bank_operations_list",
bank_operations_by_contract: "bank_operations_list",
open_items_by_counterparty_or_contract: "open_items_list",
period_coverage_profile: "profile_summary",
document_type_and_account_section_profile: "profile_summary",
counterparty_population_and_roles: "profile_summary",
contract_usage_overview: "profile_summary",
contract_usage_and_value: "profile_summary",
vat_payable_forecast: "profile_summary"
};
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 toAddressFocusObjectType(value: unknown): AddressFocusObjectType {
const normalized = toNonEmptyString(value);
if (!normalized) {
return "unknown";
}
if (normalized === "counterparty" || normalized === "contract" || normalized === "document_ref" || normalized === "account") {
return normalized;
}
return "unknown";
}
function toAddressIntent(value: unknown): AddressIntent {
const normalized = toNonEmptyString(value);
return (normalized ?? "unknown") as AddressIntent;
}
function inferDisplayEntityType(intent: AddressIntent): AddressFocusObjectType {
return DISPLAY_ENTITY_TYPE_BY_INTENT[intent] ?? "unknown";
}
function inferResultSetType(intent: AddressIntent): AddressResultSetType {
return RESULT_SET_TYPE_BY_INTENT[intent] ?? "unknown";
}
function parseEntityCandidateFromLine(line: string): { index: number; value: string } | null {
const compact = String(line ?? "").trim();
if (!compact) {
return null;
}
const numberedMatch = compact.match(/^(\d+)\.\s+(.+)$/);
if (!numberedMatch) {
return null;
}
const index = Number.parseInt(String(numberedMatch[1] ?? ""), 10);
if (!Number.isFinite(index) || index <= 0) {
return null;
}
const afterNumber = String(numberedMatch[2] ?? "");
const pieces = afterNumber.split("|").map((item) => item.trim()).filter(Boolean);
const valueCandidate = pieces.length > 0 ? pieces[0] : afterNumber;
const cleaned = valueCandidate.replace(/^["'«»“”„`’‘]+|["'«»“”„`’‘]+$/gu, "").trim();
if (!cleaned || cleaned.length < 2) {
return null;
}
return { index, value: cleaned };
}
function extractEntityRefsFromAssistantReply(
replyText: string,
intent: AddressIntent,
limit: number = MAX_ENTITY_REFS_PER_RESULT_SET
): AddressResultEntityRef[] {
const entityType = inferDisplayEntityType(intent);
if (entityType === "unknown") {
return [];
}
const dedup = new Map<string, AddressResultEntityRef>();
const lines = String(replyText ?? "").split(/\r?\n/);
for (const line of lines) {
const parsed = parseEntityCandidateFromLine(line);
if (!parsed) {
continue;
}
const key = `${parsed.index}:${entityType}:${parsed.value.toLowerCase()}`;
if (!dedup.has(key)) {
dedup.set(key, {
index: parsed.index,
entity_type: entityType,
value: parsed.value
});
}
if (dedup.size >= limit) {
break;
}
}
return Array.from(dedup.values());
}
function cloneFocusObject(value: AddressFocusObject | null): AddressFocusObject | null {
if (!value) {
return null;
}
return {
object_type: value.object_type,
object_id: value.object_id,
label: value.label,
provenance_result_set_id: value.provenance_result_set_id,
selected_at: value.selected_at
};
}
function cloneResultSet(input: AddressResultSet): AddressResultSet {
return {
result_set_id: input.result_set_id,
type: input.type,
intent: input.intent,
route_id: input.route_id,
filters: { ...input.filters },
source_refs: [...input.source_refs],
entity_refs: input.entity_refs.map((item) => ({
index: item.index,
entity_type: item.entity_type,
value: item.value
})),
created_from_turn: input.created_from_turn,
created_at: input.created_at
};
}
function cloneNavigationEvent(input: AddressNavigationEvent): AddressNavigationEvent {
return {
event_id: input.event_id,
action: input.action,
source_result_set_id: input.source_result_set_id,
target_object_id: input.target_object_id,
derived_result_set_id: input.derived_result_set_id,
turn_index: input.turn_index,
created_at: input.created_at
};
}
function normalizeFilters(value: unknown): Record<string, unknown> {
const record = toObject(value);
if (!record) {
return {};
}
return { ...record };
}
function resolveNavigationAction(debug: Record<string, unknown>, hasFocusObject: boolean): AddressNavigationAction {
const continuationContract = toObject(debug.dialog_continuation_contract_v2);
const decision = toNonEmptyString(continuationContract?.decision);
if (decision === "new_topic") {
return "open";
}
if (decision === "continue_previous") {
return hasFocusObject ? "drilldown" : "refine";
}
if (decision === "switch_to_suggested") {
return "refine";
}
return hasFocusObject ? "drilldown" : "open";
}
function buildFocusObjectFromDebug(debug: Record<string, unknown>, resultSetId: string, createdAt: string): AddressFocusObject | null {
const rawValue = toNonEmptyString(debug.anchor_value_resolved) ?? toNonEmptyString(debug.anchor_value_raw);
if (!rawValue) {
return null;
}
const objectType = toAddressFocusObjectType(debug.anchor_type);
const canonicalType = objectType === "unknown" ? inferDisplayEntityType(toAddressIntent(debug.detected_intent)) : objectType;
return {
object_type: canonicalType,
object_id: `${canonicalType}:${rawValue}`.toLowerCase(),
label: rawValue,
provenance_result_set_id: resultSetId,
selected_at: createdAt
};
}
function capResultSets(resultSets: AddressResultSet[]): AddressResultSet[] {
if (resultSets.length <= MAX_RESULT_SETS) {
return resultSets;
}
return resultSets.slice(resultSets.length - MAX_RESULT_SETS);
}
function capNavigationEvents(events: AddressNavigationEvent[]): AddressNavigationEvent[] {
if (events.length <= MAX_NAVIGATION_EVENTS) {
return events;
}
return events.slice(events.length - MAX_NAVIGATION_EVENTS);
}
function isAddressAssistantItem(item: AssistantConversationItem): boolean {
return (
item.role === "assistant" &&
Boolean(item.debug) &&
toNonEmptyString(item.debug?.detected_mode) === "address_query"
);
}
export function createEmptyAddressNavigationState(
sessionId: string,
nowIso: string = new Date().toISOString()
): AddressNavigationState {
return {
schema_version: ADDRESS_NAVIGATION_STATE_SCHEMA_VERSION,
session_id: sessionId,
updated_at: nowIso,
session_context: {
active_result_set_id: null,
active_focus_object: null,
last_confirmed_route: null,
date_scope: {
as_of_date: null,
period_from: null,
period_to: null
},
organization_scope: null
},
result_sets: [],
navigation_history: []
};
}
export function cloneAddressNavigationState(value: AddressNavigationState | null): AddressNavigationState | null {
if (!value) {
return null;
}
return {
schema_version: value.schema_version,
session_id: value.session_id,
updated_at: value.updated_at,
session_context: {
active_result_set_id: value.session_context.active_result_set_id,
active_focus_object: cloneFocusObject(value.session_context.active_focus_object),
last_confirmed_route: value.session_context.last_confirmed_route,
date_scope: {
as_of_date: value.session_context.date_scope.as_of_date,
period_from: value.session_context.date_scope.period_from,
period_to: value.session_context.date_scope.period_to
},
organization_scope: value.session_context.organization_scope
},
result_sets: value.result_sets.map(cloneResultSet),
navigation_history: value.navigation_history.map(cloneNavigationEvent)
};
}
export function normalizeAddressNavigationState(
value: AddressNavigationState | null | undefined,
sessionId: string
): AddressNavigationState {
const fallback = createEmptyAddressNavigationState(sessionId);
if (!value || typeof value !== "object") {
return fallback;
}
const normalizedSessionId = toNonEmptyString(value.session_id) ?? sessionId;
const normalizedUpdatedAt = toNonEmptyString(value.updated_at) ?? new Date().toISOString();
const context = toObject(value.session_context) ?? {};
const dateScope = toObject(context.date_scope) ?? {};
const resultSets = Array.isArray(value.result_sets) ? value.result_sets : [];
const navigationHistory = Array.isArray(value.navigation_history) ? value.navigation_history : [];
return {
schema_version: ADDRESS_NAVIGATION_STATE_SCHEMA_VERSION,
session_id: normalizedSessionId,
updated_at: normalizedUpdatedAt,
session_context: {
active_result_set_id: toNonEmptyString(context.active_result_set_id),
active_focus_object: cloneFocusObject(context.active_focus_object as AddressFocusObject | null),
last_confirmed_route: toNonEmptyString(context.last_confirmed_route),
date_scope: {
as_of_date: toNonEmptyString(dateScope.as_of_date),
period_from: toNonEmptyString(dateScope.period_from),
period_to: toNonEmptyString(dateScope.period_to)
},
organization_scope: toNonEmptyString(context.organization_scope)
},
result_sets: resultSets
.map((item) => toObject(item))
.filter((item): item is Record<string, unknown> => item !== null)
.map((item) => ({
result_set_id: toNonEmptyString(item.result_set_id) ?? `rs-${nanoid(10)}`,
type: inferResultSetType(toAddressIntent(item.intent)),
intent: toAddressIntent(item.intent),
route_id: toNonEmptyString(item.route_id),
filters: normalizeFilters(item.filters),
source_refs: Array.isArray(item.source_refs)
? item.source_refs.map((v) => toNonEmptyString(v)).filter((v): v is string => Boolean(v))
: [],
entity_refs: Array.isArray(item.entity_refs)
? item.entity_refs
.map((entry) => toObject(entry))
.filter((entry): entry is Record<string, unknown> => entry !== null)
.map((entry) => ({
index: Number.isFinite(Number(entry.index)) ? Number(entry.index) : 0,
entity_type: toAddressFocusObjectType(entry.entity_type),
value: toNonEmptyString(entry.value) ?? ""
}))
.filter((entry) => entry.index > 0 && entry.value.length > 0)
: [],
created_from_turn: Number.isFinite(Number(item.created_from_turn)) ? Number(item.created_from_turn) : 0,
created_at: toNonEmptyString(item.created_at) ?? normalizedUpdatedAt
})),
navigation_history: navigationHistory
.map((item) => toObject(item))
.filter((item): item is Record<string, unknown> => item !== null)
.map((item) => ({
event_id: toNonEmptyString(item.event_id) ?? `nav-${nanoid(10)}`,
action: (toNonEmptyString(item.action) as AddressNavigationAction | null) ?? "open",
source_result_set_id: toNonEmptyString(item.source_result_set_id),
target_object_id: toNonEmptyString(item.target_object_id),
derived_result_set_id: toNonEmptyString(item.derived_result_set_id),
turn_index: Number.isFinite(Number(item.turn_index)) ? Number(item.turn_index) : 0,
created_at: toNonEmptyString(item.created_at) ?? normalizedUpdatedAt
}))
};
}
export function evolveAddressNavigationStateWithAssistantItem(
state: AddressNavigationState,
item: AssistantConversationItem,
turnIndex: number
): AddressNavigationState {
if (!isAddressAssistantItem(item) || !item.debug) {
return state;
}
const debug = item.debug as unknown as Record<string, unknown>;
const intent = toAddressIntent(debug.detected_intent);
if (intent === "unknown") {
return state;
}
const createdAt = toNonEmptyString(item.created_at) ?? new Date().toISOString();
const resultSetId = `rs-${item.message_id}`;
const routeId = toNonEmptyString(debug.selected_recipe);
const filters = normalizeFilters(debug.extracted_filters);
const sourceRefs = routeId ? [routeId] : [];
const entityRefs = extractEntityRefsFromAssistantReply(item.text, intent);
const resultSet: AddressResultSet = {
result_set_id: resultSetId,
type: inferResultSetType(intent),
intent,
route_id: routeId,
filters,
source_refs: sourceRefs,
entity_refs: entityRefs,
created_from_turn: turnIndex,
created_at: createdAt
};
const previousResultSetId = state.session_context.active_result_set_id;
const focusObject = buildFocusObjectFromDebug(debug, resultSetId, createdAt);
const action = resolveNavigationAction(debug, Boolean(focusObject));
const navigationEvent: AddressNavigationEvent = {
event_id: `nav-${nanoid(10)}`,
action,
source_result_set_id: previousResultSetId,
target_object_id: focusObject?.object_id ?? null,
derived_result_set_id: resultSetId,
turn_index: turnIndex,
created_at: createdAt
};
const normalizedDateScope = {
as_of_date: toNonEmptyString(filters.as_of_date),
period_from: toNonEmptyString(filters.period_from),
period_to: toNonEmptyString(filters.period_to)
};
const organizationScope = toNonEmptyString(filters.organization);
const nextResultSets = capResultSets(
[...state.result_sets.filter((itemSet) => itemSet.result_set_id !== resultSetId), resultSet].sort(
(left, right) => left.created_from_turn - right.created_from_turn
)
);
const nextEvents = capNavigationEvents([...state.navigation_history, navigationEvent]);
return {
...state,
updated_at: createdAt,
session_context: {
active_result_set_id: resultSetId,
active_focus_object: focusObject ?? state.session_context.active_focus_object,
last_confirmed_route: routeId ?? state.session_context.last_confirmed_route,
date_scope: {
as_of_date: normalizedDateScope.as_of_date ?? state.session_context.date_scope.as_of_date,
period_from: normalizedDateScope.period_from ?? state.session_context.date_scope.period_from,
period_to: normalizedDateScope.period_to ?? state.session_context.date_scope.period_to
},
organization_scope: organizationScope ?? state.session_context.organization_scope
},
result_sets: nextResultSets,
navigation_history: nextEvents
};
}
@@ -1,8 +1,12 @@
import {
FEATURE_ASSISTANT_CAPABILITY_ROUTE_GUARD_V1,
FEATURE_ASSISTANT_ADDRESS_QUERY_V1,
FEATURE_ASSISTANT_ADDRESS_QUERY_LIVE_V1
} from "../config";
import type {
AddressCapabilityLayer,
AddressCapabilityRouteMode,
AddressShadowRouteStatus,
AddressAsOfDateBasis,
AddressEvidenceStrength,
AddressExecutionResult,
@@ -25,6 +29,11 @@ import { executeAddressMcpQuery } from "./addressMcpClient";
import { runAddressDecomposeStage, type AddressFollowupContext } from "./address_runtime/decomposeStage";
import { resolvePrimaryAnchor, refineAnchorFromRows, type AnchorResolutionDebug } from "./address_runtime/resolveStage";
import { composeFactualReply, inferReplyType, type ComposeReplySemantics } from "./address_runtime/composeStage";
import {
isCapabilityRouteBlocked,
resolveAddressCapabilityRouteDecision,
resolveShadowRouteIntent
} from "./addressCapabilityPolicy";
interface NormalizedAddressRow {
period: string | null;
@@ -40,6 +49,20 @@ interface AddressTryHandleOptions {
analysisDateHint?: string | null;
}
interface AddressCapabilityAudit {
capabilityId: string;
layer: AddressCapabilityLayer;
routeMode: AddressCapabilityRouteMode;
enabled: boolean;
reason: string;
}
interface AddressShadowRouteAudit {
intent: AddressIntent | null;
selectedRecipe: string | null;
status: AddressShadowRouteStatus;
}
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;
@@ -904,6 +927,45 @@ function withConfirmedBalanceFallbackReason(
return [...reasons, "confirmed_balance_unavailable_fallback_to_heuristic_candidates"];
}
function buildCapabilityAudit(intent: AddressIntent): AddressCapabilityAudit {
const decision = resolveAddressCapabilityRouteDecision(intent);
return {
capabilityId: decision.capability_id,
layer: decision.capability_layer,
routeMode: decision.capability_route_mode,
enabled: decision.capability_route_enabled,
reason: decision.capability_route_reason
};
}
function buildShadowRouteAudit(input: {
intent: AddressIntent;
requestedResultMode: AddressResultMode | undefined;
filters: AddressFilterSet;
}): AddressShadowRouteAudit {
const shadowIntent = resolveShadowRouteIntent(input.intent, input.requestedResultMode);
if (!shadowIntent) {
return {
intent: null,
selectedRecipe: null,
status: "skipped"
};
}
const shadowRecipeSelection = selectAddressRecipe(shadowIntent, input.filters);
if (!shadowRecipeSelection.selected_recipe) {
return {
intent: shadowIntent,
selectedRecipe: null,
status: "unavailable"
};
}
return {
intent: shadowIntent,
selectedRecipe: shadowRecipeSelection.selected_recipe.recipe_id,
status: "planned"
};
}
function enforceStrictAccountScopeForIntent(
plan: AddressRecipeExecutionPlan,
intent: AddressIntent
@@ -1701,6 +1763,8 @@ function buildLimitedExecutionResult(input: {
reasonText: string;
nextStep?: string;
category: AddressLimitedReasonCategory;
capabilityAudit?: AddressCapabilityAudit;
shadowRouteAudit?: AddressShadowRouteAudit;
}): AddressExecutionResult {
const accountScopeAudit = input.accountScopeAudit ?? buildDefaultAccountScopeAudit(input.filters);
const resultSemantics = deriveAddressResultSemantics({
@@ -1772,6 +1836,14 @@ function buildLimitedExecutionResult(input: {
runtime_readiness: runtimeReadinessForLimitedCategory(input.category),
limited_reason_category: input.category,
response_type: "LIMITED_WITH_REASON",
capability_id: input.capabilityAudit?.capabilityId ?? null,
capability_layer: input.capabilityAudit?.layer ?? null,
capability_route_mode: input.capabilityAudit?.routeMode ?? null,
capability_route_enabled: input.capabilityAudit?.enabled ?? true,
capability_route_reason: input.capabilityAudit?.reason ?? null,
shadow_route_intent: input.shadowRouteAudit?.intent ?? null,
shadow_route_selected_recipe: input.shadowRouteAudit?.selectedRecipe ?? null,
shadow_route_status: input.shadowRouteAudit?.status ?? "skipped",
...resultSemantics,
limitations: input.limitations,
reasons
@@ -1826,6 +1898,36 @@ export class AddressQueryService {
baseReasons.push("as_of_date_derived_for_confirmed_payables");
}
}
const capabilityDecision = resolveAddressCapabilityRouteDecision(intent.intent);
const capabilityAudit = buildCapabilityAudit(intent.intent);
const shadowRouteAudit = buildShadowRouteAudit({
intent: intent.intent,
requestedResultMode,
filters: executionFilters
});
if (isCapabilityRouteBlocked(capabilityDecision)) {
return buildLimitedExecutionResult({
mode,
shape,
intent,
filters: executionFilters,
missingRequiredFilters: [],
selectedRecipe: null,
mcpCallStatus: "skipped",
rowsFetched: 0,
rowsMatched: 0,
category: "unsupported",
reasonText: "маршрут capability временно отключен feature-флагом",
nextStep: "включите capability route или используйте соседний поддерживаемый сценарий",
limitations: ["capability_route_disabled_by_flag"],
reasons: [
...baseReasons,
FEATURE_ASSISTANT_CAPABILITY_ROUTE_GUARD_V1 ? "capability_route_guard_blocked" : "capability_route_guard_skipped"
],
capabilityAudit,
shadowRouteAudit
});
}
const composeOptionsFromFilters = (filterSet: AddressFilterSet) => ({
userMessage,
periodFrom: typeof filterSet.period_from === "string" ? filterSet.period_from : undefined,
@@ -1889,7 +1991,9 @@ export class AddressQueryService {
reasonText: "сценарий пока вне поддерживаемого контура текущего адресного режима",
nextStep: "могу проверить близкие сценарии: документы/платежи по контрагенту, договоры или остаток по счету",
limitations: ["intent_not_supported_in_v1"],
reasons: baseReasons
reasons: baseReasons,
capabilityAudit,
shadowRouteAudit
});
}
@@ -1909,7 +2013,9 @@ export class AddressQueryService {
reasonText: "для этого сценария пока нет готового шаблона выборки в текущем режиме",
nextStep: "можно выбрать близкий поддерживаемый сценарий или переключить запрос в режим расширенной проверки",
limitations: ["recipe_not_available"],
reasons: [...baseReasons, ...recipeSelection.selection_reason]
reasons: [...baseReasons, ...recipeSelection.selection_reason],
capabilityAudit,
shadowRouteAudit
});
}
@@ -1929,7 +2035,9 @@ export class AddressQueryService {
reasonText: "не хватает обязательных фильтров",
nextStep: `уточните: ${recipeSelection.missing_required_filters.join(", ")}`,
limitations: ["missing_required_filters"],
reasons: [...baseReasons, ...recipeSelection.selection_reason]
reasons: [...baseReasons, ...recipeSelection.selection_reason],
capabilityAudit,
shadowRouteAudit
});
}
@@ -1949,7 +2057,9 @@ export class AddressQueryService {
reasonText: "live address lane выключен feature-флагом",
nextStep: "включите FEATURE_ASSISTANT_ADDRESS_QUERY_LIVE_V1",
limitations: ["address_live_lane_disabled"],
reasons: baseReasons
reasons: baseReasons,
capabilityAudit,
shadowRouteAudit
});
}
@@ -2087,7 +2197,9 @@ export class AddressQueryService {
reasonText: "live MCP вызов завершился ошибкой",
nextStep: mcp.error,
limitations: ["mcp_call_failed"],
reasons: [...baseReasons, mcp.error]
reasons: [...baseReasons, mcp.error],
capabilityAudit,
shadowRouteAudit
});
}
@@ -2862,7 +2974,9 @@ export class AddressQueryService {
reasonText,
nextStep,
limitations,
reasons: baseReasons
reasons: baseReasons,
capabilityAudit,
shadowRouteAudit
});
}
@@ -2904,7 +3018,9 @@ export class AddressQueryService {
reasonText: "exact payables mode: confirmed balance was not proven for the requested as-of slice",
nextStep: "specify as_of_date/counterparty or enable detailed settlement registers for exact confirmed balance",
limitations: ["exact_payables_mode_unconfirmed_output_blocked"],
reasons: [...baseReasons, "exact_payables_mode_unconfirmed_output_blocked"]
reasons: [...baseReasons, "exact_payables_mode_unconfirmed_output_blocked"],
capabilityAudit,
shadowRouteAudit
});
}
return {
@@ -2949,6 +3065,14 @@ export class AddressQueryService {
runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS",
limited_reason_category: null,
response_type: factual.responseType,
capability_id: capabilityAudit.capabilityId,
capability_layer: capabilityAudit.layer,
capability_route_mode: capabilityAudit.routeMode,
capability_route_enabled: capabilityAudit.enabled,
capability_route_reason: capabilityAudit.reason,
shadow_route_intent: shadowRouteAudit.intent,
shadow_route_selected_recipe: shadowRouteAudit.selectedRecipe,
shadow_route_status: shadowRouteAudit.status,
...factualResultSemantics,
limitations: filters.warnings,
reasons: withConfirmedBalanceFallbackReason(
@@ -1419,6 +1419,14 @@ function buildAddressDebugPayload(addressDebug, llmPreDecomposeMeta = null) {
evidence_strength: addressDebug.evidence_strength ?? undefined,
balance_confirmed: typeof addressDebug.balance_confirmed === "boolean" ? addressDebug.balance_confirmed : undefined,
as_of_date_basis: addressDebug.as_of_date_basis ?? undefined,
capability_id: addressDebug.capability_id ?? undefined,
capability_layer: addressDebug.capability_layer ?? undefined,
capability_route_mode: addressDebug.capability_route_mode ?? undefined,
capability_route_enabled: typeof addressDebug.capability_route_enabled === "boolean" ? addressDebug.capability_route_enabled : undefined,
capability_route_reason: addressDebug.capability_route_reason ?? undefined,
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,
execution_lane: "address_query",
llm_decomposition_applied: Boolean(llmMeta?.applied),
llm_decomposition_attempted: Boolean(llmMeta?.attempted),
@@ -36,6 +36,7 @@ interface AssistantSessionLogRecord {
trace_ids: string[];
reply_types: AssistantReplyType[];
investigation_state: AssistantSessionState["investigation_state"];
address_navigation_state: AssistantSessionState["address_navigation_state"];
turns: AssistantTurnLogRecord[];
conversation: AssistantConversationItem[];
last_assistant: {
@@ -247,6 +248,7 @@ export class AssistantSessionLogger {
trace_ids: traceIds,
reply_types: replyTypes,
investigation_state: session.investigation_state,
address_navigation_state: session.address_navigation_state,
turns,
conversation: session.items,
last_assistant: {
@@ -1,8 +1,18 @@
import { nanoid } from "nanoid";
import type { AssistantConversationItem, AssistantSessionState } from "../types/assistant";
import type { InvestigationState } from "../types/stage1Contracts";
import { FEATURE_ASSISTANT_INVESTIGATION_STATE_V1 } from "../config";
import type { AddressNavigationState } from "../types/addressNavigation";
import {
FEATURE_ASSISTANT_ADDRESS_NAVIGATION_STATE_V1,
FEATURE_ASSISTANT_INVESTIGATION_STATE_V1
} from "../config";
import { cloneInvestigationState, createEmptyInvestigationState } from "./investigationState";
import {
cloneAddressNavigationState,
createEmptyAddressNavigationState,
evolveAddressNavigationStateWithAssistantItem,
normalizeAddressNavigationState
} from "./addressNavigationState";
const MAX_ITEMS_PER_SESSION = 200;
@@ -18,13 +28,15 @@ function cloneSession(state: AssistantSessionState): AssistantSessionState {
session_id: state.session_id,
updated_at: state.updated_at,
items: state.items.map(cloneItem),
investigation_state: cloneInvestigationState(state.investigation_state)
investigation_state: cloneInvestigationState(state.investigation_state),
address_navigation_state: cloneAddressNavigationState(state.address_navigation_state)
};
}
function normalizeSessionShape(state: AssistantSessionState): AssistantSessionState {
const legacy = state as AssistantSessionState & {
investigation_state?: InvestigationState | null;
address_navigation_state?: AddressNavigationState | null;
items?: AssistantConversationItem[];
updated_at?: string;
};
@@ -33,10 +45,18 @@ function normalizeSessionShape(state: AssistantSessionState): AssistantSessionSt
FEATURE_ASSISTANT_INVESTIGATION_STATE_V1
? legacy.investigation_state ?? createEmptyInvestigationState(state.session_id)
: legacy.investigation_state ?? null;
const addressNavigationState =
FEATURE_ASSISTANT_ADDRESS_NAVIGATION_STATE_V1
? normalizeAddressNavigationState(
legacy.address_navigation_state ?? createEmptyAddressNavigationState(state.session_id),
state.session_id
)
: legacy.address_navigation_state ?? null;
state.items = normalizedItems;
state.updated_at = typeof legacy.updated_at === "string" && legacy.updated_at.trim() ? legacy.updated_at : new Date().toISOString();
state.investigation_state = investigationState;
state.address_navigation_state = addressNavigationState;
return state;
}
@@ -53,7 +73,8 @@ export class AssistantSessionStore {
session_id: resolvedId,
updated_at: new Date().toISOString(),
items: [],
investigation_state: FEATURE_ASSISTANT_INVESTIGATION_STATE_V1 ? createEmptyInvestigationState(resolvedId) : null
investigation_state: FEATURE_ASSISTANT_INVESTIGATION_STATE_V1 ? createEmptyInvestigationState(resolvedId) : null,
address_navigation_state: FEATURE_ASSISTANT_ADDRESS_NAVIGATION_STATE_V1 ? createEmptyAddressNavigationState(resolvedId) : null
};
this.sessions.set(resolvedId, created);
return cloneSession(created);
@@ -65,6 +86,14 @@ export class AssistantSessionStore {
if (session.items.length > MAX_ITEMS_PER_SESSION) {
session.items = session.items.slice(session.items.length - MAX_ITEMS_PER_SESSION);
}
if (FEATURE_ASSISTANT_ADDRESS_NAVIGATION_STATE_V1) {
const baseNavigationState = normalizeAddressNavigationState(session.address_navigation_state, sessionId);
session.address_navigation_state = evolveAddressNavigationStateWithAssistantItem(
baseNavigationState,
item,
Math.max(0, session.items.length - 1)
);
}
session.updated_at = new Date().toISOString();
return cloneItem(item);
}
@@ -81,6 +110,25 @@ export class AssistantSessionStore {
return cloneInvestigationState(session.investigation_state);
}
public getAddressNavigationState(sessionId: string): AddressNavigationState | null {
const session = this.ensureMutableSession(sessionId);
if (!FEATURE_ASSISTANT_ADDRESS_NAVIGATION_STATE_V1) {
return null;
}
return cloneAddressNavigationState(session.address_navigation_state);
}
public setAddressNavigationState(sessionId: string, state: AddressNavigationState | null): AddressNavigationState | null {
const session = this.ensureMutableSession(sessionId);
if (!FEATURE_ASSISTANT_ADDRESS_NAVIGATION_STATE_V1) {
session.address_navigation_state = null;
return null;
}
session.address_navigation_state = normalizeAddressNavigationState(state, sessionId);
session.updated_at = new Date().toISOString();
return cloneAddressNavigationState(session.address_navigation_state);
}
private ensureMutableSession(sessionId: string): AssistantSessionState {
const existing = this.sessions.get(sessionId);
if (existing) {
@@ -90,7 +138,8 @@ export class AssistantSessionStore {
session_id: sessionId,
updated_at: new Date().toISOString(),
items: [],
investigation_state: FEATURE_ASSISTANT_INVESTIGATION_STATE_V1 ? createEmptyInvestigationState(sessionId) : null
investigation_state: FEATURE_ASSISTANT_INVESTIGATION_STATE_V1 ? createEmptyInvestigationState(sessionId) : null,
address_navigation_state: FEATURE_ASSISTANT_ADDRESS_NAVIGATION_STATE_V1 ? createEmptyAddressNavigationState(sessionId) : null
};
this.sessions.set(sessionId, created);
return created;