ГЛОБАЛЬНЫЙ РЕФАКТОРИНГ АРХИТЕКТУРЫ - Архитектурный фундамент: capability-guard, navigation state и трассируемый route-контракт
This commit is contained in:
parent
fbd156e58e
commit
c10260dedf
|
|
@ -0,0 +1,103 @@
|
|||
# ARCH Layer Foundation (Safe Multi-Layer Evolution)
|
||||
|
||||
## Purpose
|
||||
|
||||
This foundation prepares the assistant runtime for independent evolution of:
|
||||
|
||||
1. `compute` layer (deterministic financial routes),
|
||||
2. `navigation` layer (result-set and drilldown state),
|
||||
3. `conversational` layer (language, formatting, clarification UX).
|
||||
|
||||
The goal is to make fixes in one layer without accidental regressions in the others.
|
||||
|
||||
## Implemented Building Blocks
|
||||
|
||||
### 1) Capability Route Policy (`compute` boundary)
|
||||
|
||||
Files:
|
||||
|
||||
- `llm_normalizer/backend/src/services/addressCapabilityPolicy.ts`
|
||||
- `llm_normalizer/backend/src/config.ts` (new per-capability flags)
|
||||
- `llm_normalizer/backend/src/services/addressQueryService.ts` (guard integration)
|
||||
|
||||
What it gives:
|
||||
|
||||
- explicit capability identity per intent (`capability_id`),
|
||||
- explicit layer classification (`compute` / `navigation` / `conversational`),
|
||||
- route-mode marker (`exact` / `heuristic`),
|
||||
- hard guard by feature flags (route can be disabled safely),
|
||||
- shadow-route planning hook for controlled rollout.
|
||||
|
||||
### 2) Address Navigation State (`navigation` boundary)
|
||||
|
||||
Files:
|
||||
|
||||
- `llm_normalizer/backend/src/types/addressNavigation.ts`
|
||||
- `llm_normalizer/backend/src/services/addressNavigationState.ts`
|
||||
- `llm_normalizer/backend/src/services/assistantSessionStore.ts`
|
||||
- `llm_normalizer/backend/src/services/assistantSessionLogger.ts`
|
||||
|
||||
What it gives:
|
||||
|
||||
- first-class `session_context` with:
|
||||
- `active_result_set_id`,
|
||||
- `active_focus_object`,
|
||||
- `last_confirmed_route`,
|
||||
- date/org scope carryover,
|
||||
- persisted `result_sets` with entity refs parsed from assistant lists,
|
||||
- persisted `navigation_history` (`open` / `drilldown` / `refine`),
|
||||
- backward-compatible session normalization.
|
||||
|
||||
### 3) Runtime Traceability (`cross-layer safety`)
|
||||
|
||||
Files:
|
||||
|
||||
- `llm_normalizer/backend/src/types/addressQuery.ts`
|
||||
- `llm_normalizer/backend/src/types/assistant.ts`
|
||||
- `llm_normalizer/backend/src/services/addressQueryService.ts`
|
||||
- `llm_normalizer/backend/src/services/assistantService.ts`
|
||||
|
||||
What it gives in debug payload:
|
||||
|
||||
- `capability_id`,
|
||||
- `capability_layer`,
|
||||
- `capability_route_mode`,
|
||||
- `capability_route_enabled`,
|
||||
- `capability_route_reason`,
|
||||
- `shadow_route_intent`,
|
||||
- `shadow_route_selected_recipe`,
|
||||
- `shadow_route_status`.
|
||||
|
||||
This makes route regressions observable and testable.
|
||||
|
||||
## Guard-Rail Flags
|
||||
|
||||
New flags in `config.ts`:
|
||||
|
||||
- `FEATURE_ASSISTANT_ADDRESS_NAVIGATION_STATE_V1`
|
||||
- `FEATURE_ASSISTANT_CAPABILITY_ROUTE_GUARD_V1`
|
||||
- `FEATURE_ASSISTANT_ROUTE_ADDRESS_GENERIC_V1`
|
||||
- `FEATURE_ASSISTANT_ROUTE_DRILLDOWN_V1`
|
||||
- `FEATURE_ASSISTANT_ROUTE_BALANCE_EXACT_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`
|
||||
|
||||
## Validation Coverage Added
|
||||
|
||||
Tests:
|
||||
|
||||
- `llm_normalizer/backend/tests/addressCapabilityPolicy.test.ts`
|
||||
- `llm_normalizer/backend/tests/addressNavigationState.test.ts`
|
||||
- `llm_normalizer/backend/tests/sessionBackwardCompat.test.ts` (extended)
|
||||
|
||||
## Why This Is a Foundation, Not a Patch
|
||||
|
||||
This change does not only tune one scenario. It introduces stable contracts:
|
||||
|
||||
- capability contracts for compute routing,
|
||||
- persistent navigation model for multi-step dialog chains,
|
||||
- explicit debug semantics to separate route decisions from answer wording.
|
||||
|
||||
This is the base for parallel development across layers with lower regression risk.
|
||||
|
|
@ -3,8 +3,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.EVAL_DATASETS_DIR = exports.REPORTS_DIR = exports.PROMPTS_DIR = exports.AUTORUN_GENERATOR_HISTORY_FILE = exports.AUTORUN_GENERATOR_DIR = exports.AUTORUN_ANNOTATIONS_FILE = exports.AUTORUN_ANNOTATIONS_DIR = exports.ASSISTANT_ANNOTATIONS_FILE = exports.ASSISTANT_ANNOTATIONS_DIR = exports.ASSISTANT_SESSIONS_DIR = exports.EVAL_CASES_DIR = exports.PRESETS_DIR = exports.TRACES_DIR = exports.DATA_DIR = exports.VAT_PAYABLE_19_PREFIXES = exports.VAT_PAYABLE_68_PREFIXES = exports.ASSISTANT_MCP_LIVE_LIMIT = exports.ASSISTANT_MCP_TIMEOUT_MS = exports.ASSISTANT_MCP_CHANNEL = exports.ASSISTANT_MCP_PROXY_URL = exports.FEATURE_ASSISTANT_LIVING_CHAT_ROUTER_V1 = exports.FEATURE_ASSISTANT_ADDRESS_QUERY_LIVE_V1 = exports.FEATURE_ASSISTANT_ADDRESS_QUERY_LLM_PREDECOMPOSE_V1 = exports.FEATURE_ASSISTANT_ADDRESS_QUERY_V1 = exports.FEATURE_ASSISTANT_MCP_RUNTIME_V1 = exports.FEATURE_ASSISTANT_GRAPH_RUNTIME_V1 = exports.FEATURE_ASSISTANT_LIFECYCLE_ANSWER_V1 = exports.FEATURE_ASSISTANT_LIFECYCLE_RUNTIME_V1 = exports.FEATURE_ASSISTANT_STAGE2_EVAL_V1 = exports.FEATURE_ASSISTANT_PROBLEM_UNIT_CONTINUITY_V1 = exports.FEATURE_ASSISTANT_PROBLEM_CENTRIC_ANSWER_V1 = exports.FEATURE_ASSISTANT_PROBLEM_UNITS_V1 = exports.FEATURE_ASSISTANT_ACCOUNTANT_EVAL_V1 = exports.FEATURE_ASSISTANT_ANSWER_POLICY_V11 = exports.FEATURE_ASSISTANT_ANTI_GENERIC_RANKING_GUARD_V1 = exports.FEATURE_ASSISTANT_MIN_EVIDENCE_GATE_V1 = exports.FEATURE_ASSISTANT_BROAD_GUARD_V1 = exports.FEATURE_ASSISTANT_EVIDENCE_ENRICHMENT_V1 = exports.FEATURE_ASSISTANT_STATE_FOLLOWUP_BINDING_V1 = exports.FEATURE_ASSISTANT_CONTRACTS_V11 = exports.FEATURE_ASSISTANT_INVESTIGATION_STATE_V1 = exports.DEFAULT_PROMPT_VERSION = exports.DEFAULT_MAX_OUTPUT_TOKENS = exports.DEFAULT_TEMPERATURE = exports.DEFAULT_MODEL = exports.DEFAULT_OPENAI_BASE_URL = exports.TIMEZONE = exports.PORT = exports.MODULE_ROOT = exports.BACKEND_ROOT = void 0;
|
||||
exports.MANUAL_CASE_DECISION_SCHEMA_FILE = exports.ASSISTANT_CAPABILITIES_REGISTRY_FILE = exports.ASSISTANT_CANON_FILE = exports.ARCH_EXPORT_2020_DIR = exports.SCHEMAS_DIR = void 0;
|
||||
exports.ASSISTANT_SESSIONS_DIR = exports.EVAL_CASES_DIR = exports.PRESETS_DIR = exports.TRACES_DIR = exports.DATA_DIR = exports.VAT_PAYABLE_19_PREFIXES = exports.VAT_PAYABLE_68_PREFIXES = exports.ASSISTANT_MCP_LIVE_LIMIT = exports.ASSISTANT_MCP_TIMEOUT_MS = exports.ASSISTANT_MCP_CHANNEL = exports.ASSISTANT_MCP_PROXY_URL = exports.FEATURE_ASSISTANT_LIVING_CHAT_ROUTER_V1 = exports.FEATURE_ASSISTANT_ROUTE_SHADOW_PAYABLES_EXACT_V1 = exports.FEATURE_ASSISTANT_ROUTE_RECEIVABLES_HEURISTIC_V1 = exports.FEATURE_ASSISTANT_ROUTE_PAYABLES_HEURISTIC_V1 = exports.FEATURE_ASSISTANT_ROUTE_PAYABLES_CONFIRMED_V1 = exports.FEATURE_ASSISTANT_ROUTE_BALANCE_EXACT_V1 = exports.FEATURE_ASSISTANT_ROUTE_DRILLDOWN_V1 = exports.FEATURE_ASSISTANT_ROUTE_ADDRESS_GENERIC_V1 = exports.FEATURE_ASSISTANT_CAPABILITY_ROUTE_GUARD_V1 = exports.FEATURE_ASSISTANT_ADDRESS_NAVIGATION_STATE_V1 = exports.FEATURE_ASSISTANT_ADDRESS_QUERY_LIVE_V1 = exports.FEATURE_ASSISTANT_ADDRESS_QUERY_LLM_PREDECOMPOSE_V1 = exports.FEATURE_ASSISTANT_ADDRESS_QUERY_V1 = exports.FEATURE_ASSISTANT_MCP_RUNTIME_V1 = exports.FEATURE_ASSISTANT_GRAPH_RUNTIME_V1 = exports.FEATURE_ASSISTANT_LIFECYCLE_ANSWER_V1 = exports.FEATURE_ASSISTANT_LIFECYCLE_RUNTIME_V1 = exports.FEATURE_ASSISTANT_STAGE2_EVAL_V1 = exports.FEATURE_ASSISTANT_PROBLEM_UNIT_CONTINUITY_V1 = exports.FEATURE_ASSISTANT_PROBLEM_CENTRIC_ANSWER_V1 = exports.FEATURE_ASSISTANT_PROBLEM_UNITS_V1 = exports.FEATURE_ASSISTANT_ACCOUNTANT_EVAL_V1 = exports.FEATURE_ASSISTANT_ANSWER_POLICY_V11 = exports.FEATURE_ASSISTANT_ANTI_GENERIC_RANKING_GUARD_V1 = exports.FEATURE_ASSISTANT_MIN_EVIDENCE_GATE_V1 = exports.FEATURE_ASSISTANT_BROAD_GUARD_V1 = exports.FEATURE_ASSISTANT_EVIDENCE_ENRICHMENT_V1 = exports.FEATURE_ASSISTANT_STATE_FOLLOWUP_BINDING_V1 = exports.FEATURE_ASSISTANT_CONTRACTS_V11 = exports.FEATURE_ASSISTANT_INVESTIGATION_STATE_V1 = exports.DEFAULT_PROMPT_VERSION = exports.DEFAULT_MAX_OUTPUT_TOKENS = exports.DEFAULT_TEMPERATURE = exports.DEFAULT_MODEL = exports.DEFAULT_OPENAI_BASE_URL = exports.TIMEZONE = exports.PORT = exports.MODULE_ROOT = exports.BACKEND_ROOT = void 0;
|
||||
exports.MANUAL_CASE_DECISION_SCHEMA_FILE = exports.ASSISTANT_CAPABILITIES_REGISTRY_FILE = exports.ASSISTANT_CANON_FILE = exports.ARCH_EXPORT_2020_DIR = exports.SCHEMAS_DIR = exports.EVAL_DATASETS_DIR = exports.REPORTS_DIR = exports.PROMPTS_DIR = exports.AUTORUN_GENERATOR_HISTORY_FILE = exports.AUTORUN_GENERATOR_DIR = exports.AUTORUN_ANNOTATIONS_FILE = exports.AUTORUN_ANNOTATIONS_DIR = exports.ASSISTANT_ANNOTATIONS_FILE = exports.ASSISTANT_ANNOTATIONS_DIR = void 0;
|
||||
const path_1 = __importDefault(require("path"));
|
||||
exports.BACKEND_ROOT = path_1.default.resolve(__dirname, "..");
|
||||
exports.MODULE_ROOT = path_1.default.resolve(exports.BACKEND_ROOT, "..");
|
||||
|
|
@ -60,6 +60,15 @@ exports.FEATURE_ASSISTANT_MCP_RUNTIME_V1 = toBooleanFlag(process.env.FEATURE_ASS
|
|||
exports.FEATURE_ASSISTANT_ADDRESS_QUERY_V1 = toBooleanFlag(process.env.FEATURE_ASSISTANT_ADDRESS_QUERY_V1, true);
|
||||
exports.FEATURE_ASSISTANT_ADDRESS_QUERY_LLM_PREDECOMPOSE_V1 = toBooleanFlag(process.env.FEATURE_ASSISTANT_ADDRESS_QUERY_LLM_PREDECOMPOSE_V1, true);
|
||||
exports.FEATURE_ASSISTANT_ADDRESS_QUERY_LIVE_V1 = toBooleanFlag(process.env.FEATURE_ASSISTANT_ADDRESS_QUERY_LIVE_V1, true);
|
||||
exports.FEATURE_ASSISTANT_ADDRESS_NAVIGATION_STATE_V1 = toBooleanFlag(process.env.FEATURE_ASSISTANT_ADDRESS_NAVIGATION_STATE_V1, true);
|
||||
exports.FEATURE_ASSISTANT_CAPABILITY_ROUTE_GUARD_V1 = toBooleanFlag(process.env.FEATURE_ASSISTANT_CAPABILITY_ROUTE_GUARD_V1, true);
|
||||
exports.FEATURE_ASSISTANT_ROUTE_ADDRESS_GENERIC_V1 = toBooleanFlag(process.env.FEATURE_ASSISTANT_ROUTE_ADDRESS_GENERIC_V1, true);
|
||||
exports.FEATURE_ASSISTANT_ROUTE_DRILLDOWN_V1 = toBooleanFlag(process.env.FEATURE_ASSISTANT_ROUTE_DRILLDOWN_V1, true);
|
||||
exports.FEATURE_ASSISTANT_ROUTE_BALANCE_EXACT_V1 = toBooleanFlag(process.env.FEATURE_ASSISTANT_ROUTE_BALANCE_EXACT_V1, true);
|
||||
exports.FEATURE_ASSISTANT_ROUTE_PAYABLES_CONFIRMED_V1 = toBooleanFlag(process.env.FEATURE_ASSISTANT_ROUTE_PAYABLES_CONFIRMED_V1, true);
|
||||
exports.FEATURE_ASSISTANT_ROUTE_PAYABLES_HEURISTIC_V1 = toBooleanFlag(process.env.FEATURE_ASSISTANT_ROUTE_PAYABLES_HEURISTIC_V1, true);
|
||||
exports.FEATURE_ASSISTANT_ROUTE_RECEIVABLES_HEURISTIC_V1 = toBooleanFlag(process.env.FEATURE_ASSISTANT_ROUTE_RECEIVABLES_HEURISTIC_V1, true);
|
||||
exports.FEATURE_ASSISTANT_ROUTE_SHADOW_PAYABLES_EXACT_V1 = toBooleanFlag(process.env.FEATURE_ASSISTANT_ROUTE_SHADOW_PAYABLES_EXACT_V1, false);
|
||||
exports.FEATURE_ASSISTANT_LIVING_CHAT_ROUTER_V1 = toBooleanFlag(process.env.FEATURE_ASSISTANT_LIVING_CHAT_ROUTER_V1, true);
|
||||
exports.ASSISTANT_MCP_PROXY_URL = (process.env.ASSISTANT_MCP_PROXY_URL ?? "http://127.0.0.1:6003").replace(/\/+$/, "");
|
||||
exports.ASSISTANT_MCP_CHANNEL = process.env.ASSISTANT_MCP_CHANNEL ?? "default";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,121 @@
|
|||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.resolveAddressCapabilityRouteDecision = resolveAddressCapabilityRouteDecision;
|
||||
exports.isCapabilityRouteBlocked = isCapabilityRouteBlocked;
|
||||
exports.resolveShadowRouteIntent = resolveShadowRouteIntent;
|
||||
const config_1 = require("../config");
|
||||
const COMPUTE_EXACT_INTENTS = new Set(["account_balance_snapshot", "documents_forming_balance", "payables_confirmed_as_of_date"]);
|
||||
const NAVIGATION_INTENTS = new Set([
|
||||
"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([
|
||||
"list_payables_counterparties",
|
||||
"list_receivables_counterparties",
|
||||
"open_items_by_counterparty_or_contract",
|
||||
"list_open_contracts"
|
||||
]);
|
||||
function isExactComputeIntent(intent) {
|
||||
return COMPUTE_EXACT_INTENTS.has(intent);
|
||||
}
|
||||
function isNavigationIntent(intent) {
|
||||
return NAVIGATION_INTENTS.has(intent);
|
||||
}
|
||||
function isHeuristicListIntent(intent) {
|
||||
return HEURISTIC_LIST_INTENTS.has(intent);
|
||||
}
|
||||
function defaultCapabilityId(intent) {
|
||||
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) {
|
||||
if (intent === "payables_confirmed_as_of_date") {
|
||||
return {
|
||||
enabled: config_1.FEATURE_ASSISTANT_ROUTE_PAYABLES_CONFIRMED_V1,
|
||||
reason: config_1.FEATURE_ASSISTANT_ROUTE_PAYABLES_CONFIRMED_V1 ? "payables_confirmed_route_enabled" : "payables_confirmed_route_disabled_by_flag"
|
||||
};
|
||||
}
|
||||
if (intent === "list_payables_counterparties") {
|
||||
return {
|
||||
enabled: config_1.FEATURE_ASSISTANT_ROUTE_PAYABLES_HEURISTIC_V1,
|
||||
reason: config_1.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: config_1.FEATURE_ASSISTANT_ROUTE_RECEIVABLES_HEURISTIC_V1,
|
||||
reason: config_1.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: config_1.FEATURE_ASSISTANT_ROUTE_BALANCE_EXACT_V1,
|
||||
reason: config_1.FEATURE_ASSISTANT_ROUTE_BALANCE_EXACT_V1 ? "balance_exact_route_enabled" : "balance_exact_route_disabled_by_flag"
|
||||
};
|
||||
}
|
||||
if (isNavigationIntent(intent)) {
|
||||
return {
|
||||
enabled: config_1.FEATURE_ASSISTANT_ROUTE_DRILLDOWN_V1,
|
||||
reason: config_1.FEATURE_ASSISTANT_ROUTE_DRILLDOWN_V1 ? "drilldown_route_enabled" : "drilldown_route_disabled_by_flag"
|
||||
};
|
||||
}
|
||||
return {
|
||||
enabled: config_1.FEATURE_ASSISTANT_ROUTE_ADDRESS_GENERIC_V1,
|
||||
reason: config_1.FEATURE_ASSISTANT_ROUTE_ADDRESS_GENERIC_V1 ? "generic_address_route_enabled" : "generic_address_route_disabled_by_flag"
|
||||
};
|
||||
}
|
||||
function resolveAddressCapabilityRouteDecision(intent) {
|
||||
const capability = defaultCapabilityId(intent);
|
||||
const enabled = resolveCapabilityEnabled(intent);
|
||||
const layer = isNavigationIntent(intent)
|
||||
? "navigation"
|
||||
: isExactComputeIntent(intent) || isHeuristicListIntent(intent)
|
||||
? "compute"
|
||||
: "conversational";
|
||||
const routeMode = 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
|
||||
};
|
||||
}
|
||||
function isCapabilityRouteBlocked(decision) {
|
||||
return config_1.FEATURE_ASSISTANT_CAPABILITY_ROUTE_GUARD_V1 && !decision.capability_route_enabled;
|
||||
}
|
||||
function resolveShadowRouteIntent(intent, requestedResultMode) {
|
||||
if (!config_1.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,391 @@
|
|||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.createEmptyAddressNavigationState = createEmptyAddressNavigationState;
|
||||
exports.cloneAddressNavigationState = cloneAddressNavigationState;
|
||||
exports.normalizeAddressNavigationState = normalizeAddressNavigationState;
|
||||
exports.evolveAddressNavigationStateWithAssistantItem = evolveAddressNavigationStateWithAssistantItem;
|
||||
const nanoid_1 = require("nanoid");
|
||||
const addressNavigation_1 = require("../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 = {
|
||||
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 = {
|
||||
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) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
function toNonEmptyString(value) {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
function toAddressFocusObjectType(value) {
|
||||
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) {
|
||||
const normalized = toNonEmptyString(value);
|
||||
return (normalized ?? "unknown");
|
||||
}
|
||||
function inferDisplayEntityType(intent) {
|
||||
return DISPLAY_ENTITY_TYPE_BY_INTENT[intent] ?? "unknown";
|
||||
}
|
||||
function inferResultSetType(intent) {
|
||||
return RESULT_SET_TYPE_BY_INTENT[intent] ?? "unknown";
|
||||
}
|
||||
function parseEntityCandidateFromLine(line) {
|
||||
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, intent, limit = MAX_ENTITY_REFS_PER_RESULT_SET) {
|
||||
const entityType = inferDisplayEntityType(intent);
|
||||
if (entityType === "unknown") {
|
||||
return [];
|
||||
}
|
||||
const dedup = new Map();
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
const record = toObject(value);
|
||||
if (!record) {
|
||||
return {};
|
||||
}
|
||||
return { ...record };
|
||||
}
|
||||
function resolveNavigationAction(debug, hasFocusObject) {
|
||||
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, resultSetId, createdAt) {
|
||||
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) {
|
||||
if (resultSets.length <= MAX_RESULT_SETS) {
|
||||
return resultSets;
|
||||
}
|
||||
return resultSets.slice(resultSets.length - MAX_RESULT_SETS);
|
||||
}
|
||||
function capNavigationEvents(events) {
|
||||
if (events.length <= MAX_NAVIGATION_EVENTS) {
|
||||
return events;
|
||||
}
|
||||
return events.slice(events.length - MAX_NAVIGATION_EVENTS);
|
||||
}
|
||||
function isAddressAssistantItem(item) {
|
||||
return (item.role === "assistant" &&
|
||||
Boolean(item.debug) &&
|
||||
toNonEmptyString(item.debug?.detected_mode) === "address_query");
|
||||
}
|
||||
function createEmptyAddressNavigationState(sessionId, nowIso = new Date().toISOString()) {
|
||||
return {
|
||||
schema_version: addressNavigation_1.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: []
|
||||
};
|
||||
}
|
||||
function cloneAddressNavigationState(value) {
|
||||
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)
|
||||
};
|
||||
}
|
||||
function normalizeAddressNavigationState(value, sessionId) {
|
||||
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: addressNavigation_1.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),
|
||||
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 !== null)
|
||||
.map((item) => ({
|
||||
result_set_id: toNonEmptyString(item.result_set_id) ?? `rs-${(0, nanoid_1.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) => Boolean(v))
|
||||
: [],
|
||||
entity_refs: Array.isArray(item.entity_refs)
|
||||
? item.entity_refs
|
||||
.map((entry) => toObject(entry))
|
||||
.filter((entry) => 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 !== null)
|
||||
.map((item) => ({
|
||||
event_id: toNonEmptyString(item.event_id) ?? `nav-${(0, nanoid_1.nanoid)(10)}`,
|
||||
action: toNonEmptyString(item.action) ?? "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
|
||||
}))
|
||||
};
|
||||
}
|
||||
function evolveAddressNavigationStateWithAssistantItem(state, item, turnIndex) {
|
||||
if (!isAddressAssistantItem(item) || !item.debug) {
|
||||
return state;
|
||||
}
|
||||
const debug = item.debug;
|
||||
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 = {
|
||||
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 = {
|
||||
event_id: `nav-${(0, nanoid_1.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
|
||||
};
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ const addressMcpClient_1 = require("./addressMcpClient");
|
|||
const decomposeStage_1 = require("./address_runtime/decomposeStage");
|
||||
const resolveStage_1 = require("./address_runtime/resolveStage");
|
||||
const composeStage_1 = require("./address_runtime/composeStage");
|
||||
const addressCapabilityPolicy_1 = require("./addressCapabilityPolicy");
|
||||
const ACCOUNT_SCOPE_FIELDS_CHECKED = ["account_dt", "account_kt", "registrator", "analytics"];
|
||||
const ACCOUNT_SCOPE_MATCH_STRATEGY = "account_code_regex_plus_alias_map_v1";
|
||||
const ADDRESS_ANCHOR_RECOVERY_LIMIT = 1000;
|
||||
|
|
@ -747,6 +748,39 @@ function withConfirmedBalanceFallbackReason(reasons, requestedResultMode, semant
|
|||
}
|
||||
return [...reasons, "confirmed_balance_unavailable_fallback_to_heuristic_candidates"];
|
||||
}
|
||||
function buildCapabilityAudit(intent) {
|
||||
const decision = (0, addressCapabilityPolicy_1.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) {
|
||||
const shadowIntent = (0, addressCapabilityPolicy_1.resolveShadowRouteIntent)(input.intent, input.requestedResultMode);
|
||||
if (!shadowIntent) {
|
||||
return {
|
||||
intent: null,
|
||||
selectedRecipe: null,
|
||||
status: "skipped"
|
||||
};
|
||||
}
|
||||
const shadowRecipeSelection = (0, addressRecipeCatalog_1.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, intent) {
|
||||
if (intent !== "list_receivables_counterparties" || plan.account_scope_mode === "strict") {
|
||||
return plan;
|
||||
|
|
@ -1411,6 +1445,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
|
||||
|
|
@ -1458,6 +1500,36 @@ class AddressQueryService {
|
|||
baseReasons.push("as_of_date_derived_for_confirmed_payables");
|
||||
}
|
||||
}
|
||||
const capabilityDecision = (0, addressCapabilityPolicy_1.resolveAddressCapabilityRouteDecision)(intent.intent);
|
||||
const capabilityAudit = buildCapabilityAudit(intent.intent);
|
||||
const shadowRouteAudit = buildShadowRouteAudit({
|
||||
intent: intent.intent,
|
||||
requestedResultMode,
|
||||
filters: executionFilters
|
||||
});
|
||||
if ((0, addressCapabilityPolicy_1.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,
|
||||
config_1.FEATURE_ASSISTANT_CAPABILITY_ROUTE_GUARD_V1 ? "capability_route_guard_blocked" : "capability_route_guard_skipped"
|
||||
],
|
||||
capabilityAudit,
|
||||
shadowRouteAudit
|
||||
});
|
||||
}
|
||||
const composeOptionsFromFilters = (filterSet) => ({
|
||||
userMessage,
|
||||
periodFrom: typeof filterSet.period_from === "string" ? filterSet.period_from : undefined,
|
||||
|
|
@ -1515,7 +1587,9 @@ class AddressQueryService {
|
|||
reasonText: "сценарий пока вне поддерживаемого контура текущего адресного режима",
|
||||
nextStep: "могу проверить близкие сценарии: документы/платежи по контрагенту, договоры или остаток по счету",
|
||||
limitations: ["intent_not_supported_in_v1"],
|
||||
reasons: baseReasons
|
||||
reasons: baseReasons,
|
||||
capabilityAudit,
|
||||
shadowRouteAudit
|
||||
});
|
||||
}
|
||||
if (recipeSelection.selected_recipe === null) {
|
||||
|
|
@ -1534,7 +1608,9 @@ class AddressQueryService {
|
|||
reasonText: "для этого сценария пока нет готового шаблона выборки в текущем режиме",
|
||||
nextStep: "можно выбрать близкий поддерживаемый сценарий или переключить запрос в режим расширенной проверки",
|
||||
limitations: ["recipe_not_available"],
|
||||
reasons: [...baseReasons, ...recipeSelection.selection_reason]
|
||||
reasons: [...baseReasons, ...recipeSelection.selection_reason],
|
||||
capabilityAudit,
|
||||
shadowRouteAudit
|
||||
});
|
||||
}
|
||||
if (recipeSelection.missing_required_filters.length > 0) {
|
||||
|
|
@ -1553,7 +1629,9 @@ 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
|
||||
});
|
||||
}
|
||||
if (!config_1.FEATURE_ASSISTANT_ADDRESS_QUERY_LIVE_V1) {
|
||||
|
|
@ -1572,7 +1650,9 @@ 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
|
||||
});
|
||||
}
|
||||
const rawCounterpartyAnchor = typeof filters.extracted_filters.counterparty === "string" ? filters.extracted_filters.counterparty.trim() : "";
|
||||
|
|
@ -1695,7 +1775,9 @@ class AddressQueryService {
|
|||
reasonText: "live MCP вызов завершился ошибкой",
|
||||
nextStep: mcp.error,
|
||||
limitations: ["mcp_call_failed"],
|
||||
reasons: [...baseReasons, mcp.error]
|
||||
reasons: [...baseReasons, mcp.error],
|
||||
capabilityAudit,
|
||||
shadowRouteAudit
|
||||
});
|
||||
}
|
||||
const normalizedRawRows = toNormalizedRows(mcp.raw_rows);
|
||||
|
|
@ -2356,7 +2438,9 @@ class AddressQueryService {
|
|||
reasonText,
|
||||
nextStep,
|
||||
limitations,
|
||||
reasons: baseReasons
|
||||
reasons: baseReasons,
|
||||
capabilityAudit,
|
||||
shadowRouteAudit
|
||||
});
|
||||
}
|
||||
const factual = (0, composeStage_1.composeFactualReply)(intent.intent, filteredRows, composeOptionsFromFilters(executionFilters));
|
||||
|
|
@ -2394,7 +2478,9 @@ 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 {
|
||||
|
|
@ -2439,6 +2525,14 @@ 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(baseReasons, requestedResultMode, factual.semantics, factualResultSemantics.result_mode)
|
||||
|
|
|
|||
|
|
@ -1465,6 +1465,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),
|
||||
|
|
|
|||
|
|
@ -184,6 +184,7 @@ 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: {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ exports.AssistantSessionStore = void 0;
|
|||
const nanoid_1 = require("nanoid");
|
||||
const config_1 = require("../config");
|
||||
const investigationState_1 = require("./investigationState");
|
||||
const addressNavigationState_1 = require("./addressNavigationState");
|
||||
const MAX_ITEMS_PER_SESSION = 200;
|
||||
function cloneItem(item) {
|
||||
return {
|
||||
|
|
@ -16,7 +17,8 @@ function cloneSession(state) {
|
|||
session_id: state.session_id,
|
||||
updated_at: state.updated_at,
|
||||
items: state.items.map(cloneItem),
|
||||
investigation_state: (0, investigationState_1.cloneInvestigationState)(state.investigation_state)
|
||||
investigation_state: (0, investigationState_1.cloneInvestigationState)(state.investigation_state),
|
||||
address_navigation_state: (0, addressNavigationState_1.cloneAddressNavigationState)(state.address_navigation_state)
|
||||
};
|
||||
}
|
||||
function normalizeSessionShape(state) {
|
||||
|
|
@ -25,9 +27,13 @@ function normalizeSessionShape(state) {
|
|||
const investigationState = config_1.FEATURE_ASSISTANT_INVESTIGATION_STATE_V1
|
||||
? legacy.investigation_state ?? (0, investigationState_1.createEmptyInvestigationState)(state.session_id)
|
||||
: legacy.investigation_state ?? null;
|
||||
const addressNavigationState = config_1.FEATURE_ASSISTANT_ADDRESS_NAVIGATION_STATE_V1
|
||||
? (0, addressNavigationState_1.normalizeAddressNavigationState)(legacy.address_navigation_state ?? (0, addressNavigationState_1.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;
|
||||
}
|
||||
class AssistantSessionStore {
|
||||
|
|
@ -42,7 +48,8 @@ class AssistantSessionStore {
|
|||
session_id: resolvedId,
|
||||
updated_at: new Date().toISOString(),
|
||||
items: [],
|
||||
investigation_state: config_1.FEATURE_ASSISTANT_INVESTIGATION_STATE_V1 ? (0, investigationState_1.createEmptyInvestigationState)(resolvedId) : null
|
||||
investigation_state: config_1.FEATURE_ASSISTANT_INVESTIGATION_STATE_V1 ? (0, investigationState_1.createEmptyInvestigationState)(resolvedId) : null,
|
||||
address_navigation_state: config_1.FEATURE_ASSISTANT_ADDRESS_NAVIGATION_STATE_V1 ? (0, addressNavigationState_1.createEmptyAddressNavigationState)(resolvedId) : null
|
||||
};
|
||||
this.sessions.set(resolvedId, created);
|
||||
return cloneSession(created);
|
||||
|
|
@ -53,6 +60,10 @@ class AssistantSessionStore {
|
|||
if (session.items.length > MAX_ITEMS_PER_SESSION) {
|
||||
session.items = session.items.slice(session.items.length - MAX_ITEMS_PER_SESSION);
|
||||
}
|
||||
if (config_1.FEATURE_ASSISTANT_ADDRESS_NAVIGATION_STATE_V1) {
|
||||
const baseNavigationState = (0, addressNavigationState_1.normalizeAddressNavigationState)(session.address_navigation_state, sessionId);
|
||||
session.address_navigation_state = (0, addressNavigationState_1.evolveAddressNavigationStateWithAssistantItem)(baseNavigationState, item, Math.max(0, session.items.length - 1));
|
||||
}
|
||||
session.updated_at = new Date().toISOString();
|
||||
return cloneItem(item);
|
||||
}
|
||||
|
|
@ -66,6 +77,23 @@ class AssistantSessionStore {
|
|||
session.updated_at = new Date().toISOString();
|
||||
return (0, investigationState_1.cloneInvestigationState)(session.investigation_state);
|
||||
}
|
||||
getAddressNavigationState(sessionId) {
|
||||
const session = this.ensureMutableSession(sessionId);
|
||||
if (!config_1.FEATURE_ASSISTANT_ADDRESS_NAVIGATION_STATE_V1) {
|
||||
return null;
|
||||
}
|
||||
return (0, addressNavigationState_1.cloneAddressNavigationState)(session.address_navigation_state);
|
||||
}
|
||||
setAddressNavigationState(sessionId, state) {
|
||||
const session = this.ensureMutableSession(sessionId);
|
||||
if (!config_1.FEATURE_ASSISTANT_ADDRESS_NAVIGATION_STATE_V1) {
|
||||
session.address_navigation_state = null;
|
||||
return null;
|
||||
}
|
||||
session.address_navigation_state = (0, addressNavigationState_1.normalizeAddressNavigationState)(state, sessionId);
|
||||
session.updated_at = new Date().toISOString();
|
||||
return (0, addressNavigationState_1.cloneAddressNavigationState)(session.address_navigation_state);
|
||||
}
|
||||
ensureMutableSession(sessionId) {
|
||||
const existing = this.sessions.get(sessionId);
|
||||
if (existing) {
|
||||
|
|
@ -75,7 +103,8 @@ class AssistantSessionStore {
|
|||
session_id: sessionId,
|
||||
updated_at: new Date().toISOString(),
|
||||
items: [],
|
||||
investigation_state: config_1.FEATURE_ASSISTANT_INVESTIGATION_STATE_V1 ? (0, investigationState_1.createEmptyInvestigationState)(sessionId) : null
|
||||
investigation_state: config_1.FEATURE_ASSISTANT_INVESTIGATION_STATE_V1 ? (0, investigationState_1.createEmptyInvestigationState)(sessionId) : null,
|
||||
address_navigation_state: config_1.FEATURE_ASSISTANT_ADDRESS_NAVIGATION_STATE_V1 ? (0, addressNavigationState_1.createEmptyAddressNavigationState)(sessionId) : null
|
||||
};
|
||||
this.sessions.set(sessionId, created);
|
||||
return created;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,4 @@
|
|||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.ADDRESS_NAVIGATION_STATE_SCHEMA_VERSION = void 0;
|
||||
exports.ADDRESS_NAVIGATION_STATE_SCHEMA_VERSION = "address_navigation_state_v1";
|
||||
|
|
@ -115,6 +115,42 @@ export const FEATURE_ASSISTANT_ADDRESS_QUERY_LIVE_V1 = toBooleanFlag(
|
|||
process.env.FEATURE_ASSISTANT_ADDRESS_QUERY_LIVE_V1,
|
||||
true
|
||||
);
|
||||
export const FEATURE_ASSISTANT_ADDRESS_NAVIGATION_STATE_V1 = toBooleanFlag(
|
||||
process.env.FEATURE_ASSISTANT_ADDRESS_NAVIGATION_STATE_V1,
|
||||
true
|
||||
);
|
||||
export const FEATURE_ASSISTANT_CAPABILITY_ROUTE_GUARD_V1 = toBooleanFlag(
|
||||
process.env.FEATURE_ASSISTANT_CAPABILITY_ROUTE_GUARD_V1,
|
||||
true
|
||||
);
|
||||
export const FEATURE_ASSISTANT_ROUTE_ADDRESS_GENERIC_V1 = toBooleanFlag(
|
||||
process.env.FEATURE_ASSISTANT_ROUTE_ADDRESS_GENERIC_V1,
|
||||
true
|
||||
);
|
||||
export const FEATURE_ASSISTANT_ROUTE_DRILLDOWN_V1 = toBooleanFlag(
|
||||
process.env.FEATURE_ASSISTANT_ROUTE_DRILLDOWN_V1,
|
||||
true
|
||||
);
|
||||
export const FEATURE_ASSISTANT_ROUTE_BALANCE_EXACT_V1 = toBooleanFlag(
|
||||
process.env.FEATURE_ASSISTANT_ROUTE_BALANCE_EXACT_V1,
|
||||
true
|
||||
);
|
||||
export const FEATURE_ASSISTANT_ROUTE_PAYABLES_CONFIRMED_V1 = toBooleanFlag(
|
||||
process.env.FEATURE_ASSISTANT_ROUTE_PAYABLES_CONFIRMED_V1,
|
||||
true
|
||||
);
|
||||
export const FEATURE_ASSISTANT_ROUTE_PAYABLES_HEURISTIC_V1 = toBooleanFlag(
|
||||
process.env.FEATURE_ASSISTANT_ROUTE_PAYABLES_HEURISTIC_V1,
|
||||
true
|
||||
);
|
||||
export const FEATURE_ASSISTANT_ROUTE_RECEIVABLES_HEURISTIC_V1 = toBooleanFlag(
|
||||
process.env.FEATURE_ASSISTANT_ROUTE_RECEIVABLES_HEURISTIC_V1,
|
||||
true
|
||||
);
|
||||
export const FEATURE_ASSISTANT_ROUTE_SHADOW_PAYABLES_EXACT_V1 = toBooleanFlag(
|
||||
process.env.FEATURE_ASSISTANT_ROUTE_SHADOW_PAYABLES_EXACT_V1,
|
||||
false
|
||||
);
|
||||
export const FEATURE_ASSISTANT_LIVING_CHAT_ROUTER_V1 = toBooleanFlag(
|
||||
process.env.FEATURE_ASSISTANT_LIVING_CHAT_ROUTER_V1,
|
||||
true
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,74 @@
|
|||
import type { AddressIntent } from "./addressQuery";
|
||||
|
||||
export const ADDRESS_NAVIGATION_STATE_SCHEMA_VERSION = "address_navigation_state_v1" as const;
|
||||
|
||||
export type AddressResultSetType =
|
||||
| "counterparty_list"
|
||||
| "contract_list"
|
||||
| "document_list"
|
||||
| "bank_operations_list"
|
||||
| "open_items_list"
|
||||
| "balance_snapshot"
|
||||
| "profile_summary"
|
||||
| "unknown";
|
||||
|
||||
export type AddressFocusObjectType = "counterparty" | "contract" | "document_ref" | "account" | "unknown";
|
||||
|
||||
export type AddressNavigationAction = "open" | "drilldown" | "refine" | "back" | "reset";
|
||||
|
||||
export interface AddressResultEntityRef {
|
||||
index: number;
|
||||
entity_type: AddressFocusObjectType;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface AddressResultSet {
|
||||
result_set_id: string;
|
||||
type: AddressResultSetType;
|
||||
intent: AddressIntent;
|
||||
route_id: string | null;
|
||||
filters: Record<string, unknown>;
|
||||
source_refs: string[];
|
||||
entity_refs: AddressResultEntityRef[];
|
||||
created_from_turn: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface AddressFocusObject {
|
||||
object_type: AddressFocusObjectType;
|
||||
object_id: string;
|
||||
label: string;
|
||||
provenance_result_set_id: string;
|
||||
selected_at: string;
|
||||
}
|
||||
|
||||
export interface AddressNavigationEvent {
|
||||
event_id: string;
|
||||
action: AddressNavigationAction;
|
||||
source_result_set_id: string | null;
|
||||
target_object_id: string | null;
|
||||
derived_result_set_id: string | null;
|
||||
turn_index: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface AddressNavigationSessionContext {
|
||||
active_result_set_id: string | null;
|
||||
active_focus_object: AddressFocusObject | null;
|
||||
last_confirmed_route: string | null;
|
||||
date_scope: {
|
||||
as_of_date: string | null;
|
||||
period_from: string | null;
|
||||
period_to: string | null;
|
||||
};
|
||||
organization_scope: string | null;
|
||||
}
|
||||
|
||||
export interface AddressNavigationState {
|
||||
schema_version: typeof ADDRESS_NAVIGATION_STATE_SCHEMA_VERSION;
|
||||
session_id: string;
|
||||
updated_at: string;
|
||||
session_context: AddressNavigationSessionContext;
|
||||
result_sets: AddressResultSet[];
|
||||
navigation_history: AddressNavigationEvent[];
|
||||
}
|
||||
|
|
@ -28,6 +28,9 @@ export type AddressResponseType = "FACTUAL_LIST" | "FACTUAL_SUMMARY" | "LIMITED_
|
|||
export type AddressResultMode = "heuristic_candidates" | "confirmed_balance";
|
||||
export type AddressEvidenceStrength = "weak" | "medium" | "strong";
|
||||
export type AddressAsOfDateBasis = "period_end" | "explicit_as_of_date" | "period_range";
|
||||
export type AddressCapabilityLayer = "compute" | "navigation" | "conversational";
|
||||
export type AddressCapabilityRouteMode = "exact" | "heuristic";
|
||||
export type AddressShadowRouteStatus = "skipped" | "planned" | "unavailable";
|
||||
|
||||
export type AddressQueryShape =
|
||||
| "AGGREGATE_LOOKUP"
|
||||
|
|
@ -198,6 +201,14 @@ export interface AddressExecutionDebug {
|
|||
evidence_strength?: AddressEvidenceStrength;
|
||||
balance_confirmed?: boolean;
|
||||
as_of_date_basis?: AddressAsOfDateBasis | null;
|
||||
capability_id?: string | null;
|
||||
capability_layer?: AddressCapabilityLayer | null;
|
||||
capability_route_mode?: AddressCapabilityRouteMode | null;
|
||||
capability_route_enabled?: boolean;
|
||||
capability_route_reason?: string | null;
|
||||
shadow_route_intent?: AddressIntent | null;
|
||||
shadow_route_selected_recipe?: string | null;
|
||||
shadow_route_status?: AddressShadowRouteStatus | null;
|
||||
limitations: string[];
|
||||
reasons: string[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import type {
|
|||
ProblemUnitSummary
|
||||
} from "./stage2ProblemUnits";
|
||||
import type { AccountingGraphBuildResult } from "./stage4Graph";
|
||||
import type { AddressNavigationState } from "./addressNavigation";
|
||||
|
||||
export type AssistantFallbackType = "none" | "out_of_scope" | "clarification" | "partial" | "unknown";
|
||||
export type AssistantReplyType =
|
||||
|
|
@ -432,6 +433,14 @@ export interface AssistantDebugPayload {
|
|||
evidence_strength?: "weak" | "medium" | "strong";
|
||||
balance_confirmed?: boolean;
|
||||
as_of_date_basis?: "period_end" | "explicit_as_of_date" | "period_range" | null;
|
||||
capability_id?: string | null;
|
||||
capability_layer?: "compute" | "navigation" | "conversational" | null;
|
||||
capability_route_mode?: "exact" | "heuristic" | null;
|
||||
capability_route_enabled?: boolean;
|
||||
capability_route_reason?: string | null;
|
||||
shadow_route_intent?: string | null;
|
||||
shadow_route_selected_recipe?: string | null;
|
||||
shadow_route_status?: "skipped" | "planned" | "unavailable" | null;
|
||||
execution_lane?: "address_query" | "deep_analysis";
|
||||
llm_decomposition_applied?: boolean;
|
||||
llm_decomposition_attempted?: boolean;
|
||||
|
|
@ -510,6 +519,7 @@ export interface AssistantSessionState {
|
|||
updated_at: string;
|
||||
items: AssistantConversationItem[];
|
||||
investigation_state: InvestigationStateWithProblemUnits | null;
|
||||
address_navigation_state: AddressNavigationState | null;
|
||||
}
|
||||
|
||||
export interface AssistantMessageResponsePayload {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,37 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
isCapabilityRouteBlocked,
|
||||
resolveAddressCapabilityRouteDecision,
|
||||
resolveShadowRouteIntent
|
||||
} from "../src/services/addressCapabilityPolicy";
|
||||
|
||||
describe("address capability policy", () => {
|
||||
it("maps confirmed payables intent to compute exact capability", () => {
|
||||
const decision = resolveAddressCapabilityRouteDecision("payables_confirmed_as_of_date");
|
||||
expect(decision.capability_id).toBe("confirmed_payables_as_of_date");
|
||||
expect(decision.capability_layer).toBe("compute");
|
||||
expect(decision.capability_route_mode).toBe("exact");
|
||||
expect(decision.capability_route_enabled).toBe(true);
|
||||
expect(isCapabilityRouteBlocked(decision)).toBe(false);
|
||||
});
|
||||
|
||||
it("maps document drilldown intent to navigation capability", () => {
|
||||
const decision = resolveAddressCapabilityRouteDecision("list_documents_by_contract");
|
||||
expect(decision.capability_id).toBe("documents_drilldown");
|
||||
expect(decision.capability_layer).toBe("navigation");
|
||||
expect(decision.capability_route_mode).toBe("exact");
|
||||
expect(decision.capability_route_enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("maps heuristic list intents to heuristic compute route mode", () => {
|
||||
const decision = resolveAddressCapabilityRouteDecision("list_receivables_counterparties");
|
||||
expect(decision.capability_id).toBe("receivables_candidates_list");
|
||||
expect(decision.capability_layer).toBe("compute");
|
||||
expect(decision.capability_route_mode).toBe("heuristic");
|
||||
});
|
||||
|
||||
it("keeps shadow route disabled by default", () => {
|
||||
expect(resolveShadowRouteIntent("payables_confirmed_as_of_date", "confirmed_balance")).toBeNull();
|
||||
expect(resolveShadowRouteIntent("list_payables_counterparties", "confirmed_balance")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createEmptyAddressNavigationState,
|
||||
evolveAddressNavigationStateWithAssistantItem,
|
||||
normalizeAddressNavigationState
|
||||
} from "../src/services/addressNavigationState";
|
||||
|
||||
describe("address navigation state", () => {
|
||||
it("creates default empty state", () => {
|
||||
const state = createEmptyAddressNavigationState("asst-1", "2026-04-12T10:00:00.000Z");
|
||||
expect(state.schema_version).toBe("address_navigation_state_v1");
|
||||
expect(state.session_id).toBe("asst-1");
|
||||
expect(state.result_sets).toEqual([]);
|
||||
expect(state.navigation_history).toEqual([]);
|
||||
expect(state.session_context.active_result_set_id).toBeNull();
|
||||
});
|
||||
|
||||
it("captures result_set and focus from address assistant turn", () => {
|
||||
const base = createEmptyAddressNavigationState("asst-2", "2026-04-12T10:00:00.000Z");
|
||||
const assistantItem = {
|
||||
message_id: "msg-a1",
|
||||
session_id: "asst-2",
|
||||
role: "assistant",
|
||||
text: [
|
||||
"Топ-6 заказчиков по сумме поступлений:",
|
||||
"1. Группа | сумма: 12093465 | операций: 13",
|
||||
"4. Гамма-мебель, ООО | сумма: 471000 | операций: 2"
|
||||
].join("\n"),
|
||||
reply_type: "factual",
|
||||
created_at: "2026-04-12T10:00:10.000Z",
|
||||
trace_id: "address-123",
|
||||
debug: {
|
||||
detected_mode: "address_query",
|
||||
detected_intent: "customer_revenue_and_payments",
|
||||
selected_recipe: "address_customer_revenue_and_payments_v1",
|
||||
extracted_filters: {
|
||||
period_from: "2020-01-01",
|
||||
period_to: "2020-12-31"
|
||||
},
|
||||
anchor_type: "counterparty",
|
||||
anchor_value_resolved: "Гамма-мебель, ООО",
|
||||
dialog_continuation_contract_v2: {
|
||||
decision: "new_topic"
|
||||
}
|
||||
}
|
||||
} as any;
|
||||
|
||||
const evolved = evolveAddressNavigationStateWithAssistantItem(base, assistantItem, 2);
|
||||
expect(evolved.result_sets.length).toBe(1);
|
||||
expect(evolved.result_sets[0]?.result_set_id).toBe("rs-msg-a1");
|
||||
expect(evolved.result_sets[0]?.intent).toBe("customer_revenue_and_payments");
|
||||
expect(evolved.result_sets[0]?.entity_refs.length).toBeGreaterThan(0);
|
||||
expect(evolved.session_context.active_result_set_id).toBe("rs-msg-a1");
|
||||
expect(evolved.session_context.active_focus_object?.label).toBe("Гамма-мебель, ООО");
|
||||
expect(evolved.navigation_history[0]?.action).toBe("open");
|
||||
});
|
||||
|
||||
it("tracks drilldown event for follow-up continuation turn", () => {
|
||||
const initial = normalizeAddressNavigationState(
|
||||
{
|
||||
schema_version: "address_navigation_state_v1",
|
||||
session_id: "asst-3",
|
||||
updated_at: "2026-04-12T10:00:00.000Z",
|
||||
session_context: {
|
||||
active_result_set_id: "rs-prev",
|
||||
active_focus_object: null,
|
||||
last_confirmed_route: "address_customer_revenue_and_payments_v1",
|
||||
date_scope: {
|
||||
as_of_date: null,
|
||||
period_from: "2020-01-01",
|
||||
period_to: "2020-12-31"
|
||||
},
|
||||
organization_scope: null
|
||||
},
|
||||
result_sets: [],
|
||||
navigation_history: []
|
||||
} as any,
|
||||
"asst-3"
|
||||
);
|
||||
const assistantItem = {
|
||||
message_id: "msg-a2",
|
||||
session_id: "asst-3",
|
||||
role: "assistant",
|
||||
text: "Собран список договоров по контрагенту Гамма-мебель, ООО.",
|
||||
reply_type: "factual",
|
||||
created_at: "2026-04-12T10:02:00.000Z",
|
||||
trace_id: "address-456",
|
||||
debug: {
|
||||
detected_mode: "address_query",
|
||||
detected_intent: "list_contracts_by_counterparty",
|
||||
selected_recipe: "address_contracts_by_counterparty_v1",
|
||||
extracted_filters: {
|
||||
period_from: "2020-01-01",
|
||||
period_to: "2020-12-31",
|
||||
counterparty: "Гамма-мебель, ООО"
|
||||
},
|
||||
anchor_type: "counterparty",
|
||||
anchor_value_resolved: "Гамма-мебель, ООО",
|
||||
dialog_continuation_contract_v2: {
|
||||
decision: "continue_previous"
|
||||
}
|
||||
}
|
||||
} as any;
|
||||
|
||||
const evolved = evolveAddressNavigationStateWithAssistantItem(initial, assistantItem, 4);
|
||||
expect(evolved.navigation_history.length).toBe(1);
|
||||
expect(evolved.navigation_history[0]?.action).toBe("drilldown");
|
||||
expect(evolved.navigation_history[0]?.source_result_set_id).toBe("rs-prev");
|
||||
expect(evolved.session_context.active_result_set_id).toBe("rs-msg-a2");
|
||||
expect(evolved.session_context.active_focus_object?.object_type).toBe("counterparty");
|
||||
expect(evolved.session_context.date_scope.period_from).toBe("2020-01-01");
|
||||
expect(evolved.session_context.date_scope.period_to).toBe("2020-12-31");
|
||||
});
|
||||
});
|
||||
|
|
@ -19,6 +19,7 @@ describe("assistant session backward compatibility", () => {
|
|||
expect(session?.session_id).toBe(sessionId);
|
||||
expect(Array.isArray(session?.items)).toBe(true);
|
||||
expect(session?.investigation_state?.schema_version).toBe("investigation_state_v1");
|
||||
expect(session?.address_navigation_state?.schema_version).toBe("address_navigation_state_v1");
|
||||
});
|
||||
|
||||
it("normalizes malformed legacy sessions with missing items array", () => {
|
||||
|
|
@ -35,6 +36,7 @@ describe("assistant session backward compatibility", () => {
|
|||
expect(Array.isArray(ensured.items)).toBe(true);
|
||||
expect(ensured.items.length).toBe(0);
|
||||
expect(ensured.investigation_state?.schema_version).toBe("investigation_state_v1");
|
||||
expect(ensured.address_navigation_state?.schema_version).toBe("address_navigation_state_v1");
|
||||
});
|
||||
|
||||
it("preserves optional stage2 problem_unit_state in session clone flow", () => {
|
||||
|
|
@ -73,5 +75,6 @@ describe("assistant session backward compatibility", () => {
|
|||
expect(session).toBeTruthy();
|
||||
expect(session?.investigation_state?.problem_unit_state?.active_problem_units).toEqual(["pu-1", "pu-2"]);
|
||||
expect(session?.investigation_state?.problem_unit_state?.focus_problem_types).toEqual(["broken_chain_segment"]);
|
||||
expect(session?.address_navigation_state?.schema_version).toBe("address_navigation_state_v1");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue