From c10260dedf061b9be4edc3000359adf2c0aab6c5 Mon Sep 17 00:00:00 2001 From: dctouch Date: Sun, 12 Apr 2026 14:40:48 +0300 Subject: [PATCH] =?UTF-8?q?=D0=93=D0=9B=D0=9E=D0=91=D0=90=D0=9B=D0=AC?= =?UTF-8?q?=D0=9D=D0=AB=D0=99=20=D0=A0=D0=95=D0=A4=D0=90=D0=9A=D0=A2=D0=9E?= =?UTF-8?q?=D0=A0=D0=98=D0=9D=D0=93=20=D0=90=D0=A0=D0=A5=D0=98=D0=A2=D0=95?= =?UTF-8?q?=D0=9A=D0=A2=D0=A3=D0=A0=D0=AB=20-=20=D0=90=D1=80=D1=85=D0=B8?= =?UTF-8?q?=D1=82=D0=B5=D0=BA=D1=82=D1=83=D1=80=D0=BD=D1=8B=D0=B9=20=D1=84?= =?UTF-8?q?=D1=83=D0=BD=D0=B4=D0=B0=D0=BC=D0=B5=D0=BD=D1=82:=20capability-?= =?UTF-8?q?guard,=20navigation=20state=20=D0=B8=20=D1=82=D1=80=D0=B0=D1=81?= =?UTF-8?q?=D1=81=D0=B8=D1=80=D1=83=D0=B5=D0=BC=D1=8B=D0=B9=20route-=D0=BA?= =?UTF-8?q?=D0=BE=D0=BD=D1=82=D1=80=D0=B0=D0=BA=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/TECH/ARCH_LAYER_FOUNDATION.md | 103 ++++ llm_normalizer/backend/dist/config.js | 13 +- .../dist/services/addressCapabilityPolicy.js | 121 +++++ .../dist/services/addressNavigationState.js | 391 ++++++++++++++++ .../dist/services/addressQueryService.js | 108 ++++- .../backend/dist/services/assistantService.js | 8 + .../dist/services/assistantSessionLogger.js | 1 + .../dist/services/assistantSessionStore.js | 35 +- .../backend/dist/types/addressNavigation.js | 4 + llm_normalizer/backend/src/config.ts | 36 ++ .../src/services/addressCapabilityPolicy.ts | 150 ++++++ .../src/services/addressNavigationState.ts | 441 ++++++++++++++++++ .../src/services/addressQueryService.ts | 138 +++++- .../backend/src/services/assistantService.ts | 8 + .../src/services/assistantSessionLogger.ts | 2 + .../src/services/assistantSessionStore.ts | 57 ++- .../backend/src/types/addressNavigation.ts | 74 +++ .../backend/src/types/addressQuery.ts | 11 + llm_normalizer/backend/src/types/assistant.ts | 10 + .../tests/addressCapabilityPolicy.test.ts | 37 ++ .../tests/addressNavigationState.test.ts | 114 +++++ .../tests/sessionBackwardCompat.test.ts | 3 + 22 files changed, 1842 insertions(+), 23 deletions(-) create mode 100644 docs/TECH/ARCH_LAYER_FOUNDATION.md create mode 100644 llm_normalizer/backend/dist/services/addressCapabilityPolicy.js create mode 100644 llm_normalizer/backend/dist/services/addressNavigationState.js create mode 100644 llm_normalizer/backend/dist/types/addressNavigation.js create mode 100644 llm_normalizer/backend/src/services/addressCapabilityPolicy.ts create mode 100644 llm_normalizer/backend/src/services/addressNavigationState.ts create mode 100644 llm_normalizer/backend/src/types/addressNavigation.ts create mode 100644 llm_normalizer/backend/tests/addressCapabilityPolicy.test.ts create mode 100644 llm_normalizer/backend/tests/addressNavigationState.test.ts diff --git a/docs/TECH/ARCH_LAYER_FOUNDATION.md b/docs/TECH/ARCH_LAYER_FOUNDATION.md new file mode 100644 index 0000000..ba701ae --- /dev/null +++ b/docs/TECH/ARCH_LAYER_FOUNDATION.md @@ -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. diff --git a/llm_normalizer/backend/dist/config.js b/llm_normalizer/backend/dist/config.js index 47818da..d7d75ff 100644 --- a/llm_normalizer/backend/dist/config.js +++ b/llm_normalizer/backend/dist/config.js @@ -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"; diff --git a/llm_normalizer/backend/dist/services/addressCapabilityPolicy.js b/llm_normalizer/backend/dist/services/addressCapabilityPolicy.js new file mode 100644 index 0000000..4493244 --- /dev/null +++ b/llm_normalizer/backend/dist/services/addressCapabilityPolicy.js @@ -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; +} diff --git a/llm_normalizer/backend/dist/services/addressNavigationState.js b/llm_normalizer/backend/dist/services/addressNavigationState.js new file mode 100644 index 0000000..bcaa7bc --- /dev/null +++ b/llm_normalizer/backend/dist/services/addressNavigationState.js @@ -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 + }; +} diff --git a/llm_normalizer/backend/dist/services/addressQueryService.js b/llm_normalizer/backend/dist/services/addressQueryService.js index fd5ba43..bf653a5 100644 --- a/llm_normalizer/backend/dist/services/addressQueryService.js +++ b/llm_normalizer/backend/dist/services/addressQueryService.js @@ -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) diff --git a/llm_normalizer/backend/dist/services/assistantService.js b/llm_normalizer/backend/dist/services/assistantService.js index de8365f..082c1ac 100644 --- a/llm_normalizer/backend/dist/services/assistantService.js +++ b/llm_normalizer/backend/dist/services/assistantService.js @@ -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), diff --git a/llm_normalizer/backend/dist/services/assistantSessionLogger.js b/llm_normalizer/backend/dist/services/assistantSessionLogger.js index 754bb91..f52428c 100644 --- a/llm_normalizer/backend/dist/services/assistantSessionLogger.js +++ b/llm_normalizer/backend/dist/services/assistantSessionLogger.js @@ -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: { diff --git a/llm_normalizer/backend/dist/services/assistantSessionStore.js b/llm_normalizer/backend/dist/services/assistantSessionStore.js index 8fe9021..f28d714 100644 --- a/llm_normalizer/backend/dist/services/assistantSessionStore.js +++ b/llm_normalizer/backend/dist/services/assistantSessionStore.js @@ -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; diff --git a/llm_normalizer/backend/dist/types/addressNavigation.js b/llm_normalizer/backend/dist/types/addressNavigation.js new file mode 100644 index 0000000..54204bb --- /dev/null +++ b/llm_normalizer/backend/dist/types/addressNavigation.js @@ -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"; diff --git a/llm_normalizer/backend/src/config.ts b/llm_normalizer/backend/src/config.ts index fb3c84e..8c9cc88 100644 --- a/llm_normalizer/backend/src/config.ts +++ b/llm_normalizer/backend/src/config.ts @@ -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 diff --git a/llm_normalizer/backend/src/services/addressCapabilityPolicy.ts b/llm_normalizer/backend/src/services/addressCapabilityPolicy.ts new file mode 100644 index 0000000..41d8017 --- /dev/null +++ b/llm_normalizer/backend/src/services/addressCapabilityPolicy.ts @@ -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(["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: 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; +} diff --git a/llm_normalizer/backend/src/services/addressNavigationState.ts b/llm_normalizer/backend/src/services/addressNavigationState.ts new file mode 100644 index 0000000..6dc10a4 --- /dev/null +++ b/llm_normalizer/backend/src/services/addressNavigationState.ts @@ -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> = { + 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> = { + 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 | null { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + return value as Record; +} + +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(); + 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 { + const record = toObject(value); + if (!record) { + return {}; + } + return { ...record }; +} + +function resolveNavigationAction(debug: Record, 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, 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 => 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 => 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 => 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; + 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 + }; +} diff --git a/llm_normalizer/backend/src/services/addressQueryService.ts b/llm_normalizer/backend/src/services/addressQueryService.ts index 839b6a0..42a0d7c 100644 --- a/llm_normalizer/backend/src/services/addressQueryService.ts +++ b/llm_normalizer/backend/src/services/addressQueryService.ts @@ -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( diff --git a/llm_normalizer/backend/src/services/assistantService.ts b/llm_normalizer/backend/src/services/assistantService.ts index 98ecb9f..f7778da 100644 --- a/llm_normalizer/backend/src/services/assistantService.ts +++ b/llm_normalizer/backend/src/services/assistantService.ts @@ -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), diff --git a/llm_normalizer/backend/src/services/assistantSessionLogger.ts b/llm_normalizer/backend/src/services/assistantSessionLogger.ts index bc3cf1e..2e91252 100644 --- a/llm_normalizer/backend/src/services/assistantSessionLogger.ts +++ b/llm_normalizer/backend/src/services/assistantSessionLogger.ts @@ -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: { diff --git a/llm_normalizer/backend/src/services/assistantSessionStore.ts b/llm_normalizer/backend/src/services/assistantSessionStore.ts index aaf49fe..ddafa10 100644 --- a/llm_normalizer/backend/src/services/assistantSessionStore.ts +++ b/llm_normalizer/backend/src/services/assistantSessionStore.ts @@ -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; diff --git a/llm_normalizer/backend/src/types/addressNavigation.ts b/llm_normalizer/backend/src/types/addressNavigation.ts new file mode 100644 index 0000000..40bfaa9 --- /dev/null +++ b/llm_normalizer/backend/src/types/addressNavigation.ts @@ -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; + 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[]; +} diff --git a/llm_normalizer/backend/src/types/addressQuery.ts b/llm_normalizer/backend/src/types/addressQuery.ts index 2d36b01..bf18521 100644 --- a/llm_normalizer/backend/src/types/addressQuery.ts +++ b/llm_normalizer/backend/src/types/addressQuery.ts @@ -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[]; } diff --git a/llm_normalizer/backend/src/types/assistant.ts b/llm_normalizer/backend/src/types/assistant.ts index 7cc7756..ca62e94 100644 --- a/llm_normalizer/backend/src/types/assistant.ts +++ b/llm_normalizer/backend/src/types/assistant.ts @@ -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 { diff --git a/llm_normalizer/backend/tests/addressCapabilityPolicy.test.ts b/llm_normalizer/backend/tests/addressCapabilityPolicy.test.ts new file mode 100644 index 0000000..2ecc80a --- /dev/null +++ b/llm_normalizer/backend/tests/addressCapabilityPolicy.test.ts @@ -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(); + }); +}); diff --git a/llm_normalizer/backend/tests/addressNavigationState.test.ts b/llm_normalizer/backend/tests/addressNavigationState.test.ts new file mode 100644 index 0000000..1175203 --- /dev/null +++ b/llm_normalizer/backend/tests/addressNavigationState.test.ts @@ -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"); + }); +}); diff --git a/llm_normalizer/backend/tests/sessionBackwardCompat.test.ts b/llm_normalizer/backend/tests/sessionBackwardCompat.test.ts index 168f936..24e4a30 100644 --- a/llm_normalizer/backend/tests/sessionBackwardCompat.test.ts +++ b/llm_normalizer/backend/tests/sessionBackwardCompat.test.ts @@ -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"); }); });