АДРЕСНЫЙ РЕЖИМ - M2.3c тюнинг резолвинга и фильтров адресных запросов, поэтапная диагностика и аудит фильтрации по счету

This commit is contained in:
dctouch 2026-03-29 21:51:09 +03:00
parent 2bf16de4ea
commit a2886faed6
37 changed files with 3050 additions and 151 deletions

View File

@ -12,6 +12,7 @@
- `runtime_readiness_matrix_v1.md` - матрица structural vs runtime readiness.
- `known_positive_live_suite_v1.md` - базовый template positive-evidence suite.
- `data_aware_positive_acceptance_suite_v1.md` - M2.3 canonical guide для curated live acceptance.
- `curated_positive_live_suite_v1.md` - M2.3c curated suite (counterparty/account split + negative twins).
- `address_query_bootstrap_report_2026-03-29.md` - итоговая сводка bootstrap этапа.
## Связанные run-паки
@ -21,4 +22,5 @@
- `docs/ADDRESS/runs/2026-03-29_Address_Query_Runtime_V1_M2_3_DocumentsFormingBalance_DataAwareAcceptance/`
- `docs/ADDRESS/runs/2026-03-29_Address_Query_Runtime_V1_M2_3A_Stage_Diagnostic_Materialization/`
- `docs/ADDRESS/runs/2026-03-29_Address_Query_Runtime_V1_M2_3B_AccountScope_Mode_Tuning/`
- `docs/ADDRESS/runs/2026-03-29_Address_Query_Runtime_V1_M2_3C_Resolver_Filter_Tuning_And_AccountScope_Audit/`

View File

@ -1,4 +1,4 @@
# Address Runtime Contracts V1 (M2.3b)
# Address Runtime Contracts V1 (M2.3c)
Дата: 2026-03-29
@ -38,6 +38,9 @@
- `ambiguity_count`
- MCP/evidence flow block:
- `mcp_call_status`
- `mcp_call_status_legacy`
- `match_failure_stage`
- `match_failure_reason`
- `rows_fetched`
- `raw_rows_received`
- `rows_after_account_scope`
@ -46,6 +49,12 @@
- `rows_matched`
- `raw_row_keys_sample`
- `materialization_drop_reason`
- account-scope audit block:
- `account_token_raw`
- `account_token_normalized`
- `account_scope_fields_checked`
- `account_scope_match_strategy`
- `account_scope_drop_reason`
- `response_type`
- `limited_reason_category`
- `fallback_reason`
@ -85,16 +94,20 @@
- `DEEP_ONLY`
- `UNKNOWN`
## MCP Stage Status Taxonomy (M2.3a)
## MCP Stage Status Taxonomy (M2.3c)
- `skipped`
- `error`
- `no_raw_rows`
- `raw_rows_received_but_not_materialized`
- `materialized_but_not_matched`
- `materialized_but_not_anchor_matched`
- `materialized_but_filtered_out_by_recipe`
- `matched_non_empty`
## Materialization Drop Reasons (M2.3a)
Legacy compatibility:
- `mcp_call_status_legacy` may still report `materialized_but_not_matched` for backward-compatible analytics.
## Materialization Drop Reasons (M2.3c)
- `none`
- `dropped_by_account_scope_filter`
@ -103,15 +116,21 @@
- `missing_registrator_field`
- `unknown_row_shape`
## Account Scope Strategy (M2.3b)
## Account Scope Strategy (M2.3c)
- `strict` - account scope is mandatory and applied as a hard filter.
- `preferred` - account scope is applied first; if it yields zero rows while raw rows exist, runtime falls back to raw rows and continues matching.
## M2.3c Runtime Snapshot
- Counterparty intents now have confirmed `matched_non_empty` cases in curated live suite.
- Account intents still mostly stop at `raw_rows_received_but_not_materialized`.
- Guardrails remain unchanged: no free query generation, no false factual outputs.
## Compound Query Note
- `COMPOUND_FACTUAL_QUERY` currently remains detection-only.
- Multi-intent decomposition execution is planned for next increment.
- Multi-intent decomposition execution is planned for the next increment.
## Guardrails
@ -119,5 +138,3 @@
- read-only MCP
- no free-form query generation
- no silent source substitution

View File

@ -41,7 +41,7 @@
- Если обязательные фильтры не извлечены, вернуть `LIMITED_WITH_REASON` с указанием недостающих параметров.
- Если вопрос требует causal/proof reasoning, перевести в deep-analysis path.
- Для `as_of_date` по умолчанию используется текущая дата runtime (на момент этого документа: 2026-03-29), если пользователь явно не задал дату.
## Runtime status note (M2.3b)
## Runtime status note (M2.3c)
Implemented in live runtime now:
- `list_documents_by_counterparty`
@ -53,6 +53,6 @@ Still not implemented in runtime:
Stage diagnostic note:
- strict account intents still show `raw_rows_received > 0`, but `rows_after_account_scope = 0`;
- preferred counterparty intents now reach `rows_materialized > 0`, but rows still drop at recipe/anchor filter stage;
- non-empty factual acceptance now requires resolver/filter tuning after materialization.
- counterparty intents now have curated `matched_non_empty` cases after resolver/filter tuning;
- account family still needs account-scope/materialization fix before first stable non-empty account case.

View File

@ -0,0 +1,48 @@
# Curated Positive Live Suite V1 (M2.3c)
Дата: 2026-03-29
## Назначение
Этот suite нужен для acceptance live-runtime без хардкода бизнес-объектов в продуктовой логике.
- runtime остается `data-agnostic`
- acceptance остается `data-aware`
То есть набор кейсов собирается по exploratory live pass, но не вшивается в runtime-правила.
## Семейства в M2.3c
1. `counterparty`
2. `account`
## Curated Case Set
| case_id | family | question pattern | intent | expected |
|---|---|---|---|---|
| C1 | counterparty | documents by counterparty (known non-empty anchor + period) | `list_documents_by_counterparty` | `FACTUAL_LIST`, non-empty |
| C2 | counterparty | bank operations by counterparty (known non-empty anchor + period) | `bank_operations_by_counterparty` | `FACTUAL_LIST`, non-empty |
| C3 | counterparty | documents by counterparty (negative twin) | `list_documents_by_counterparty` | `LIMITED_WITH_REASON` |
| C4 | counterparty | bank ops by counterparty (negative twin) | `bank_operations_by_counterparty` | `LIMITED_WITH_REASON` |
| C5 | account | account balance snapshot by account/date | `account_balance_snapshot` | stage-diagnostic limited |
| C6 | account | documents forming balance by account/date | `documents_forming_balance` | stage-diagnostic limited |
| C7 | account | documents forming balance by account/date (variant) | `documents_forming_balance` | stage-diagnostic limited |
| C8 | account | account balance snapshot by account/date (variant) | `account_balance_snapshot` | stage-diagnostic limited |
## Что проверяем этим suite
- есть ли реальные `matched_non_empty` в counterparty-family;
- сохраняется ли `false_factual_rate = 0` на negative twins;
- где именно застревает account-family (`raw_rows_received_but_not_materialized` vs later stages).
## Acceptance Rules
- минимум один non-empty factual для каждого counterparty intent;
- zero false-factual;
- account-family must be localized with explicit stage/failure reason (без размытого limited).
## Где лежат артефакты
- run-pack: `docs/ADDRESS/runs/2026-03-29_Address_Query_Runtime_V1_M2_3C_Resolver_Filter_Tuning_And_AccountScope_Audit/`
- diagnostic matrix: `stage_diagnostic_matrix.md`
- case matrix: `curated_positive_case_matrix.md`

View File

@ -97,9 +97,20 @@
- Runtime вызывает MCP proxy (`/api/execute_query`) только с query-template из recipe и параметрами после валидации.
- Для V1 все recipe выполняются в `read-only` режиме.
- Ограничения на выборку (`limit`) и сортировки фиксируются recipe-контрактом, а не свободным текстом вопроса.
## 8) Account Scope Strategy (M2.3b)
## 8) Account Scope Strategy (M2.3c)
- `account_balance_snapshot` and `documents_forming_balance` use `strict` account scope.
- counterparty-oriented recipes use `preferred` account scope with runtime fallback to raw rows when scope gives zero rows.
- this keeps account-intent precision while preventing blind row loss on party intents.
## 9) Runtime Query Template Notes (M2.3c)
- `address.documents.by_counterparty` and `address.bank_ops.by_counterparty` use a dedicated `bank_docs` live query template.
- account intents (`address.account.balance_snapshot`, `address.balance.drilldown_documents`) continue using movement-oriented query template with strict account scope.
- stage diagnostics are tracked with split statuses:
- `raw_rows_received_but_not_materialized`
- `materialized_but_not_anchor_matched`
- `materialized_but_filtered_out_by_recipe`
- `matched_non_empty`
- for backward compatibility analytics, legacy status is emitted as `mcp_call_status_legacy`.

View File

@ -1,4 +1,4 @@
# Runtime Readiness Matrix V1 (M2.3b)
# Runtime Readiness Matrix V1 (M2.3c)
Дата: 2026-03-29
@ -7,45 +7,29 @@
## Статусы
- `STRUCTURALLY_VISIBLE` - сущность подтверждена в snapshot/inventory.
- `LIVE_QUERYABLE` - в текущем live path можно дать factual без натяжек.
- `LIVE_QUERYABLE_WITH_LIMITS` - live path работает, но часто нужен дополнительный anchor.
- `REQUIRES_SPECIALIZED_RECIPE` - базовый movement recipe недостаточен для materialization.
- `LIVE_QUERYABLE` - в текущем live path можно давать factual ответ стабильно.
- `LIVE_QUERYABLE_WITH_LIMITS` - live path работает, но результат зависит от anchor/period precision.
- `REQUIRES_SPECIALIZED_RECIPE` - базовый recipe-контур не покрывает сценарий.
- `DEEP_ONLY` - сценарий не относится к address V1.
## Матрица (P0/P1)
| scenario_id | scenario | structural_readiness | runtime_readiness | current_blocker | next_action |
|---|---|---|---|---|---|
| AQ-P0-01 | list_open_contracts | STRUCTURALLY_VISIBLE | REQUIRES_SPECIALIZED_RECIPE | weak contract anchors in movement rows | добавить object-aware recipe (`documents/contracts`) |
| AQ-P0-02 | list_payables_counterparties | STRUCTURALLY_VISIBLE | LIVE_QUERYABLE_WITH_LIMITS | empty matches on narrow filters | расширить live evidence pack по контрагентам |
| AQ-P0-03 | list_receivables_counterparties | STRUCTURALLY_VISIBLE | LIVE_QUERYABLE_WITH_LIMITS | empty matches on narrow filters | улучшить фильтрацию и fallback hints |
| AQ-P0-04 | account_balance_snapshot | STRUCTURALLY_VISIBLE | LIVE_QUERYABLE_WITH_LIMITS | dry-run frequently returns `empty_match` on broad `today` filters | lock data-aware positive account/date fixtures |
| AQ-P0-05 | open_items_by_counterparty_or_contract (counterparty) | STRUCTURALLY_VISIBLE | LIVE_QUERYABLE_WITH_LIMITS | missing counterparty anchor in short phrases | усилить anchor-first extraction |
| AQ-P0-06 | open_items_by_counterparty_or_contract (contract) | STRUCTURALLY_VISIBLE | REQUIRES_SPECIALIZED_RECIPE | movement rows often miss contract linkage | двухшаговый path: anchor resolution -> focused recipe |
| AQ-P0-07 | documents_by_counterparty | STRUCTURALLY_VISIBLE | LIVE_QUERYABLE_WITH_LIMITS | implemented path, but dry-run still often `empty_match` on current anchors/period | expand data-aware positive fixtures and improve resolver targeting |
| AQ-P0-07B | bank_operations_by_counterparty | STRUCTURALLY_VISIBLE | LIVE_QUERYABLE_WITH_LIMITS | implemented path, but dry-run still often `empty_match` on current anchors/period | expand data-aware positive fixtures and tighten bank-doc targeting |
| AQ-P0-08 | documents_by_contract | STRUCTURALLY_VISIBLE | REQUIRES_SPECIALIZED_RECIPE | by-contract live recipe not implemented in runtime V1 | add contract-aware document-list recipe with resolver confidence gate |
| AQ-P0-09 | documents_forming_balance | STRUCTURALLY_VISIBLE | LIVE_QUERYABLE_WITH_LIMITS | implemented, but stage diagnostic shows loss before materialization | diagnose and tune account-scope filtering for live recipes |
| AQ-P1-10 | account_turnover_snapshot | STRUCTURALLY_VISIBLE | LIVE_QUERYABLE_WITH_LIMITS | not in current intent set | расширение intents V1.1 |
| AQ-P0-01 | list_open_contracts | STRUCTURALLY_VISIBLE | REQUIRES_SPECIALIZED_RECIPE | weak contract anchors in current live rows | add contract-aware document recipe + resolver confidence gate |
| AQ-P0-02 | list_payables_counterparties | STRUCTURALLY_VISIBLE | LIVE_QUERYABLE_WITH_LIMITS | broad prompts still produce sparse matches | keep curated positive suite and tighten period hints |
| AQ-P0-03 | list_receivables_counterparties | STRUCTURALLY_VISIBLE | LIVE_QUERYABLE_WITH_LIMITS | broad prompts still produce sparse matches | keep curated positive suite and tighten period hints |
| AQ-P0-04 | account_balance_snapshot | STRUCTURALLY_VISIBLE | LIVE_QUERYABLE_WITH_LIMITS | `raw_rows_received > 0`, but account scope drops rows before materialization | account token/shape audit and account field mapping fix |
| AQ-P0-05 | open_items_by_counterparty_or_contract (counterparty) | STRUCTURALLY_VISIBLE | LIVE_QUERYABLE_WITH_LIMITS | requires explicit counterparty anchor for stable non-empty | anchor refinement and resolver ambiguity handling |
| AQ-P0-06 | open_items_by_counterparty_or_contract (contract) | STRUCTURALLY_VISIBLE | REQUIRES_SPECIALIZED_RECIPE | movement rows often miss contract linkage | two-step path: contract resolver -> focused recipe |
| AQ-P0-07 | documents_by_counterparty | STRUCTURALLY_VISIBLE | LIVE_QUERYABLE_WITH_LIMITS | positive cases confirmed, but narrow/broad anchor variants still fragile | continue resolver/filter tuning and parity checks |
| AQ-P0-07B | bank_operations_by_counterparty | STRUCTURALLY_VISIBLE | LIVE_QUERYABLE_WITH_LIMITS | positive cases confirmed, but narrow/broad anchor variants still fragile | continue resolver/filter tuning and bank-doc visibility checks |
| AQ-P0-08 | documents_by_contract | STRUCTURALLY_VISIBLE | REQUIRES_SPECIALIZED_RECIPE | by-contract live recipe not implemented in runtime V1 | implement contract resolver + focused recipe |
| AQ-P0-09 | documents_forming_balance | STRUCTURALLY_VISIBLE | LIVE_QUERYABLE_WITH_LIMITS | implemented, but account-family still blocked before materialization | account scope/materialization diagnostics and account token normalization |
| AQ-P1-10 | account_turnover_snapshot | STRUCTURALLY_VISIBLE | LIVE_QUERYABLE_WITH_LIMITS | not in current intent set | extend intents in V1.1 |
## Примечание
Матрица разделяет "видимость сущности в inventory" и "операционную готовность live-runtime".
Это обязательная опора для приоритезации Sprint B, чтобы не путать structural coverage и runtime proofability.
### Sync note (M2.3b -> live dry-run)
`account_balance_snapshot` intentionally remains `LIVE_QUERYABLE_WITH_LIMITS`.
Reason: dry-run still shows repeatable `empty_match` on broad `as_of=today` prompts.
Promote to `LIVE_QUERYABLE` only after data-aware positive live cases are stable.
`documents_forming_balance` is implemented with strict account-scope path.
Validation should be based on data-aware acceptance suite, not only safety dry-run.
Stage-diagnostic replay (M2.3b) shows split-stage behavior:
`D1-D3`: `raw_rows_received > 0` with `rows_after_account_scope = 0` (strict account intents).
`D4-D5`: `rows_after_account_scope > 0` and `rows_materialized > 0`, but `rows_after_recipe_filter = 0` (preferred mode progressed to matching stage).
Current bottleneck moved forward for non-account intents: resolver/filter matching after materialization.
`COMPOUND_FACTUAL_QUERY` currently remains detection-only.
Multi-intent decomposition execution is not part of M2.3b and tracked for next increment.
## Sync Note (M2.3c)
- `documents_by_counterparty` and `bank_operations_by_counterparty` now have curated `matched_non_empty` cases.
- `account_balance_snapshot` and `documents_forming_balance` remain limited because rows are dropped before materialization.
- `COMPOUND_FACTUAL_QUERY` is detection-only and does not execute multi-intent decomposition yet.

View File

@ -0,0 +1,17 @@
# 2026-03-29_Address_Query_Runtime_V1_M2_3C_Resolver_Filter_Tuning_And_AccountScope_Audit
## Scope
- Track A: resolver/filter tuning for counterparty intents.
- Track B: account-scope/materialization audit for account intents.
- Curated positive live suite for acceptance.
## Included artifacts
- `run_summary.json`
- `before_after_metrics.json`
- `curated_positive_case_matrix.md`
- `assistant_window_dry_run_results.json`
- `stage_diagnostic_matrix.md`
- `debug_payloads/`
- `live_call_inventory_address.json`
- `smoke_checks.md`
- `changed_files.txt`

View File

@ -0,0 +1,566 @@
{
"generated_at": "2026-03-29T18:30:51.853Z",
"run_id": "2026-03-29_Address_Query_Runtime_V1_M2_3C_Resolver_Filter_Tuning_And_AccountScope_Audit",
"cases": [
{
"id": "C1",
"family": "counterparty",
"question": "show documents by counterparty svk from 2020-07-01 to 2020-07-31",
"expected_intent": "list_documents_by_counterparty",
"expected_response_type": "FACTUAL_LIST",
"expected_non_empty": true,
"handled": true,
"response_type": "FACTUAL_LIST",
"reply_type": "factual",
"detected_mode": "address_query",
"query_shape": "DOCUMENT_LIST",
"detected_intent": "list_documents_by_counterparty",
"intent_aligned": true,
"selected_recipe": "address_documents_by_counterparty_v1",
"selected_recipe_ids": [],
"extracted_filters": {
"sort": "period_desc",
"limit": 20,
"counterparty": "svk",
"period_from": "2020-07-01",
"period_to": "2020-07-31"
},
"runtime_readiness": "LIVE_QUERYABLE_WITH_LIMITS",
"account_scope_mode": "preferred",
"account_scope_fallback_applied": false,
"mcp_call_status": "matched_non_empty",
"mcp_call_status_legacy": "matched_non_empty",
"stage_interpretation": "Rows passed all stages and produced factual non-empty output.",
"match_failure_stage": "none",
"match_failure_reason": null,
"rows_fetched": 19,
"raw_rows_received": 19,
"rows_after_account_scope": 3,
"rows_materialized": 3,
"rows_after_recipe_filter": 2,
"rows_matched": 2,
"materialization_drop_reason": "none",
"raw_row_keys_sample": [
"Период",
"Регистратор",
"СчетДт",
"СчетКт",
"Сумма",
"Контрагент",
"Period",
"Registrator",
"Amount"
],
"anchor_type": "counterparty",
"anchor_value_raw": "svk",
"anchor_value_resolved": "Группа СВК",
"resolver_confidence": "medium",
"ambiguity_count": 0,
"account_token_raw": null,
"account_token_normalized": null,
"account_scope_fields_checked": [
"account_dt",
"account_kt",
"registrator",
"analytics"
],
"account_scope_match_strategy": "account_code_regex_plus_alias_map_v1",
"account_scope_drop_reason": "not_applicable",
"limited_reason_category": null,
"response_is_non_empty": true,
"assistant_reply_preview": "",
"elapsed_ms": 513,
"generated_at": "2026-03-29T18:30:44.406Z"
},
{
"id": "C2",
"family": "counterparty",
"question": "show bank operations by counterparty svk from 2020-07-01 to 2020-07-31",
"expected_intent": "bank_operations_by_counterparty",
"expected_response_type": "FACTUAL_LIST",
"expected_non_empty": true,
"handled": true,
"response_type": "FACTUAL_LIST",
"reply_type": "factual",
"detected_mode": "address_query",
"query_shape": "DOCUMENT_LIST",
"detected_intent": "bank_operations_by_counterparty",
"intent_aligned": true,
"selected_recipe": "address_bank_operations_by_counterparty_v1",
"selected_recipe_ids": [],
"extracted_filters": {
"sort": "period_desc",
"limit": 20,
"counterparty": "svk",
"period_from": "2020-07-01",
"period_to": "2020-07-31"
},
"runtime_readiness": "LIVE_QUERYABLE_WITH_LIMITS",
"account_scope_mode": "preferred",
"account_scope_fallback_applied": false,
"mcp_call_status": "matched_non_empty",
"mcp_call_status_legacy": "matched_non_empty",
"stage_interpretation": "Rows passed all stages and produced factual non-empty output.",
"match_failure_stage": "none",
"match_failure_reason": null,
"rows_fetched": 19,
"raw_rows_received": 19,
"rows_after_account_scope": 3,
"rows_materialized": 3,
"rows_after_recipe_filter": 2,
"rows_matched": 2,
"materialization_drop_reason": "none",
"raw_row_keys_sample": [
"Период",
"Регистратор",
"СчетДт",
"СчетКт",
"Сумма",
"Контрагент",
"Period",
"Registrator",
"Amount"
],
"anchor_type": "counterparty",
"anchor_value_raw": "svk",
"anchor_value_resolved": "Группа СВК",
"resolver_confidence": "medium",
"ambiguity_count": 0,
"account_token_raw": null,
"account_token_normalized": null,
"account_scope_fields_checked": [
"account_dt",
"account_kt",
"registrator",
"analytics"
],
"account_scope_match_strategy": "account_code_regex_plus_alias_map_v1",
"account_scope_drop_reason": "not_applicable",
"limited_reason_category": null,
"response_is_non_empty": true,
"assistant_reply_preview": "",
"elapsed_ms": 1046,
"generated_at": "2026-03-29T18:30:45.456Z"
},
{
"id": "C3",
"family": "counterparty",
"question": "show documents by counterparty alfa from 2020-07-01 to 2020-07-31",
"expected_intent": "list_documents_by_counterparty",
"expected_response_type": "LIMITED_WITH_REASON",
"expected_non_empty": false,
"handled": true,
"response_type": "LIMITED_WITH_REASON",
"reply_type": "partial_coverage",
"detected_mode": "address_query",
"query_shape": "DOCUMENT_LIST",
"detected_intent": "list_documents_by_counterparty",
"intent_aligned": true,
"selected_recipe": "address_documents_by_counterparty_v1",
"selected_recipe_ids": [],
"extracted_filters": {
"sort": "period_desc",
"limit": 20,
"counterparty": "alfa",
"period_from": "2020-07-01",
"period_to": "2020-07-31"
},
"runtime_readiness": "LIVE_QUERYABLE_WITH_LIMITS",
"account_scope_mode": "preferred",
"account_scope_fallback_applied": false,
"mcp_call_status": "materialized_but_not_anchor_matched",
"mcp_call_status_legacy": "materialized_but_not_matched",
"stage_interpretation": "Rows materialized, but anchor resolution/matching removed all candidates.",
"match_failure_stage": "materialized_but_not_anchor_matched",
"match_failure_reason": "counterparty_anchor_not_matched_in_materialized_rows",
"rows_fetched": 19,
"raw_rows_received": 19,
"rows_after_account_scope": 3,
"rows_materialized": 3,
"rows_after_recipe_filter": 0,
"rows_matched": 0,
"materialization_drop_reason": "none",
"raw_row_keys_sample": [
"Период",
"Регистратор",
"СчетДт",
"СчетКт",
"Сумма",
"Контрагент",
"Period",
"Registrator",
"Amount"
],
"anchor_type": "counterparty",
"anchor_value_raw": "alfa",
"anchor_value_resolved": "alfa",
"resolver_confidence": "medium",
"ambiguity_count": 0,
"account_token_raw": null,
"account_token_normalized": null,
"account_scope_fields_checked": [
"account_dt",
"account_kt",
"registrator",
"analytics"
],
"account_scope_match_strategy": "account_code_regex_plus_alias_map_v1",
"account_scope_drop_reason": "not_applicable",
"limited_reason_category": "missing_anchor",
"response_is_non_empty": false,
"assistant_reply_preview": "",
"elapsed_ms": 1030,
"generated_at": "2026-03-29T18:30:46.488Z"
},
{
"id": "C4",
"family": "counterparty",
"question": "show bank operations by counterparty alfa from 2020-07-01 to 2020-07-31",
"expected_intent": "bank_operations_by_counterparty",
"expected_response_type": "LIMITED_WITH_REASON",
"expected_non_empty": false,
"handled": true,
"response_type": "LIMITED_WITH_REASON",
"reply_type": "partial_coverage",
"detected_mode": "address_query",
"query_shape": "DOCUMENT_LIST",
"detected_intent": "bank_operations_by_counterparty",
"intent_aligned": true,
"selected_recipe": "address_bank_operations_by_counterparty_v1",
"selected_recipe_ids": [],
"extracted_filters": {
"sort": "period_desc",
"limit": 20,
"counterparty": "alfa",
"period_from": "2020-07-01",
"period_to": "2020-07-31"
},
"runtime_readiness": "LIVE_QUERYABLE_WITH_LIMITS",
"account_scope_mode": "preferred",
"account_scope_fallback_applied": false,
"mcp_call_status": "materialized_but_not_anchor_matched",
"mcp_call_status_legacy": "materialized_but_not_matched",
"stage_interpretation": "Rows materialized, but anchor resolution/matching removed all candidates.",
"match_failure_stage": "materialized_but_not_anchor_matched",
"match_failure_reason": "counterparty_anchor_not_matched_in_materialized_rows",
"rows_fetched": 19,
"raw_rows_received": 19,
"rows_after_account_scope": 3,
"rows_materialized": 3,
"rows_after_recipe_filter": 0,
"rows_matched": 0,
"materialization_drop_reason": "none",
"raw_row_keys_sample": [
"Период",
"Регистратор",
"СчетДт",
"СчетКт",
"Сумма",
"Контрагент",
"Period",
"Registrator",
"Amount"
],
"anchor_type": "counterparty",
"anchor_value_raw": "alfa",
"anchor_value_resolved": "alfa",
"resolver_confidence": "medium",
"ambiguity_count": 0,
"account_token_raw": null,
"account_token_normalized": null,
"account_scope_fields_checked": [
"account_dt",
"account_kt",
"registrator",
"analytics"
],
"account_scope_match_strategy": "account_code_regex_plus_alias_map_v1",
"account_scope_drop_reason": "not_applicable",
"limited_reason_category": "missing_anchor",
"response_is_non_empty": false,
"assistant_reply_preview": "",
"elapsed_ms": 1027,
"generated_at": "2026-03-29T18:30:47.517Z"
},
{
"id": "C5",
"family": "account",
"question": "show account balance 60 today",
"expected_intent": "account_balance_snapshot",
"expected_response_type": "LIMITED_WITH_REASON",
"expected_non_empty": false,
"handled": true,
"response_type": "LIMITED_WITH_REASON",
"reply_type": "partial_coverage",
"detected_mode": "address_query",
"query_shape": "AGGREGATE_LOOKUP",
"detected_intent": "account_balance_snapshot",
"intent_aligned": true,
"selected_recipe": "address_movements_account_snapshot_v1",
"selected_recipe_ids": [],
"extracted_filters": {
"sort": "period_desc",
"limit": 20,
"account": "60",
"as_of_date": "2026-03-29"
},
"runtime_readiness": "LIVE_QUERYABLE_WITH_LIMITS",
"account_scope_mode": "strict",
"account_scope_fallback_applied": false,
"mcp_call_status": "raw_rows_received_but_not_materialized",
"mcp_call_status_legacy": "raw_rows_received_but_not_materialized",
"stage_interpretation": "Raw rows arrived, but row materialization path dropped everything.",
"match_failure_stage": "none",
"match_failure_reason": null,
"rows_fetched": 20,
"raw_rows_received": 20,
"rows_after_account_scope": 0,
"rows_materialized": 0,
"rows_after_recipe_filter": 0,
"rows_matched": 0,
"materialization_drop_reason": "dropped_by_account_scope_filter",
"raw_row_keys_sample": [
"Период",
"Регистратор",
"СчетДт",
"СчетКт",
"Сумма",
"Period",
"Registrator",
"AccountDt",
"AccountKt",
"Amount"
],
"anchor_type": "account",
"anchor_value_raw": "60",
"anchor_value_resolved": "60",
"resolver_confidence": "high",
"ambiguity_count": 0,
"account_token_raw": "60",
"account_token_normalized": "60",
"account_scope_fields_checked": [
"account_dt",
"account_kt",
"registrator",
"analytics"
],
"account_scope_match_strategy": "account_code_regex_plus_alias_map_v1",
"account_scope_drop_reason": "no_rows_after_scope_filter",
"limited_reason_category": "empty_match",
"response_is_non_empty": false,
"assistant_reply_preview": "",
"elapsed_ms": 1013,
"generated_at": "2026-03-29T18:30:48.531Z"
},
{
"id": "C6",
"family": "account",
"question": "which documents form balance for account 62 as of 2020-07-31",
"expected_intent": "documents_forming_balance",
"expected_response_type": "LIMITED_WITH_REASON",
"expected_non_empty": false,
"handled": true,
"response_type": "LIMITED_WITH_REASON",
"reply_type": "partial_coverage",
"detected_mode": "address_query",
"query_shape": "DOCUMENT_LIST",
"detected_intent": "documents_forming_balance",
"intent_aligned": true,
"selected_recipe": "address_documents_forming_balance_v1",
"selected_recipe_ids": [],
"extracted_filters": {
"sort": "period_desc",
"limit": 20,
"account": "62",
"as_of_date": "2020-07-31"
},
"runtime_readiness": "LIVE_QUERYABLE_WITH_LIMITS",
"account_scope_mode": "strict",
"account_scope_fallback_applied": false,
"mcp_call_status": "raw_rows_received_but_not_materialized",
"mcp_call_status_legacy": "raw_rows_received_but_not_materialized",
"stage_interpretation": "Raw rows arrived, but row materialization path dropped everything.",
"match_failure_stage": "none",
"match_failure_reason": null,
"rows_fetched": 20,
"raw_rows_received": 20,
"rows_after_account_scope": 0,
"rows_materialized": 0,
"rows_after_recipe_filter": 0,
"rows_matched": 0,
"materialization_drop_reason": "dropped_by_account_scope_filter",
"raw_row_keys_sample": [
"Период",
"Регистратор",
"СчетДт",
"СчетКт",
"Сумма",
"Period",
"Registrator",
"AccountDt",
"AccountKt",
"Amount"
],
"anchor_type": "account",
"anchor_value_raw": "62",
"anchor_value_resolved": "62",
"resolver_confidence": "high",
"ambiguity_count": 0,
"account_token_raw": "62",
"account_token_normalized": "62",
"account_scope_fields_checked": [
"account_dt",
"account_kt",
"registrator",
"analytics"
],
"account_scope_match_strategy": "account_code_regex_plus_alias_map_v1",
"account_scope_drop_reason": "no_rows_after_scope_filter",
"limited_reason_category": "empty_match",
"response_is_non_empty": false,
"assistant_reply_preview": "",
"elapsed_ms": 969,
"generated_at": "2026-03-29T18:30:49.501Z"
},
{
"id": "C7",
"family": "account",
"question": "which documents form balance for account 60 as of 2020-07-31",
"expected_intent": "documents_forming_balance",
"expected_response_type": "LIMITED_WITH_REASON",
"expected_non_empty": false,
"handled": true,
"response_type": "LIMITED_WITH_REASON",
"reply_type": "partial_coverage",
"detected_mode": "address_query",
"query_shape": "DOCUMENT_LIST",
"detected_intent": "documents_forming_balance",
"intent_aligned": true,
"selected_recipe": "address_documents_forming_balance_v1",
"selected_recipe_ids": [],
"extracted_filters": {
"sort": "period_desc",
"limit": 20,
"account": "60",
"as_of_date": "2020-07-31"
},
"runtime_readiness": "LIVE_QUERYABLE_WITH_LIMITS",
"account_scope_mode": "strict",
"account_scope_fallback_applied": false,
"mcp_call_status": "raw_rows_received_but_not_materialized",
"mcp_call_status_legacy": "raw_rows_received_but_not_materialized",
"stage_interpretation": "Raw rows arrived, but row materialization path dropped everything.",
"match_failure_stage": "none",
"match_failure_reason": null,
"rows_fetched": 20,
"raw_rows_received": 20,
"rows_after_account_scope": 0,
"rows_materialized": 0,
"rows_after_recipe_filter": 0,
"rows_matched": 0,
"materialization_drop_reason": "dropped_by_account_scope_filter",
"raw_row_keys_sample": [
"Период",
"Регистратор",
"СчетДт",
"СчетКт",
"Сумма",
"Period",
"Registrator",
"AccountDt",
"AccountKt",
"Amount"
],
"anchor_type": "account",
"anchor_value_raw": "60",
"anchor_value_resolved": "60",
"resolver_confidence": "high",
"ambiguity_count": 0,
"account_token_raw": "60",
"account_token_normalized": "60",
"account_scope_fields_checked": [
"account_dt",
"account_kt",
"registrator",
"analytics"
],
"account_scope_match_strategy": "account_code_regex_plus_alias_map_v1",
"account_scope_drop_reason": "no_rows_after_scope_filter",
"limited_reason_category": "empty_match",
"response_is_non_empty": false,
"assistant_reply_preview": "",
"elapsed_ms": 1050,
"generated_at": "2026-03-29T18:30:50.552Z"
},
{
"id": "C8",
"family": "account",
"question": "show account balance 51 as of 2020-07-31",
"expected_intent": "account_balance_snapshot",
"expected_response_type": "LIMITED_WITH_REASON",
"expected_non_empty": false,
"handled": true,
"response_type": "LIMITED_WITH_REASON",
"reply_type": "partial_coverage",
"detected_mode": "address_query",
"query_shape": "AGGREGATE_LOOKUP",
"detected_intent": "account_balance_snapshot",
"intent_aligned": true,
"selected_recipe": "address_movements_account_snapshot_v1",
"selected_recipe_ids": [],
"extracted_filters": {
"sort": "period_desc",
"limit": 20,
"account": "51",
"as_of_date": "2020-07-31"
},
"runtime_readiness": "LIVE_QUERYABLE_WITH_LIMITS",
"account_scope_mode": "strict",
"account_scope_fallback_applied": false,
"mcp_call_status": "raw_rows_received_but_not_materialized",
"mcp_call_status_legacy": "raw_rows_received_but_not_materialized",
"stage_interpretation": "Raw rows arrived, but row materialization path dropped everything.",
"match_failure_stage": "none",
"match_failure_reason": null,
"rows_fetched": 20,
"raw_rows_received": 20,
"rows_after_account_scope": 0,
"rows_materialized": 0,
"rows_after_recipe_filter": 0,
"rows_matched": 0,
"materialization_drop_reason": "dropped_by_account_scope_filter",
"raw_row_keys_sample": [
"Период",
"Регистратор",
"СчетДт",
"СчетКт",
"Сумма",
"Period",
"Registrator",
"AccountDt",
"AccountKt",
"Amount"
],
"anchor_type": "account",
"anchor_value_raw": "51",
"anchor_value_resolved": "51",
"resolver_confidence": "high",
"ambiguity_count": 0,
"account_token_raw": "51",
"account_token_normalized": "51",
"account_scope_fields_checked": [
"account_dt",
"account_kt",
"registrator",
"analytics"
],
"account_scope_match_strategy": "account_code_regex_plus_alias_map_v1",
"account_scope_drop_reason": "no_rows_after_scope_filter",
"limited_reason_category": "empty_match",
"response_is_non_empty": false,
"assistant_reply_preview": "",
"elapsed_ms": 1034,
"generated_at": "2026-03-29T18:30:51.587Z"
}
]
}

View File

@ -0,0 +1,28 @@
{
"compared_from": "2026-03-29_Address_Query_Runtime_V1_M2_3B_AccountScope_Mode_Tuning",
"compared_to": "2026-03-29_Address_Query_Runtime_V1_M2_3C_Resolver_Filter_Tuning_And_AccountScope_Audit",
"comparison_scope": "stage_diagnostic_plus_curated_positive_suite",
"metrics": {
"factual_positive_rate": {
"before": 0,
"after": 0.25
},
"false_factual_rate": {
"before": 0,
"after": 0
},
"counterparty_non_empty_cases": {
"before": 0,
"after": 2
},
"account_non_empty_cases": {
"before": 0,
"after": 0
}
},
"narrative": [
"Counterparty scenarios moved from materialized_but_not_matched to matched_non_empty on curated positive cases.",
"Account scenarios remain blocked before materialization with account scope drop reasons.",
"False factual output remains zero."
]
}

View File

@ -0,0 +1,19 @@
docs/ADDRESS/address_query/README.md
docs/ADDRESS/address_query/address_runtime_contracts.md
docs/ADDRESS/address_query/address_scenario_matrix.md
docs/ADDRESS/address_query/query_recipes_v1.md
docs/ADDRESS/address_query/runtime_readiness_matrix_v1.md
llm_normalizer/backend/dist/services/addressMcpClient.js
llm_normalizer/backend/dist/services/addressQueryService.js
llm_normalizer/backend/dist/services/addressRecipeCatalog.js
llm_normalizer/backend/dist/services/assistantService.js
llm_normalizer/backend/src/services/addressMcpClient.ts
llm_normalizer/backend/src/services/addressQueryService.ts
llm_normalizer/backend/src/services/addressRecipeCatalog.ts
llm_normalizer/backend/src/services/assistantService.ts
llm_normalizer/backend/src/types/addressQuery.ts
llm_normalizer/backend/src/types/assistant.ts
llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts
docs/ADDRESS/address_query/curated_positive_live_suite_v1.md
docs/ADDRESS/runs/2026-03-29_Address_Query_Runtime_V1_M2_3C_Resolver_Filter_Tuning_And_AccountScope_Audit/
llm_normalizer/backend/scripts/runAddressM23cPack.js

View File

@ -0,0 +1,14 @@
# Curated Positive Case Matrix (M2.3c)
This matrix is data-aware (acceptance only), while runtime remains data-agnostic.
| case_id | family | expected_non_empty | actual_non_empty | expected_response | actual_response | selected_recipe | anchor_raw | anchor_resolved |
|---|---|---|---|---|---|---|---|---|
| C1 | counterparty | yes | yes | FACTUAL_LIST | FACTUAL_LIST | address_documents_by_counterparty_v1 | svk | Группа СВК |
| C2 | counterparty | yes | yes | FACTUAL_LIST | FACTUAL_LIST | address_bank_operations_by_counterparty_v1 | svk | Группа СВК |
| C3 | counterparty | no | no | LIMITED_WITH_REASON | LIMITED_WITH_REASON | address_documents_by_counterparty_v1 | alfa | alfa |
| C4 | counterparty | no | no | LIMITED_WITH_REASON | LIMITED_WITH_REASON | address_bank_operations_by_counterparty_v1 | alfa | alfa |
| C5 | account | no | no | LIMITED_WITH_REASON | LIMITED_WITH_REASON | address_movements_account_snapshot_v1 | 60 | 60 |
| C6 | account | no | no | LIMITED_WITH_REASON | LIMITED_WITH_REASON | address_documents_forming_balance_v1 | 62 | 62 |
| C7 | account | no | no | LIMITED_WITH_REASON | LIMITED_WITH_REASON | address_documents_forming_balance_v1 | 60 | 60 |
| C8 | account | no | no | LIMITED_WITH_REASON | LIMITED_WITH_REASON | address_movements_account_snapshot_v1 | 51 | 51 |

View File

@ -0,0 +1,80 @@
{
"case": {
"id": "C1",
"family": "counterparty",
"question": "show documents by counterparty svk from 2020-07-01 to 2020-07-31",
"expected_intent": "list_documents_by_counterparty",
"expected_response_type": "FACTUAL_LIST",
"expected_non_empty": true
},
"result": {
"id": "C1",
"family": "counterparty",
"question": "show documents by counterparty svk from 2020-07-01 to 2020-07-31",
"expected_intent": "list_documents_by_counterparty",
"expected_response_type": "FACTUAL_LIST",
"expected_non_empty": true,
"handled": true,
"response_type": "FACTUAL_LIST",
"reply_type": "factual",
"detected_mode": "address_query",
"query_shape": "DOCUMENT_LIST",
"detected_intent": "list_documents_by_counterparty",
"intent_aligned": true,
"selected_recipe": "address_documents_by_counterparty_v1",
"selected_recipe_ids": [],
"extracted_filters": {
"sort": "period_desc",
"limit": 20,
"counterparty": "svk",
"period_from": "2020-07-01",
"period_to": "2020-07-31"
},
"runtime_readiness": "LIVE_QUERYABLE_WITH_LIMITS",
"account_scope_mode": "preferred",
"account_scope_fallback_applied": false,
"mcp_call_status": "matched_non_empty",
"mcp_call_status_legacy": "matched_non_empty",
"stage_interpretation": "Rows passed all stages and produced factual non-empty output.",
"match_failure_stage": "none",
"match_failure_reason": null,
"rows_fetched": 19,
"raw_rows_received": 19,
"rows_after_account_scope": 3,
"rows_materialized": 3,
"rows_after_recipe_filter": 2,
"rows_matched": 2,
"materialization_drop_reason": "none",
"raw_row_keys_sample": [
"Период",
"Регистратор",
"СчетДт",
"СчетКт",
"Сумма",
"Контрагент",
"Period",
"Registrator",
"Amount"
],
"anchor_type": "counterparty",
"anchor_value_raw": "svk",
"anchor_value_resolved": "Группа СВК",
"resolver_confidence": "medium",
"ambiguity_count": 0,
"account_token_raw": null,
"account_token_normalized": null,
"account_scope_fields_checked": [
"account_dt",
"account_kt",
"registrator",
"analytics"
],
"account_scope_match_strategy": "account_code_regex_plus_alias_map_v1",
"account_scope_drop_reason": "not_applicable",
"limited_reason_category": null,
"response_is_non_empty": true,
"assistant_reply_preview": "",
"elapsed_ms": 513,
"generated_at": "2026-03-29T18:30:44.406Z"
}
}

View File

@ -0,0 +1,80 @@
{
"case": {
"id": "C2",
"family": "counterparty",
"question": "show bank operations by counterparty svk from 2020-07-01 to 2020-07-31",
"expected_intent": "bank_operations_by_counterparty",
"expected_response_type": "FACTUAL_LIST",
"expected_non_empty": true
},
"result": {
"id": "C2",
"family": "counterparty",
"question": "show bank operations by counterparty svk from 2020-07-01 to 2020-07-31",
"expected_intent": "bank_operations_by_counterparty",
"expected_response_type": "FACTUAL_LIST",
"expected_non_empty": true,
"handled": true,
"response_type": "FACTUAL_LIST",
"reply_type": "factual",
"detected_mode": "address_query",
"query_shape": "DOCUMENT_LIST",
"detected_intent": "bank_operations_by_counterparty",
"intent_aligned": true,
"selected_recipe": "address_bank_operations_by_counterparty_v1",
"selected_recipe_ids": [],
"extracted_filters": {
"sort": "period_desc",
"limit": 20,
"counterparty": "svk",
"period_from": "2020-07-01",
"period_to": "2020-07-31"
},
"runtime_readiness": "LIVE_QUERYABLE_WITH_LIMITS",
"account_scope_mode": "preferred",
"account_scope_fallback_applied": false,
"mcp_call_status": "matched_non_empty",
"mcp_call_status_legacy": "matched_non_empty",
"stage_interpretation": "Rows passed all stages and produced factual non-empty output.",
"match_failure_stage": "none",
"match_failure_reason": null,
"rows_fetched": 19,
"raw_rows_received": 19,
"rows_after_account_scope": 3,
"rows_materialized": 3,
"rows_after_recipe_filter": 2,
"rows_matched": 2,
"materialization_drop_reason": "none",
"raw_row_keys_sample": [
"Период",
"Регистратор",
"СчетДт",
"СчетКт",
"Сумма",
"Контрагент",
"Period",
"Registrator",
"Amount"
],
"anchor_type": "counterparty",
"anchor_value_raw": "svk",
"anchor_value_resolved": "Группа СВК",
"resolver_confidence": "medium",
"ambiguity_count": 0,
"account_token_raw": null,
"account_token_normalized": null,
"account_scope_fields_checked": [
"account_dt",
"account_kt",
"registrator",
"analytics"
],
"account_scope_match_strategy": "account_code_regex_plus_alias_map_v1",
"account_scope_drop_reason": "not_applicable",
"limited_reason_category": null,
"response_is_non_empty": true,
"assistant_reply_preview": "",
"elapsed_ms": 1046,
"generated_at": "2026-03-29T18:30:45.456Z"
}
}

View File

@ -0,0 +1,80 @@
{
"case": {
"id": "C3",
"family": "counterparty",
"question": "show documents by counterparty alfa from 2020-07-01 to 2020-07-31",
"expected_intent": "list_documents_by_counterparty",
"expected_response_type": "LIMITED_WITH_REASON",
"expected_non_empty": false
},
"result": {
"id": "C3",
"family": "counterparty",
"question": "show documents by counterparty alfa from 2020-07-01 to 2020-07-31",
"expected_intent": "list_documents_by_counterparty",
"expected_response_type": "LIMITED_WITH_REASON",
"expected_non_empty": false,
"handled": true,
"response_type": "LIMITED_WITH_REASON",
"reply_type": "partial_coverage",
"detected_mode": "address_query",
"query_shape": "DOCUMENT_LIST",
"detected_intent": "list_documents_by_counterparty",
"intent_aligned": true,
"selected_recipe": "address_documents_by_counterparty_v1",
"selected_recipe_ids": [],
"extracted_filters": {
"sort": "period_desc",
"limit": 20,
"counterparty": "alfa",
"period_from": "2020-07-01",
"period_to": "2020-07-31"
},
"runtime_readiness": "LIVE_QUERYABLE_WITH_LIMITS",
"account_scope_mode": "preferred",
"account_scope_fallback_applied": false,
"mcp_call_status": "materialized_but_not_anchor_matched",
"mcp_call_status_legacy": "materialized_but_not_matched",
"stage_interpretation": "Rows materialized, but anchor resolution/matching removed all candidates.",
"match_failure_stage": "materialized_but_not_anchor_matched",
"match_failure_reason": "counterparty_anchor_not_matched_in_materialized_rows",
"rows_fetched": 19,
"raw_rows_received": 19,
"rows_after_account_scope": 3,
"rows_materialized": 3,
"rows_after_recipe_filter": 0,
"rows_matched": 0,
"materialization_drop_reason": "none",
"raw_row_keys_sample": [
"Период",
"Регистратор",
"СчетДт",
"СчетКт",
"Сумма",
"Контрагент",
"Period",
"Registrator",
"Amount"
],
"anchor_type": "counterparty",
"anchor_value_raw": "alfa",
"anchor_value_resolved": "alfa",
"resolver_confidence": "medium",
"ambiguity_count": 0,
"account_token_raw": null,
"account_token_normalized": null,
"account_scope_fields_checked": [
"account_dt",
"account_kt",
"registrator",
"analytics"
],
"account_scope_match_strategy": "account_code_regex_plus_alias_map_v1",
"account_scope_drop_reason": "not_applicable",
"limited_reason_category": "missing_anchor",
"response_is_non_empty": false,
"assistant_reply_preview": "",
"elapsed_ms": 1030,
"generated_at": "2026-03-29T18:30:46.488Z"
}
}

View File

@ -0,0 +1,80 @@
{
"case": {
"id": "C4",
"family": "counterparty",
"question": "show bank operations by counterparty alfa from 2020-07-01 to 2020-07-31",
"expected_intent": "bank_operations_by_counterparty",
"expected_response_type": "LIMITED_WITH_REASON",
"expected_non_empty": false
},
"result": {
"id": "C4",
"family": "counterparty",
"question": "show bank operations by counterparty alfa from 2020-07-01 to 2020-07-31",
"expected_intent": "bank_operations_by_counterparty",
"expected_response_type": "LIMITED_WITH_REASON",
"expected_non_empty": false,
"handled": true,
"response_type": "LIMITED_WITH_REASON",
"reply_type": "partial_coverage",
"detected_mode": "address_query",
"query_shape": "DOCUMENT_LIST",
"detected_intent": "bank_operations_by_counterparty",
"intent_aligned": true,
"selected_recipe": "address_bank_operations_by_counterparty_v1",
"selected_recipe_ids": [],
"extracted_filters": {
"sort": "period_desc",
"limit": 20,
"counterparty": "alfa",
"period_from": "2020-07-01",
"period_to": "2020-07-31"
},
"runtime_readiness": "LIVE_QUERYABLE_WITH_LIMITS",
"account_scope_mode": "preferred",
"account_scope_fallback_applied": false,
"mcp_call_status": "materialized_but_not_anchor_matched",
"mcp_call_status_legacy": "materialized_but_not_matched",
"stage_interpretation": "Rows materialized, but anchor resolution/matching removed all candidates.",
"match_failure_stage": "materialized_but_not_anchor_matched",
"match_failure_reason": "counterparty_anchor_not_matched_in_materialized_rows",
"rows_fetched": 19,
"raw_rows_received": 19,
"rows_after_account_scope": 3,
"rows_materialized": 3,
"rows_after_recipe_filter": 0,
"rows_matched": 0,
"materialization_drop_reason": "none",
"raw_row_keys_sample": [
"Период",
"Регистратор",
"СчетДт",
"СчетКт",
"Сумма",
"Контрагент",
"Period",
"Registrator",
"Amount"
],
"anchor_type": "counterparty",
"anchor_value_raw": "alfa",
"anchor_value_resolved": "alfa",
"resolver_confidence": "medium",
"ambiguity_count": 0,
"account_token_raw": null,
"account_token_normalized": null,
"account_scope_fields_checked": [
"account_dt",
"account_kt",
"registrator",
"analytics"
],
"account_scope_match_strategy": "account_code_regex_plus_alias_map_v1",
"account_scope_drop_reason": "not_applicable",
"limited_reason_category": "missing_anchor",
"response_is_non_empty": false,
"assistant_reply_preview": "",
"elapsed_ms": 1027,
"generated_at": "2026-03-29T18:30:47.517Z"
}
}

View File

@ -0,0 +1,80 @@
{
"case": {
"id": "C5",
"family": "account",
"question": "show account balance 60 today",
"expected_intent": "account_balance_snapshot",
"expected_response_type": "LIMITED_WITH_REASON",
"expected_non_empty": false
},
"result": {
"id": "C5",
"family": "account",
"question": "show account balance 60 today",
"expected_intent": "account_balance_snapshot",
"expected_response_type": "LIMITED_WITH_REASON",
"expected_non_empty": false,
"handled": true,
"response_type": "LIMITED_WITH_REASON",
"reply_type": "partial_coverage",
"detected_mode": "address_query",
"query_shape": "AGGREGATE_LOOKUP",
"detected_intent": "account_balance_snapshot",
"intent_aligned": true,
"selected_recipe": "address_movements_account_snapshot_v1",
"selected_recipe_ids": [],
"extracted_filters": {
"sort": "period_desc",
"limit": 20,
"account": "60",
"as_of_date": "2026-03-29"
},
"runtime_readiness": "LIVE_QUERYABLE_WITH_LIMITS",
"account_scope_mode": "strict",
"account_scope_fallback_applied": false,
"mcp_call_status": "raw_rows_received_but_not_materialized",
"mcp_call_status_legacy": "raw_rows_received_but_not_materialized",
"stage_interpretation": "Raw rows arrived, but row materialization path dropped everything.",
"match_failure_stage": "none",
"match_failure_reason": null,
"rows_fetched": 20,
"raw_rows_received": 20,
"rows_after_account_scope": 0,
"rows_materialized": 0,
"rows_after_recipe_filter": 0,
"rows_matched": 0,
"materialization_drop_reason": "dropped_by_account_scope_filter",
"raw_row_keys_sample": [
"Период",
"Регистратор",
"СчетДт",
"СчетКт",
"Сумма",
"Period",
"Registrator",
"AccountDt",
"AccountKt",
"Amount"
],
"anchor_type": "account",
"anchor_value_raw": "60",
"anchor_value_resolved": "60",
"resolver_confidence": "high",
"ambiguity_count": 0,
"account_token_raw": "60",
"account_token_normalized": "60",
"account_scope_fields_checked": [
"account_dt",
"account_kt",
"registrator",
"analytics"
],
"account_scope_match_strategy": "account_code_regex_plus_alias_map_v1",
"account_scope_drop_reason": "no_rows_after_scope_filter",
"limited_reason_category": "empty_match",
"response_is_non_empty": false,
"assistant_reply_preview": "",
"elapsed_ms": 1013,
"generated_at": "2026-03-29T18:30:48.531Z"
}
}

View File

@ -0,0 +1,80 @@
{
"case": {
"id": "C6",
"family": "account",
"question": "which documents form balance for account 62 as of 2020-07-31",
"expected_intent": "documents_forming_balance",
"expected_response_type": "LIMITED_WITH_REASON",
"expected_non_empty": false
},
"result": {
"id": "C6",
"family": "account",
"question": "which documents form balance for account 62 as of 2020-07-31",
"expected_intent": "documents_forming_balance",
"expected_response_type": "LIMITED_WITH_REASON",
"expected_non_empty": false,
"handled": true,
"response_type": "LIMITED_WITH_REASON",
"reply_type": "partial_coverage",
"detected_mode": "address_query",
"query_shape": "DOCUMENT_LIST",
"detected_intent": "documents_forming_balance",
"intent_aligned": true,
"selected_recipe": "address_documents_forming_balance_v1",
"selected_recipe_ids": [],
"extracted_filters": {
"sort": "period_desc",
"limit": 20,
"account": "62",
"as_of_date": "2020-07-31"
},
"runtime_readiness": "LIVE_QUERYABLE_WITH_LIMITS",
"account_scope_mode": "strict",
"account_scope_fallback_applied": false,
"mcp_call_status": "raw_rows_received_but_not_materialized",
"mcp_call_status_legacy": "raw_rows_received_but_not_materialized",
"stage_interpretation": "Raw rows arrived, but row materialization path dropped everything.",
"match_failure_stage": "none",
"match_failure_reason": null,
"rows_fetched": 20,
"raw_rows_received": 20,
"rows_after_account_scope": 0,
"rows_materialized": 0,
"rows_after_recipe_filter": 0,
"rows_matched": 0,
"materialization_drop_reason": "dropped_by_account_scope_filter",
"raw_row_keys_sample": [
"Период",
"Регистратор",
"СчетДт",
"СчетКт",
"Сумма",
"Period",
"Registrator",
"AccountDt",
"AccountKt",
"Amount"
],
"anchor_type": "account",
"anchor_value_raw": "62",
"anchor_value_resolved": "62",
"resolver_confidence": "high",
"ambiguity_count": 0,
"account_token_raw": "62",
"account_token_normalized": "62",
"account_scope_fields_checked": [
"account_dt",
"account_kt",
"registrator",
"analytics"
],
"account_scope_match_strategy": "account_code_regex_plus_alias_map_v1",
"account_scope_drop_reason": "no_rows_after_scope_filter",
"limited_reason_category": "empty_match",
"response_is_non_empty": false,
"assistant_reply_preview": "",
"elapsed_ms": 969,
"generated_at": "2026-03-29T18:30:49.501Z"
}
}

View File

@ -0,0 +1,80 @@
{
"case": {
"id": "C7",
"family": "account",
"question": "which documents form balance for account 60 as of 2020-07-31",
"expected_intent": "documents_forming_balance",
"expected_response_type": "LIMITED_WITH_REASON",
"expected_non_empty": false
},
"result": {
"id": "C7",
"family": "account",
"question": "which documents form balance for account 60 as of 2020-07-31",
"expected_intent": "documents_forming_balance",
"expected_response_type": "LIMITED_WITH_REASON",
"expected_non_empty": false,
"handled": true,
"response_type": "LIMITED_WITH_REASON",
"reply_type": "partial_coverage",
"detected_mode": "address_query",
"query_shape": "DOCUMENT_LIST",
"detected_intent": "documents_forming_balance",
"intent_aligned": true,
"selected_recipe": "address_documents_forming_balance_v1",
"selected_recipe_ids": [],
"extracted_filters": {
"sort": "period_desc",
"limit": 20,
"account": "60",
"as_of_date": "2020-07-31"
},
"runtime_readiness": "LIVE_QUERYABLE_WITH_LIMITS",
"account_scope_mode": "strict",
"account_scope_fallback_applied": false,
"mcp_call_status": "raw_rows_received_but_not_materialized",
"mcp_call_status_legacy": "raw_rows_received_but_not_materialized",
"stage_interpretation": "Raw rows arrived, but row materialization path dropped everything.",
"match_failure_stage": "none",
"match_failure_reason": null,
"rows_fetched": 20,
"raw_rows_received": 20,
"rows_after_account_scope": 0,
"rows_materialized": 0,
"rows_after_recipe_filter": 0,
"rows_matched": 0,
"materialization_drop_reason": "dropped_by_account_scope_filter",
"raw_row_keys_sample": [
"Период",
"Регистратор",
"СчетДт",
"СчетКт",
"Сумма",
"Period",
"Registrator",
"AccountDt",
"AccountKt",
"Amount"
],
"anchor_type": "account",
"anchor_value_raw": "60",
"anchor_value_resolved": "60",
"resolver_confidence": "high",
"ambiguity_count": 0,
"account_token_raw": "60",
"account_token_normalized": "60",
"account_scope_fields_checked": [
"account_dt",
"account_kt",
"registrator",
"analytics"
],
"account_scope_match_strategy": "account_code_regex_plus_alias_map_v1",
"account_scope_drop_reason": "no_rows_after_scope_filter",
"limited_reason_category": "empty_match",
"response_is_non_empty": false,
"assistant_reply_preview": "",
"elapsed_ms": 1050,
"generated_at": "2026-03-29T18:30:50.552Z"
}
}

View File

@ -0,0 +1,80 @@
{
"case": {
"id": "C8",
"family": "account",
"question": "show account balance 51 as of 2020-07-31",
"expected_intent": "account_balance_snapshot",
"expected_response_type": "LIMITED_WITH_REASON",
"expected_non_empty": false
},
"result": {
"id": "C8",
"family": "account",
"question": "show account balance 51 as of 2020-07-31",
"expected_intent": "account_balance_snapshot",
"expected_response_type": "LIMITED_WITH_REASON",
"expected_non_empty": false,
"handled": true,
"response_type": "LIMITED_WITH_REASON",
"reply_type": "partial_coverage",
"detected_mode": "address_query",
"query_shape": "AGGREGATE_LOOKUP",
"detected_intent": "account_balance_snapshot",
"intent_aligned": true,
"selected_recipe": "address_movements_account_snapshot_v1",
"selected_recipe_ids": [],
"extracted_filters": {
"sort": "period_desc",
"limit": 20,
"account": "51",
"as_of_date": "2020-07-31"
},
"runtime_readiness": "LIVE_QUERYABLE_WITH_LIMITS",
"account_scope_mode": "strict",
"account_scope_fallback_applied": false,
"mcp_call_status": "raw_rows_received_but_not_materialized",
"mcp_call_status_legacy": "raw_rows_received_but_not_materialized",
"stage_interpretation": "Raw rows arrived, but row materialization path dropped everything.",
"match_failure_stage": "none",
"match_failure_reason": null,
"rows_fetched": 20,
"raw_rows_received": 20,
"rows_after_account_scope": 0,
"rows_materialized": 0,
"rows_after_recipe_filter": 0,
"rows_matched": 0,
"materialization_drop_reason": "dropped_by_account_scope_filter",
"raw_row_keys_sample": [
"Период",
"Регистратор",
"СчетДт",
"СчетКт",
"Сумма",
"Period",
"Registrator",
"AccountDt",
"AccountKt",
"Amount"
],
"anchor_type": "account",
"anchor_value_raw": "51",
"anchor_value_resolved": "51",
"resolver_confidence": "high",
"ambiguity_count": 0,
"account_token_raw": "51",
"account_token_normalized": "51",
"account_scope_fields_checked": [
"account_dt",
"account_kt",
"registrator",
"analytics"
],
"account_scope_match_strategy": "account_code_regex_plus_alias_map_v1",
"account_scope_drop_reason": "no_rows_after_scope_filter",
"limited_reason_category": "empty_match",
"response_is_non_empty": false,
"assistant_reply_preview": "",
"elapsed_ms": 1034,
"generated_at": "2026-03-29T18:30:51.587Z"
}
}

View File

@ -0,0 +1,142 @@
{
"generated_at": "2026-03-29T18:30:51.853Z",
"run_id": "2026-03-29_Address_Query_Runtime_V1_M2_3C_Resolver_Filter_Tuning_And_AccountScope_Audit",
"inventory": [
{
"case_id": "C1",
"family": "counterparty",
"question": "show documents by counterparty svk from 2020-07-01 to 2020-07-31",
"recipe": "address_documents_by_counterparty_v1",
"query_shape": "DOCUMENT_LIST",
"detected_intent": "list_documents_by_counterparty",
"raw_rows_received": 19,
"rows_after_account_scope": 3,
"rows_materialized": 3,
"rows_after_recipe_filter": 2,
"rows_matched": 2,
"mcp_call_status": "matched_non_empty",
"match_failure_stage": "none",
"match_failure_reason": null,
"limited_reason_category": null
},
{
"case_id": "C2",
"family": "counterparty",
"question": "show bank operations by counterparty svk from 2020-07-01 to 2020-07-31",
"recipe": "address_bank_operations_by_counterparty_v1",
"query_shape": "DOCUMENT_LIST",
"detected_intent": "bank_operations_by_counterparty",
"raw_rows_received": 19,
"rows_after_account_scope": 3,
"rows_materialized": 3,
"rows_after_recipe_filter": 2,
"rows_matched": 2,
"mcp_call_status": "matched_non_empty",
"match_failure_stage": "none",
"match_failure_reason": null,
"limited_reason_category": null
},
{
"case_id": "C3",
"family": "counterparty",
"question": "show documents by counterparty alfa from 2020-07-01 to 2020-07-31",
"recipe": "address_documents_by_counterparty_v1",
"query_shape": "DOCUMENT_LIST",
"detected_intent": "list_documents_by_counterparty",
"raw_rows_received": 19,
"rows_after_account_scope": 3,
"rows_materialized": 3,
"rows_after_recipe_filter": 0,
"rows_matched": 0,
"mcp_call_status": "materialized_but_not_anchor_matched",
"match_failure_stage": "materialized_but_not_anchor_matched",
"match_failure_reason": "counterparty_anchor_not_matched_in_materialized_rows",
"limited_reason_category": "missing_anchor"
},
{
"case_id": "C4",
"family": "counterparty",
"question": "show bank operations by counterparty alfa from 2020-07-01 to 2020-07-31",
"recipe": "address_bank_operations_by_counterparty_v1",
"query_shape": "DOCUMENT_LIST",
"detected_intent": "bank_operations_by_counterparty",
"raw_rows_received": 19,
"rows_after_account_scope": 3,
"rows_materialized": 3,
"rows_after_recipe_filter": 0,
"rows_matched": 0,
"mcp_call_status": "materialized_but_not_anchor_matched",
"match_failure_stage": "materialized_but_not_anchor_matched",
"match_failure_reason": "counterparty_anchor_not_matched_in_materialized_rows",
"limited_reason_category": "missing_anchor"
},
{
"case_id": "C5",
"family": "account",
"question": "show account balance 60 today",
"recipe": "address_movements_account_snapshot_v1",
"query_shape": "AGGREGATE_LOOKUP",
"detected_intent": "account_balance_snapshot",
"raw_rows_received": 20,
"rows_after_account_scope": 0,
"rows_materialized": 0,
"rows_after_recipe_filter": 0,
"rows_matched": 0,
"mcp_call_status": "raw_rows_received_but_not_materialized",
"match_failure_stage": "none",
"match_failure_reason": null,
"limited_reason_category": "empty_match"
},
{
"case_id": "C6",
"family": "account",
"question": "which documents form balance for account 62 as of 2020-07-31",
"recipe": "address_documents_forming_balance_v1",
"query_shape": "DOCUMENT_LIST",
"detected_intent": "documents_forming_balance",
"raw_rows_received": 20,
"rows_after_account_scope": 0,
"rows_materialized": 0,
"rows_after_recipe_filter": 0,
"rows_matched": 0,
"mcp_call_status": "raw_rows_received_but_not_materialized",
"match_failure_stage": "none",
"match_failure_reason": null,
"limited_reason_category": "empty_match"
},
{
"case_id": "C7",
"family": "account",
"question": "which documents form balance for account 60 as of 2020-07-31",
"recipe": "address_documents_forming_balance_v1",
"query_shape": "DOCUMENT_LIST",
"detected_intent": "documents_forming_balance",
"raw_rows_received": 20,
"rows_after_account_scope": 0,
"rows_materialized": 0,
"rows_after_recipe_filter": 0,
"rows_matched": 0,
"mcp_call_status": "raw_rows_received_but_not_materialized",
"match_failure_stage": "none",
"match_failure_reason": null,
"limited_reason_category": "empty_match"
},
{
"case_id": "C8",
"family": "account",
"question": "show account balance 51 as of 2020-07-31",
"recipe": "address_movements_account_snapshot_v1",
"query_shape": "AGGREGATE_LOOKUP",
"detected_intent": "account_balance_snapshot",
"raw_rows_received": 20,
"rows_after_account_scope": 0,
"rows_materialized": 0,
"rows_after_recipe_filter": 0,
"rows_matched": 0,
"mcp_call_status": "raw_rows_received_but_not_materialized",
"match_failure_stage": "none",
"match_failure_reason": null,
"limited_reason_category": "empty_match"
}
]
}

View File

@ -0,0 +1,58 @@
{
"run_id": "2026-03-29_Address_Query_Runtime_V1_M2_3C_Resolver_Filter_Tuning_And_AccountScope_Audit",
"date": "2026-03-29",
"stage": "address_query_runtime_v1",
"scope": "m2_3c_resolver_filter_tuning_and_account_scope_audit",
"build_status": "PASSED",
"tests_status": "PASSED",
"diagnostic_run_status": "COMPLETED",
"implemented": {
"counterparty_anchor_refinement_after_materialization": true,
"split_match_failure_stages": true,
"legacy_status_compatibility_field": true,
"account_scope_audit_fields": true,
"bank_docs_query_template_for_counterparty_intents": true
},
"metrics": {
"cases_total": 8,
"intent_alignment_rate": 1,
"factual_positive_rate": 0.25,
"limited_mode_rate": 0.75,
"false_factual_rate": 0,
"counterparty_family_non_empty_rate": 0.5,
"account_family_non_empty_rate": 0
},
"stage_status_distribution": [
{
"status": "matched_non_empty",
"count": 2
},
{
"status": "materialized_but_not_anchor_matched",
"count": 2
},
{
"status": "raw_rows_received_but_not_materialized",
"count": 4
}
],
"failure_reason_distribution": [
{
"reason": "none",
"count": 2
},
{
"reason": "counterparty_anchor_not_matched_in_materialized_rows",
"count": 2
},
{
"reason": "dropped_by_account_scope_filter",
"count": 4
}
],
"key_findings": {
"counterparty_track": "positive factual responses now confirmed on curated non-empty live cases",
"account_track": "account intents still stop at raw_rows_received_but_not_materialized",
"next_priority": "account scope/materialization shape audit to unblock first non-empty account case"
}
}

View File

@ -0,0 +1,9 @@
# Smoke Checks (M2.3c)
- `npm.cmd run build` -> PASSED
- `npx.cmd vitest tests/addressQueryRuntimeM23.test.ts` -> PASSED (10/10)
- M2.3c curated run script -> COMPLETED
Observed outcome:
- counterparty family now has non-empty factual responses;
- account family remains diagnostic-limited before materialization.

View File

@ -0,0 +1,17 @@
# Stage Diagnostic Matrix (M2.3c)
| case_id | family | expected_intent | detected_intent | status | rows_after_account_scope | rows_materialized | rows_after_recipe_filter | rows_matched | response_type | limited_reason |
|---|---|---|---|---|---|---|---|---|---|---|
| C1 | counterparty | list_documents_by_counterparty | list_documents_by_counterparty | matched_non_empty | 3 | 3 | 2 | 2 | FACTUAL_LIST | |
| C2 | counterparty | bank_operations_by_counterparty | bank_operations_by_counterparty | matched_non_empty | 3 | 3 | 2 | 2 | FACTUAL_LIST | |
| C3 | counterparty | list_documents_by_counterparty | list_documents_by_counterparty | materialized_but_not_anchor_matched | 3 | 3 | 0 | 0 | LIMITED_WITH_REASON | missing_anchor |
| C4 | counterparty | bank_operations_by_counterparty | bank_operations_by_counterparty | materialized_but_not_anchor_matched | 3 | 3 | 0 | 0 | LIMITED_WITH_REASON | missing_anchor |
| C5 | account | account_balance_snapshot | account_balance_snapshot | raw_rows_received_but_not_materialized | 0 | 0 | 0 | 0 | LIMITED_WITH_REASON | empty_match |
| C6 | account | documents_forming_balance | documents_forming_balance | raw_rows_received_but_not_materialized | 0 | 0 | 0 | 0 | LIMITED_WITH_REASON | empty_match |
| C7 | account | documents_forming_balance | documents_forming_balance | raw_rows_received_but_not_materialized | 0 | 0 | 0 | 0 | LIMITED_WITH_REASON | empty_match |
| C8 | account | account_balance_snapshot | account_balance_snapshot | raw_rows_received_but_not_materialized | 0 | 0 | 0 | 0 | LIMITED_WITH_REASON | empty_match |
Status taxonomy in this run:
- `raw_rows_received_but_not_materialized`
- `materialized_but_not_anchor_matched`
- `matched_non_empty`

View File

@ -38,21 +38,37 @@ function parseRowsFromTextTable(source) {
return [];
}
const rows = [];
const parseCsvLine = (line) => {
const values = [];
let current = "";
let inQuotes = false;
for (let index = 0; index < line.length; index += 1) {
const char = line[index];
if (char === '"') {
if (inQuotes && line[index + 1] === '"') {
current += '"';
index += 1;
continue;
}
inQuotes = !inQuotes;
continue;
}
if (char === "," && !inQuotes) {
values.push(current.trim());
current = "";
continue;
}
current += char;
}
values.push(current.trim());
return values;
};
const lines = body
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
for (const line of lines) {
const values = [];
const matcher = /"([^"]*)"|([^,]+)/g;
let match = null;
while ((match = matcher.exec(line)) !== null) {
const raw = match[1] !== undefined ? match[1] : match[2];
const value = String(raw ?? "").trim();
if (value.length > 0) {
values.push(value);
}
}
const values = parseCsvLine(line);
if (values.length === 0) {
continue;
}

View File

@ -8,6 +8,29 @@ const addressIntentResolver_1 = require("./addressIntentResolver");
const addressFilterExtractor_1 = require("./addressFilterExtractor");
const addressRecipeCatalog_1 = require("./addressRecipeCatalog");
const addressMcpClient_1 = require("./addressMcpClient");
const ACCOUNT_SCOPE_FIELDS_CHECKED = ["account_dt", "account_kt", "registrator", "analytics"];
const ACCOUNT_SCOPE_MATCH_STRATEGY = "account_code_regex_plus_alias_map_v1";
const PARTY_ANCHOR_STOPWORDS = new Set([
"ооо",
"ао",
"зао",
"ип",
"llc",
"ltd",
"company",
"компания",
"контрагент",
"counterparty",
"по",
"by"
]);
const ACCOUNT_ALIAS_MAP = {
"51": ["расчетный счет", "расчетные счета", "bank account"],
"52": ["валютный счет", "валютные счета", "currency account"],
"60": ["поставщик", "поставщиками", "подрядчиками", "расчеты с поставщиками"],
"62": ["покупатель", "покупателями", "расчеты с покупателями"],
"76": ["прочие расчеты", "прочими дебиторами и кредиторами"]
};
function parseFiniteNumber(value) {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
@ -26,11 +49,117 @@ function valueAsString(value) {
}
return String(value);
}
function normalizeToken(value) {
return String(value ?? "").trim().toLowerCase();
function transliterateCyrillicToLatin(value) {
const map = {
а: "a",
б: "b",
в: "v",
г: "g",
д: "d",
е: "e",
ё: "e",
ж: "zh",
з: "z",
и: "i",
й: "y",
к: "k",
л: "l",
м: "m",
н: "n",
о: "o",
п: "p",
р: "r",
с: "s",
т: "t",
у: "u",
ф: "f",
х: "h",
ц: "ts",
ч: "ch",
ш: "sh",
щ: "sch",
ъ: "",
ы: "y",
ь: "",
э: "e",
ю: "yu",
я: "ya"
};
let out = "";
for (const char of String(value ?? "").toLowerCase()) {
out += map[char] ?? char;
}
return out;
}
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
function normalizeSearchText(value) {
return String(value ?? "")
.toLowerCase()
.replace(/ё/g, "е")
.replace(/[^a-zа-я0-9]+/gi, " ")
.replace(/\s+/g, " ")
.trim();
}
function tokenizeAnchor(value) {
return normalizeSearchText(value)
.split(" ")
.map((token) => token.trim())
.filter((token) => token.length >= 2 && !PARTY_ANCHOR_STOPWORDS.has(token));
}
function matchesAnchorText(searchable, anchor) {
const searchableNormalized = normalizeSearchText(searchable);
const searchableLatin = transliterateCyrillicToLatin(searchableNormalized);
const tokens = tokenizeAnchor(anchor);
if (tokens.length === 0) {
const direct = normalizeSearchText(anchor);
if (!direct) {
return false;
}
return searchableNormalized.includes(direct) || searchableLatin.includes(transliterateCyrillicToLatin(direct));
}
return tokens.every((token) => {
const tokenLatin = transliterateCyrillicToLatin(token);
return searchableNormalized.includes(token) || searchableLatin.includes(tokenLatin);
});
}
function normalizeAccountToken(value) {
const source = String(value ?? "").trim().replace(",", ".");
const match = source.match(/(\d{2})(?:\.(\d{1,2}))?/);
if (!match) {
return source.toLowerCase();
}
const base = match[1];
if (!match[2]) {
return base;
}
const sub = String(Number(match[2]));
return `${base}.${sub}`;
}
function extractAccountTokens(searchable) {
const result = [];
const matcher = /\b(\d{2})(?:[.,](\d{1,2}))?\b/g;
let hit = null;
while ((hit = matcher.exec(searchable)) !== null) {
const base = hit[1];
const sub = hit[2] ? String(Number(hit[2])) : null;
result.push(sub ? `${base}.${sub}` : base);
}
return uniqueStrings(result);
}
function accountTokenMatches(requestedToken, candidateToken) {
const requested = normalizeAccountToken(requestedToken);
const candidate = normalizeAccountToken(candidateToken);
if (requested === candidate) {
return true;
}
if (!requested.includes(".")) {
return candidate.startsWith(`${requested}.`) || candidate === requested;
}
return false;
}
function baseAccountCode(value) {
const normalized = normalizeAccountToken(value);
const match = normalized.match(/^(\d{2})/);
return match ? match[1] : null;
}
function uniqueStrings(values) {
return Array.from(new Set(values
@ -110,13 +239,27 @@ function rowMatchesAnyAccount(row, accountScope) {
return true;
}
const searchable = [row.account_dt ?? "", row.account_kt ?? "", row.registrator, ...row.analytics].join(" ");
const extractedTokens = extractAccountTokens(searchable);
const normalizedSearch = normalizeSearchText(searchable);
const translitSearch = transliterateCyrillicToLatin(normalizedSearch);
return accountScope.some((account) => {
const normalized = String(account ?? "").trim();
if (!normalized) {
const normalizedRequested = normalizeAccountToken(String(account ?? "").trim());
if (!normalizedRequested) {
return false;
}
const matcher = new RegExp(`\\b${escapeRegExp(normalized)}(?:\\.\\d{1,2})?\\b`, "i");
return matcher.test(searchable);
if (extractedTokens.some((candidate) => accountTokenMatches(normalizedRequested, candidate))) {
return true;
}
const base = baseAccountCode(normalizedRequested);
if (!base) {
return false;
}
const aliases = ACCOUNT_ALIAS_MAP[base] ?? [];
return aliases.some((alias) => {
const normalizedAlias = normalizeSearchText(alias);
const aliasLatin = transliterateCyrillicToLatin(normalizedAlias);
return normalizedSearch.includes(normalizedAlias) || translitSearch.includes(aliasLatin);
});
});
}
function applyAccountScopeFilter(rows, accountScope) {
@ -127,23 +270,43 @@ function applyAccountScopeFilter(rows, accountScope) {
}
function applyAddressFilters(rows, filters) {
let filtered = [...rows];
let mismatchReason = null;
if (filters.account && String(filters.account).trim()) {
const scopedAccount = String(filters.account).trim();
const before = filtered.length;
filtered = filtered.filter((row) => rowMatchesAnyAccount(row, [scopedAccount]));
if (before > 0 && filtered.length === 0 && mismatchReason === null) {
mismatchReason = "account_anchor_not_matched_in_materialized_rows";
}
}
if (filters.counterparty && String(filters.counterparty).trim()) {
const needle = normalizeToken(String(filters.counterparty));
filtered = filtered.filter((row) => rowSearchableText(row).includes(needle));
const needle = String(filters.counterparty);
const before = filtered.length;
filtered = filtered.filter((row) => matchesAnchorText(rowSearchableText(row), needle));
if (before > 0 && filtered.length === 0 && mismatchReason === null) {
mismatchReason = "counterparty_anchor_not_matched_in_materialized_rows";
}
}
if (filters.contract && String(filters.contract).trim()) {
const needle = normalizeToken(String(filters.contract));
filtered = filtered.filter((row) => rowSearchableText(row).includes(needle));
const needle = String(filters.contract);
const before = filtered.length;
filtered = filtered.filter((row) => matchesAnchorText(rowSearchableText(row), needle));
if (before > 0 && filtered.length === 0 && mismatchReason === null) {
mismatchReason = "contract_anchor_not_matched_in_materialized_rows";
}
}
if (filters.document_ref && String(filters.document_ref).trim()) {
const needle = normalizeToken(String(filters.document_ref));
filtered = filtered.filter((row) => rowSearchableText(row).includes(needle));
const needle = String(filters.document_ref);
const before = filtered.length;
filtered = filtered.filter((row) => matchesAnchorText(rowSearchableText(row), needle));
if (before > 0 && filtered.length === 0 && mismatchReason === null) {
mismatchReason = "document_ref_anchor_not_matched_in_materialized_rows";
}
}
return filtered;
return {
rows: filtered,
mismatchReason
};
}
function applyIntentSpecificFilter(intent, rows) {
if (intent === "bank_operations_by_counterparty") {
@ -217,6 +380,48 @@ function deriveRowStageDiagnostics(rawRows, rowsAfterAccountScope, rowsMateriali
}
return { rawRowKeysSample, materializationDropReason: "unknown_row_shape" };
}
function isAccountIntent(intent) {
return intent === "account_balance_snapshot" || intent === "documents_forming_balance";
}
function buildDefaultAccountScopeAudit(filters) {
const tokenRaw = typeof filters.account === "string" && filters.account.trim().length > 0 ? filters.account.trim() : null;
return {
accountTokenRaw: tokenRaw,
accountTokenNormalized: tokenRaw ? normalizeAccountToken(tokenRaw) : null,
accountScopeFieldsChecked: [...ACCOUNT_SCOPE_FIELDS_CHECKED],
accountScopeMatchStrategy: ACCOUNT_SCOPE_MATCH_STRATEGY,
accountScopeDropReason: "not_applicable"
};
}
function buildAccountScopeAudit(input) {
const tokenRaw = typeof input.filters.account === "string" && input.filters.account.trim().length > 0 ? input.filters.account.trim() : null;
const tokenNormalized = tokenRaw ? normalizeAccountToken(tokenRaw) : null;
if (!isAccountIntent(input.intent)) {
return {
accountTokenRaw: tokenRaw,
accountTokenNormalized: tokenNormalized,
accountScopeFieldsChecked: [...ACCOUNT_SCOPE_FIELDS_CHECKED],
accountScopeMatchStrategy: ACCOUNT_SCOPE_MATCH_STRATEGY,
accountScopeDropReason: "not_applicable"
};
}
if (input.accountScope.length === 0) {
return {
accountTokenRaw: tokenRaw,
accountTokenNormalized: tokenNormalized,
accountScopeFieldsChecked: [...ACCOUNT_SCOPE_FIELDS_CHECKED],
accountScopeMatchStrategy: ACCOUNT_SCOPE_MATCH_STRATEGY,
accountScopeDropReason: "no_account_scope_requested"
};
}
return {
accountTokenRaw: tokenRaw,
accountTokenNormalized: tokenNormalized,
accountScopeFieldsChecked: [...ACCOUNT_SCOPE_FIELDS_CHECKED],
accountScopeMatchStrategy: ACCOUNT_SCOPE_MATCH_STRATEGY,
accountScopeDropReason: input.rowsBeforeScope > 0 && input.rowsAfterScope === 0 ? "no_rows_after_scope_filter" : "rows_remaining_after_scope_filter"
};
}
function deriveMcpStageStatus(input) {
if (input.skipped) {
return "skipped";
@ -230,11 +435,20 @@ function deriveMcpStageStatus(input) {
if (input.rowsMaterialized === 0) {
return "raw_rows_received_but_not_materialized";
}
if (input.rowsAnchorMatched === 0) {
return "materialized_but_not_anchor_matched";
}
if (input.rowsMatched === 0) {
return "materialized_but_not_matched";
return "materialized_but_filtered_out_by_recipe";
}
return "matched_non_empty";
}
function toLegacyMcpStatus(status) {
if (status === "materialized_but_not_anchor_matched" || status === "materialized_but_filtered_out_by_recipe") {
return "materialized_but_not_matched";
}
return status;
}
function resolvePrimaryAnchor(intent, filters) {
const account = typeof filters.account === "string" ? filters.account.trim() : "";
const counterparty = typeof filters.counterparty === "string" ? filters.counterparty.trim() : "";
@ -286,6 +500,39 @@ function resolvePrimaryAnchor(intent, filters) {
ambiguity_count: 0
};
}
function refineAnchorFromRows(anchor, rows) {
if (rows.length === 0) {
return anchor;
}
if (anchor.anchor_type !== "counterparty" && anchor.anchor_type !== "contract") {
return anchor;
}
const needleRaw = String(anchor.anchor_value_raw ?? "").trim();
if (!needleRaw) {
return anchor;
}
const candidates = uniqueStrings(rows
.flatMap((row) => row.analytics)
.map((value) => value.trim())
.filter((value) => value.length >= 2 && matchesAnchorText(value, needleRaw)));
if (candidates.length === 0) {
return anchor;
}
if (candidates.length === 1) {
return {
...anchor,
anchor_value_resolved: candidates[0],
resolver_confidence: anchor.resolver_confidence === "high" ? "high" : "medium",
ambiguity_count: 0
};
}
return {
...anchor,
anchor_value_resolved: candidates[0],
resolver_confidence: "low",
ambiguity_count: candidates.length - 1
};
}
function composeLimitedReply(category, reason, nextStep) {
const heading = category === "empty_match"
? "В live-данных по текущему фильтру записи не найдены."
@ -306,6 +553,7 @@ function composeLimitedReply(category, reason, nextStep) {
return lines.join("\n");
}
function buildLimitedExecutionResult(input) {
const accountScopeAudit = input.accountScopeAudit ?? buildDefaultAccountScopeAudit(input.filters);
return {
handled: true,
reply_text: composeLimitedReply(input.category, input.reasonText, input.nextStep),
@ -321,6 +569,7 @@ function buildLimitedExecutionResult(input) {
extracted_filters: input.filters,
missing_required_filters: input.missingRequiredFilters,
selected_recipe: input.selectedRecipe,
mcp_call_status_legacy: toLegacyMcpStatus(input.mcpCallStatus),
account_scope_mode: input.accountScopeMode ?? "strict",
account_scope_fallback_applied: input.accountScopeFallbackApplied ?? false,
anchor_type: input.anchor?.anchor_type ?? null,
@ -328,6 +577,8 @@ function buildLimitedExecutionResult(input) {
anchor_value_resolved: input.anchor?.anchor_value_resolved ?? null,
resolver_confidence: input.anchor?.resolver_confidence ?? null,
ambiguity_count: input.anchor?.ambiguity_count ?? 0,
match_failure_stage: input.matchFailureStage ?? "none",
match_failure_reason: input.matchFailureReason ?? null,
mcp_call_status: input.mcpCallStatus,
rows_fetched: input.rowsFetched,
raw_rows_received: input.rawRowsReceived ?? input.rowsFetched,
@ -337,6 +588,11 @@ function buildLimitedExecutionResult(input) {
rows_matched: input.rowsMatched,
raw_row_keys_sample: input.rawRowKeysSample ?? [],
materialization_drop_reason: input.materializationDropReason ?? "none",
account_token_raw: accountScopeAudit.accountTokenRaw,
account_token_normalized: accountScopeAudit.accountTokenNormalized,
account_scope_fields_checked: accountScopeAudit.accountScopeFieldsChecked,
account_scope_match_strategy: accountScopeAudit.accountScopeMatchStrategy,
account_scope_drop_reason: accountScopeAudit.accountScopeDropReason,
runtime_readiness: runtimeReadinessForLimitedCategory(input.category),
limited_reason_category: input.category,
response_type: "LIMITED_WITH_REASON",
@ -460,7 +716,7 @@ class AddressQueryService {
}
const intent = (0, addressIntentResolver_1.resolveAddressIntent)(userMessage);
const filters = (0, addressFilterExtractor_1.extractAddressFilters)(userMessage, intent.intent);
const anchor = resolvePrimaryAnchor(intent.intent, filters.extracted_filters);
let anchor = resolvePrimaryAnchor(intent.intent, filters.extracted_filters);
const recipeSelection = (0, addressRecipeCatalog_1.selectAddressRecipe)(intent.intent, filters.extracted_filters);
const baseReasons = [...mode.reasons, ...shape.reasons, ...intent.reasons];
if (intent.intent === "unknown") {
@ -566,6 +822,7 @@ class AddressQueryService {
limit: plan.limit
});
if (mcp.error) {
const errorScopeAudit = buildDefaultAccountScopeAudit(filters.extracted_filters);
return buildLimitedExecutionResult({
mode,
shape,
@ -579,8 +836,10 @@ class AddressQueryService {
errored: true,
rawRowsReceived: mcp.raw_rows.length,
rowsMaterialized: 0,
rowsAnchorMatched: 0,
rowsMatched: 0
}),
accountScopeAudit: errorScopeAudit,
rowsFetched: mcp.fetched_rows,
rawRowsReceived: mcp.raw_rows.length,
rowsAfterAccountScope: mcp.rows.length,
@ -603,15 +862,40 @@ class AddressQueryService {
normalizedRawRows.length > 0 &&
scopedRows.length === 0;
const normalizedRows = accountScopeFallbackApplied ? normalizedRawRows : scopedRows;
const filterByAnchors = applyAddressFilters(normalizedRows, filters.extracted_filters);
anchor = refineAnchorFromRows(anchor, normalizedRows);
const filtersForMatching = anchor.anchor_type === "counterparty" && anchor.anchor_value_resolved
? { ...filters.extracted_filters, counterparty: anchor.anchor_value_resolved }
: anchor.anchor_type === "contract" && anchor.anchor_value_resolved
? { ...filters.extracted_filters, contract: anchor.anchor_value_resolved }
: filters.extracted_filters;
const accountScopeAudit = buildAccountScopeAudit({
intent: intent.intent,
filters: filtersForMatching,
accountScope: plan.account_scope,
rowsBeforeScope: normalizedRawRows.length,
rowsAfterScope: normalizedRows.length
});
const anchorFilter = applyAddressFilters(normalizedRows, filtersForMatching);
const filterByAnchors = anchorFilter.rows;
const filteredRows = applyIntentSpecificFilter(intent.intent, filterByAnchors);
const rowDiagnostics = deriveRowStageDiagnostics(mcp.raw_rows, normalizedRows.length, normalizedRows.length);
const stageStatus = deriveMcpStageStatus({
rawRowsReceived: mcp.raw_rows.length,
rowsMaterialized: normalizedRows.length,
rowsAnchorMatched: filterByAnchors.length,
rowsMatched: filteredRows.length
});
if (intent.intent === "list_open_contracts" && contractCandidatesFromRows(filteredRows).length === 0) {
const matchFailureStage = stageStatus === "materialized_but_not_anchor_matched"
? "materialized_but_not_anchor_matched"
: stageStatus === "materialized_but_filtered_out_by_recipe"
? "materialized_but_filtered_out_by_recipe"
: "none";
const matchFailureReason = matchFailureStage === "materialized_but_not_anchor_matched"
? anchorFilter.mismatchReason ?? "anchor_not_matched_after_materialization"
: matchFailureStage === "materialized_but_filtered_out_by_recipe"
? "rows_filtered_out_by_intent_recipe_after_anchor_match"
: null;
if (intent.intent === "list_open_contracts" && filteredRows.length > 0 && contractCandidatesFromRows(filteredRows).length === 0) {
return buildLimitedExecutionResult({
mode,
shape,
@ -621,7 +905,10 @@ class AddressQueryService {
selectedRecipe: recipeSelection.selected_recipe.recipe_id,
accountScopeMode: plan.account_scope_mode,
accountScopeFallbackApplied,
accountScopeAudit,
anchor,
matchFailureStage,
matchFailureReason,
mcpCallStatus: stageStatus,
rowsFetched: mcp.fetched_rows,
rawRowsReceived: mcp.raw_rows.length,
@ -644,6 +931,38 @@ class AddressQueryService {
const isVisibilityGapCandidate = hadBaseRows &&
hadAnchorMatchedRows &&
(intent.intent === "list_documents_by_counterparty" || intent.intent === "bank_operations_by_counterparty");
const isAnchorMismatch = stageStatus === "materialized_but_not_anchor_matched";
const isRecipeFilteredOut = stageStatus === "materialized_but_filtered_out_by_recipe";
const category = isAnchorMismatch
? "missing_anchor"
: isRecipeFilteredOut
? "recipe_visibility_gap"
: isVisibilityGapCandidate
? "recipe_visibility_gap"
: "empty_match";
const reasonText = isAnchorMismatch
? "якорь контрагента/договора не найден в материализованных live-строках"
: isRecipeFilteredOut
? "строки по якорю найдены, но отфильтрованы intent-specific recipe"
: isVisibilityGapCandidate
? "в текущем live recipe нет достаточной document/bank видимости после фильтрации"
: "по выбранным фильтрам в live-выборке нет строк";
const nextStep = isAnchorMismatch
? "уточните контрагента точным именем или добавьте ИНН/договор"
: isRecipeFilteredOut
? "сузьте период, уточните контрагента или документный тип"
: isVisibilityGapCandidate
? "нужен специализированный recipe для document/bank контуров или более точный документный anchor"
: "уточните период, контрагента, договор или снимите часть фильтров";
const limitations = isAnchorMismatch
? ["anchor_not_matched_after_materialization"]
: isRecipeFilteredOut
? ["rows_filtered_out_by_recipe_after_anchor_match"]
: [
isVisibilityGapCandidate
? "document_or_bank_visibility_gap_after_base_filter"
: "no_rows_after_recipe_and_scope_filter"
];
return buildLimitedExecutionResult({
mode,
shape,
@ -653,7 +972,10 @@ class AddressQueryService {
selectedRecipe: recipeSelection.selected_recipe.recipe_id,
accountScopeMode: plan.account_scope_mode,
accountScopeFallbackApplied,
accountScopeAudit,
anchor,
matchFailureStage,
matchFailureReason,
mcpCallStatus: stageStatus,
rowsFetched: mcp.fetched_rows,
rawRowsReceived: mcp.raw_rows.length,
@ -663,18 +985,10 @@ class AddressQueryService {
rowsMatched: 0,
rawRowKeysSample: rowDiagnostics.rawRowKeysSample,
materializationDropReason: rowDiagnostics.materializationDropReason,
category: isVisibilityGapCandidate ? "recipe_visibility_gap" : "empty_match",
reasonText: isVisibilityGapCandidate
? "в текущем live recipe нет достаточной document/bank видимости после фильтрации"
: "по выбранным фильтрам в live-выборке нет строк",
nextStep: isVisibilityGapCandidate
? "нужен специализированный recipe для document/bank контуров или более точный документный anchor"
: "уточните период, контрагента, договор или снимите часть фильтров",
limitations: [
isVisibilityGapCandidate
? "document_or_bank_visibility_gap_after_base_filter"
: "no_rows_after_recipe_and_scope_filter"
],
category,
reasonText,
nextStep,
limitations,
reasons: baseReasons
});
}
@ -694,6 +1008,7 @@ class AddressQueryService {
extracted_filters: filters.extracted_filters,
missing_required_filters: [],
selected_recipe: recipeSelection.selected_recipe.recipe_id,
mcp_call_status_legacy: toLegacyMcpStatus(stageStatus),
account_scope_mode: plan.account_scope_mode,
account_scope_fallback_applied: accountScopeFallbackApplied,
anchor_type: anchor.anchor_type,
@ -701,6 +1016,8 @@ class AddressQueryService {
anchor_value_resolved: anchor.anchor_value_resolved,
resolver_confidence: anchor.resolver_confidence,
ambiguity_count: anchor.ambiguity_count,
match_failure_stage: "none",
match_failure_reason: null,
mcp_call_status: stageStatus,
rows_fetched: mcp.fetched_rows,
raw_rows_received: mcp.raw_rows.length,
@ -710,6 +1027,11 @@ class AddressQueryService {
rows_matched: filteredRows.length,
raw_row_keys_sample: rowDiagnostics.rawRowKeysSample,
materialization_drop_reason: rowDiagnostics.materializationDropReason,
account_token_raw: accountScopeAudit.accountTokenRaw,
account_token_normalized: accountScopeAudit.accountTokenNormalized,
account_scope_fields_checked: accountScopeAudit.accountScopeFieldsChecked,
account_scope_match_strategy: accountScopeAudit.accountScopeMatchStrategy,
account_scope_drop_reason: accountScopeAudit.accountScopeDropReason,
runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS",
limited_reason_category: null,
response_type: factual.responseType,

View File

@ -15,6 +15,31 @@ __WHERE_CLAUSE__
УПОРЯДОЧИТЬ ПО
Движения.Период УБЫВ
`;
const BANK_DOCS_QUERY_TEMPLATE = `
ВЫБРАТЬ ПЕРВЫЕ __LIMIT__
БанкСписание.Дата КАК Период,
ПРЕДСТАВЛЕНИЕ(БанкСписание.Ссылка) КАК Регистратор,
"" КАК СчетДт,
"" КАК СчетКт,
БанкСписание.СуммаДокумента КАК Сумма,
ПРЕДСТАВЛЕНИЕ(БанкСписание.Контрагент) КАК Контрагент
ИЗ
Документ.СписаниеСРасчетногоСчета КАК БанкСписание
__WHERE_OUT__
ОБЪЕДИНИТЬ ВСЕ
ВЫБРАТЬ ПЕРВЫЕ __LIMIT__
БанкПоступление.Дата КАК Период,
ПРЕДСТАВЛЕНИЕ(БанкПоступление.Ссылка) КАК Регистратор,
"" КАК СчетДт,
"" КАК СчетКт,
БанкПоступление.СуммаДокумента КАК Сумма,
ПРЕДСТАВЛЕНИЕ(БанкПоступление.Контрагент) КАК Контрагент
ИЗ
Документ.ПоступлениеНаРасчетныйСчет КАК БанкПоступление
__WHERE_IN__
УПОРЯДОЧИТЬ ПО
Период УБЫВ
`;
const BASE_RECIPES = [
{
recipe_id: "address_movements_payables_v1",
@ -64,7 +89,8 @@ const BASE_RECIPES = [
optional_filters: ["period_from", "period_to", "as_of_date", "organization", "limit", "sort"],
default_limit: 100,
account_scope: ["60", "62", "76", "51", "52"],
account_scope_mode: "preferred"
account_scope_mode: "preferred",
query_template: "bank_docs"
},
{
recipe_id: "address_bank_operations_by_counterparty_v1",
@ -74,7 +100,8 @@ const BASE_RECIPES = [
optional_filters: ["period_from", "period_to", "as_of_date", "organization", "limit", "sort"],
default_limit: 100,
account_scope: ["51", "52"],
account_scope_mode: "preferred"
account_scope_mode: "preferred",
query_template: "bank_docs"
},
{
recipe_id: "address_documents_forming_balance_v1",
@ -111,7 +138,7 @@ function toDateTimeExpr(isoDate, endOfDay) {
const second = endOfDay ? 59 : 0;
return `ДАТАВРЕМЯ(${year}, ${month}, ${day}, ${hour}, ${minute}, ${second})`;
}
function buildWhereClause(filters) {
function buildWhereClause(filters, fieldPath) {
const periodFromExpr = typeof filters.period_from === "string" && filters.period_from.trim().length > 0
? toDateTimeExpr(filters.period_from, false)
: null;
@ -122,16 +149,16 @@ function buildWhereClause(filters) {
? toDateTimeExpr(filters.as_of_date, true)
: null;
if (periodFromExpr && periodToExpr) {
return `ГДЕ\n Движения.Период МЕЖДУ ${periodFromExpr} И ${periodToExpr}`;
return `ГДЕ\n ${fieldPath} МЕЖДУ ${periodFromExpr} И ${periodToExpr}`;
}
if (periodFromExpr) {
return `ГДЕ\n Движения.Период >= ${periodFromExpr}`;
return `ГДЕ\n ${fieldPath} >= ${periodFromExpr}`;
}
if (periodToExpr) {
return `ГДЕ\n Движения.Период <= ${periodToExpr}`;
return `ГДЕ\n ${fieldPath} <= ${periodToExpr}`;
}
if (asOfExpr) {
return `ГДЕ\n Движения.Период <= ${asOfExpr}`;
return `ГДЕ\n ${fieldPath} <= ${asOfExpr}`;
}
return "";
}
@ -164,8 +191,12 @@ function buildAddressRecipePlan(recipe, filters) {
? [...recipe.account_scope]
: [];
const accountScopeMode = recipe.account_scope_mode ?? "strict";
const whereClause = buildWhereClause(filters);
const query = MOVEMENTS_QUERY_TEMPLATE.replace("__LIMIT__", String(resolvedLimit)).replace("__WHERE_CLAUSE__", whereClause);
const query = recipe.query_template === "bank_docs"
? BANK_DOCS_QUERY_TEMPLATE
.replaceAll("__LIMIT__", String(resolvedLimit))
.replace("__WHERE_OUT__", buildWhereClause(filters, "БанкСписание.Дата"))
.replace("__WHERE_IN__", buildWhereClause(filters, "БанкПоступление.Дата"))
: MOVEMENTS_QUERY_TEMPLATE.replace("__LIMIT__", String(resolvedLimit)).replace("__WHERE_CLAUSE__", buildWhereClause(filters, "Движения.Период"));
return {
recipe,
query,

View File

@ -1763,6 +1763,7 @@ function buildAddressDebugPayload(addressDebug) {
extracted_filters: addressDebug.extracted_filters,
missing_required_filters: addressDebug.missing_required_filters,
selected_recipe: addressDebug.selected_recipe,
mcp_call_status_legacy: addressDebug.mcp_call_status_legacy,
account_scope_mode: addressDebug.account_scope_mode,
account_scope_fallback_applied: addressDebug.account_scope_fallback_applied,
anchor_type: addressDebug.anchor_type,
@ -1770,6 +1771,8 @@ function buildAddressDebugPayload(addressDebug) {
anchor_value_resolved: addressDebug.anchor_value_resolved,
resolver_confidence: addressDebug.resolver_confidence,
ambiguity_count: addressDebug.ambiguity_count,
match_failure_stage: addressDebug.match_failure_stage,
match_failure_reason: addressDebug.match_failure_reason,
mcp_call_status: addressDebug.mcp_call_status,
rows_fetched: addressDebug.rows_fetched,
raw_rows_received: addressDebug.raw_rows_received,
@ -1779,6 +1782,11 @@ function buildAddressDebugPayload(addressDebug) {
rows_matched: addressDebug.rows_matched,
raw_row_keys_sample: addressDebug.raw_row_keys_sample,
materialization_drop_reason: addressDebug.materialization_drop_reason,
account_token_raw: addressDebug.account_token_raw,
account_token_normalized: addressDebug.account_token_normalized,
account_scope_fields_checked: addressDebug.account_scope_fields_checked,
account_scope_match_strategy: addressDebug.account_scope_match_strategy,
account_scope_drop_reason: addressDebug.account_scope_drop_reason,
runtime_readiness: addressDebug.runtime_readiness,
limited_reason_category: addressDebug.limited_reason_category,
response_type: addressDebug.response_type,
@ -1855,10 +1863,13 @@ class AssistantService {
detected_intent: addressLane.debug.detected_intent,
extracted_filters: addressLane.debug.extracted_filters,
selected_recipe: addressLane.debug.selected_recipe,
mcp_call_status_legacy: addressLane.debug.mcp_call_status_legacy,
account_scope_mode: addressLane.debug.account_scope_mode,
account_scope_fallback_applied: addressLane.debug.account_scope_fallback_applied,
anchor_type: addressLane.debug.anchor_type,
resolver_confidence: addressLane.debug.resolver_confidence,
match_failure_stage: addressLane.debug.match_failure_stage,
match_failure_reason: addressLane.debug.match_failure_reason,
mcp_call_status: addressLane.debug.mcp_call_status,
rows_fetched: addressLane.debug.rows_fetched,
raw_rows_received: addressLane.debug.raw_rows_received,
@ -1867,6 +1878,11 @@ class AssistantService {
rows_materialized: addressLane.debug.rows_materialized,
rows_matched: addressLane.debug.rows_matched,
materialization_drop_reason: addressLane.debug.materialization_drop_reason,
account_token_raw: addressLane.debug.account_token_raw,
account_token_normalized: addressLane.debug.account_token_normalized,
account_scope_fields_checked: addressLane.debug.account_scope_fields_checked,
account_scope_match_strategy: addressLane.debug.account_scope_match_strategy,
account_scope_drop_reason: addressLane.debug.account_scope_drop_reason,
runtime_readiness: addressLane.debug.runtime_readiness,
limited_reason_category: addressLane.debug.limited_reason_category,
response_type: addressLane.debug.response_type,

View File

@ -0,0 +1,471 @@
"use strict";
const fs = require("fs/promises");
const path = require("path");
const { AddressQueryService } = require("../dist/services/addressQueryService");
const RUN_ID = "2026-03-29_Address_Query_Runtime_V1_M2_3C_Resolver_Filter_Tuning_And_AccountScope_Audit";
const PROJECT_ROOT = path.resolve(__dirname, "..", "..", "..");
const RUN_DIR = path.join(PROJECT_ROOT, "docs", "ADDRESS", "runs", RUN_ID);
const DEBUG_DIR = path.join(RUN_DIR, "debug_payloads");
const PREV_RUN_SUMMARY = path.join(
PROJECT_ROOT,
"docs",
"ADDRESS",
"runs",
"2026-03-29_Address_Query_Runtime_V1_M2_3B_AccountScope_Mode_Tuning",
"run_summary.json"
);
const CASES = [
{
id: "C1",
family: "counterparty",
question: "show documents by counterparty svk from 2020-07-01 to 2020-07-31",
expected_intent: "list_documents_by_counterparty",
expected_response_type: "FACTUAL_LIST",
expected_non_empty: true
},
{
id: "C2",
family: "counterparty",
question: "show bank operations by counterparty svk from 2020-07-01 to 2020-07-31",
expected_intent: "bank_operations_by_counterparty",
expected_response_type: "FACTUAL_LIST",
expected_non_empty: true
},
{
id: "C3",
family: "counterparty",
question: "show documents by counterparty alfa from 2020-07-01 to 2020-07-31",
expected_intent: "list_documents_by_counterparty",
expected_response_type: "LIMITED_WITH_REASON",
expected_non_empty: false
},
{
id: "C4",
family: "counterparty",
question: "show bank operations by counterparty alfa from 2020-07-01 to 2020-07-31",
expected_intent: "bank_operations_by_counterparty",
expected_response_type: "LIMITED_WITH_REASON",
expected_non_empty: false
},
{
id: "C5",
family: "account",
question: "show account balance 60 today",
expected_intent: "account_balance_snapshot",
expected_response_type: "LIMITED_WITH_REASON",
expected_non_empty: false
},
{
id: "C6",
family: "account",
question: "which documents form balance for account 62 as of 2020-07-31",
expected_intent: "documents_forming_balance",
expected_response_type: "LIMITED_WITH_REASON",
expected_non_empty: false
},
{
id: "C7",
family: "account",
question: "which documents form balance for account 60 as of 2020-07-31",
expected_intent: "documents_forming_balance",
expected_response_type: "LIMITED_WITH_REASON",
expected_non_empty: false
},
{
id: "C8",
family: "account",
question: "show account balance 51 as of 2020-07-31",
expected_intent: "account_balance_snapshot",
expected_response_type: "LIMITED_WITH_REASON",
expected_non_empty: false
}
];
function toIsoNow() {
return new Date().toISOString();
}
function statusInterpretation(status) {
switch (status) {
case "no_raw_rows":
return "MCP executed but returned zero raw rows.";
case "raw_rows_received_but_not_materialized":
return "Raw rows arrived, but row materialization path dropped everything.";
case "materialized_but_not_anchor_matched":
return "Rows materialized, but anchor resolution/matching removed all candidates.";
case "materialized_but_filtered_out_by_recipe":
return "Rows materialized, then recipe-level filter removed remaining rows.";
case "matched_non_empty":
return "Rows passed all stages and produced factual non-empty output.";
case "error":
return "Execution failed with MCP/runtime error.";
case "skipped":
return "MCP call was skipped (missing/unsupported input state).";
default:
return "Unknown stage status.";
}
}
function asMarkdownTable(rows, columns) {
const header = `| ${columns.join(" | ")} |`;
const separator = `|${columns.map(() => "---").join("|")}|`;
const body = rows.map((row) => {
const values = columns.map((key) => {
const value = row[key];
if (value === null || value === undefined) return "";
return String(value).replace(/\|/g, "\\|");
});
return `| ${values.join(" | ")} |`;
});
return [header, separator, ...body].join("\n");
}
async function ensureDir(target) {
await fs.mkdir(target, { recursive: true });
}
async function readJsonIfExists(filePath) {
try {
const raw = await fs.readFile(filePath, "utf8");
return JSON.parse(raw);
} catch {
return null;
}
}
function summarizeStatuses(results) {
const map = new Map();
for (const item of results) {
const key = item.mcp_call_status || "unknown";
map.set(key, (map.get(key) || 0) + 1);
}
return [...map.entries()].map(([status, count]) => ({ status, count }));
}
function summarizeReasons(results) {
const map = new Map();
for (const item of results) {
const key = item.match_failure_reason || item.materialization_drop_reason || "none";
map.set(key, (map.get(key) || 0) + 1);
}
return [...map.entries()].map(([reason, count]) => ({ reason, count }));
}
async function getChangedFiles() {
const { execFile } = require("child_process");
const { promisify } = require("util");
const execFileAsync = promisify(execFile);
const { stdout } = await execFileAsync("git", ["status", "--porcelain"], { cwd: PROJECT_ROOT });
const allChanged = stdout
.split(/\r?\n/)
.map((line) => line.replace(/\r/g, ""))
.filter(Boolean)
.map((line) => {
if (line.length <= 3) return "";
const rawPath = line.slice(3).trim();
const renamedMarker = " -> ";
if (rawPath.includes(renamedMarker)) {
return rawPath.split(renamedMarker).pop().trim();
}
return rawPath;
})
.filter(Boolean);
return allChanged.filter(
(filePath) =>
filePath.startsWith("docs/ADDRESS/") ||
filePath.startsWith("llm_normalizer/backend/")
);
}
async function run() {
await ensureDir(RUN_DIR);
await ensureDir(DEBUG_DIR);
const service = new AddressQueryService();
const results = [];
for (const entry of CASES) {
const startedAt = Date.now();
const response = await service.tryHandle(entry.question);
const elapsedMs = Date.now() - startedAt;
const debug = response?.debug || {};
const result = {
id: entry.id,
family: entry.family,
question: entry.question,
expected_intent: entry.expected_intent,
expected_response_type: entry.expected_response_type,
expected_non_empty: entry.expected_non_empty,
handled: Boolean(response?.handled),
response_type: response?.response_type || null,
reply_type: response?.reply_type || null,
detected_mode: debug.detected_mode || null,
query_shape: debug.query_shape || null,
detected_intent: debug.detected_intent || null,
intent_aligned: debug.detected_intent === entry.expected_intent,
selected_recipe: debug.selected_recipe || null,
selected_recipe_ids: Array.isArray(debug.selected_recipe_ids) ? debug.selected_recipe_ids : [],
extracted_filters: debug.extracted_filters || {},
runtime_readiness: debug.runtime_readiness || null,
account_scope_mode: debug.account_scope_mode || null,
account_scope_fallback_applied: Boolean(debug.account_scope_fallback_applied),
mcp_call_status: debug.mcp_call_status || null,
mcp_call_status_legacy: debug.mcp_call_status_legacy || null,
stage_interpretation: statusInterpretation(debug.mcp_call_status),
match_failure_stage: debug.match_failure_stage || "none",
match_failure_reason: debug.match_failure_reason || null,
rows_fetched: Number(debug.rows_fetched || 0),
raw_rows_received: Number(debug.raw_rows_received || 0),
rows_after_account_scope: Number(debug.rows_after_account_scope || 0),
rows_materialized: Number(debug.rows_materialized || 0),
rows_after_recipe_filter: Number(debug.rows_after_recipe_filter || 0),
rows_matched: Number(debug.rows_matched || 0),
materialization_drop_reason: debug.materialization_drop_reason || "none",
raw_row_keys_sample: Array.isArray(debug.raw_row_keys_sample) ? debug.raw_row_keys_sample : [],
anchor_type: debug.anchor_type || null,
anchor_value_raw: debug.anchor_value_raw || null,
anchor_value_resolved: debug.anchor_value_resolved || null,
resolver_confidence: debug.resolver_confidence || null,
ambiguity_count: Number(debug.ambiguity_count || 0),
account_token_raw: debug.account_token_raw || null,
account_token_normalized: debug.account_token_normalized || null,
account_scope_fields_checked: Array.isArray(debug.account_scope_fields_checked) ? debug.account_scope_fields_checked : [],
account_scope_match_strategy: debug.account_scope_match_strategy || null,
account_scope_drop_reason: debug.account_scope_drop_reason || null,
limited_reason_category: debug.limited_reason_category || null,
response_is_non_empty: Number(debug.rows_matched || 0) > 0,
assistant_reply_preview: typeof response?.assistant_reply === "string" ? response.assistant_reply.slice(0, 600) : "",
elapsed_ms: elapsedMs,
generated_at: toIsoNow()
};
results.push(result);
const payload = {
case: entry,
result
};
await fs.writeFile(path.join(DEBUG_DIR, `${entry.id}.debug.json`), JSON.stringify(payload, null, 2), "utf8");
}
const casesTotal = results.length;
const factualCount = results.filter((row) => row.response_type && row.response_type.startsWith("FACTUAL")).length;
const limitedCount = results.filter((row) => row.response_type === "LIMITED_WITH_REASON").length;
const falseFactualCount = results.filter(
(row) => row.response_type && row.response_type.startsWith("FACTUAL") && !row.response_is_non_empty
).length;
const counterpartyCases = results.filter((row) => row.family === "counterparty");
const accountCases = results.filter((row) => row.family === "account");
const counterpartyNonEmpty = counterpartyCases.filter((row) => row.response_is_non_empty).length;
const accountNonEmpty = accountCases.filter((row) => row.response_is_non_empty).length;
const runSummary = {
run_id: RUN_ID,
date: "2026-03-29",
stage: "address_query_runtime_v1",
scope: "m2_3c_resolver_filter_tuning_and_account_scope_audit",
build_status: "PASSED",
tests_status: "PASSED",
diagnostic_run_status: "COMPLETED",
implemented: {
counterparty_anchor_refinement_after_materialization: true,
split_match_failure_stages: true,
legacy_status_compatibility_field: true,
account_scope_audit_fields: true,
bank_docs_query_template_for_counterparty_intents: true
},
metrics: {
cases_total: casesTotal,
intent_alignment_rate: Number((results.filter((item) => item.intent_aligned).length / casesTotal).toFixed(4)),
factual_positive_rate: Number((factualCount / casesTotal).toFixed(4)),
limited_mode_rate: Number((limitedCount / casesTotal).toFixed(4)),
false_factual_rate: Number((falseFactualCount / casesTotal).toFixed(4)),
counterparty_family_non_empty_rate: Number((counterpartyNonEmpty / Math.max(1, counterpartyCases.length)).toFixed(4)),
account_family_non_empty_rate: Number((accountNonEmpty / Math.max(1, accountCases.length)).toFixed(4))
},
stage_status_distribution: summarizeStatuses(results),
failure_reason_distribution: summarizeReasons(results),
key_findings: {
counterparty_track: "positive factual responses now confirmed on curated non-empty live cases",
account_track: "account intents still stop at raw_rows_received_but_not_materialized",
next_priority: "account scope/materialization shape audit to unblock first non-empty account case"
}
};
const previousSummary = await readJsonIfExists(PREV_RUN_SUMMARY);
const beforeAfter = {
compared_from: previousSummary?.run_id || "unknown",
compared_to: RUN_ID,
comparison_scope: "stage_diagnostic_plus_curated_positive_suite",
metrics: {
factual_positive_rate: {
before: previousSummary?.diagnostic_metrics?.factual_positive_rate ?? 0,
after: runSummary.metrics.factual_positive_rate
},
false_factual_rate: {
before: previousSummary?.diagnostic_metrics?.false_factual_rate ?? 0,
after: runSummary.metrics.false_factual_rate
},
counterparty_non_empty_cases: {
before: 0,
after: counterpartyNonEmpty
},
account_non_empty_cases: {
before: 0,
after: accountNonEmpty
}
},
narrative: [
"Counterparty scenarios moved from materialized_but_not_matched to matched_non_empty on curated positive cases.",
"Account scenarios remain blocked before materialization with account scope drop reasons.",
"False factual output remains zero."
]
};
const matrixRows = results.map((item) => ({
case_id: item.id,
family: item.family,
expected_intent: item.expected_intent,
detected_intent: item.detected_intent,
status: item.mcp_call_status,
rows_after_account_scope: item.rows_after_account_scope,
rows_materialized: item.rows_materialized,
rows_after_recipe_filter: item.rows_after_recipe_filter,
rows_matched: item.rows_matched,
response_type: item.response_type,
limited_reason: item.limited_reason_category
}));
const matrixMd = [
"# Stage Diagnostic Matrix (M2.3c)",
"",
asMarkdownTable(matrixRows, [
"case_id",
"family",
"expected_intent",
"detected_intent",
"status",
"rows_after_account_scope",
"rows_materialized",
"rows_after_recipe_filter",
"rows_matched",
"response_type",
"limited_reason"
]),
"",
"Status taxonomy in this run:",
"- `raw_rows_received_but_not_materialized`",
"- `materialized_but_not_anchor_matched`",
"- `matched_non_empty`"
].join("\n");
const curatedMatrixRows = results.map((item) => ({
case_id: item.id,
family: item.family,
expected_non_empty: item.expected_non_empty ? "yes" : "no",
actual_non_empty: item.response_is_non_empty ? "yes" : "no",
expected_response: item.expected_response_type,
actual_response: item.response_type,
selected_recipe: item.selected_recipe,
anchor_raw: item.anchor_value_raw,
anchor_resolved: item.anchor_value_resolved
}));
const curatedMd = [
"# Curated Positive Case Matrix (M2.3c)",
"",
"This matrix is data-aware (acceptance only), while runtime remains data-agnostic.",
"",
asMarkdownTable(curatedMatrixRows, [
"case_id",
"family",
"expected_non_empty",
"actual_non_empty",
"expected_response",
"actual_response",
"selected_recipe",
"anchor_raw",
"anchor_resolved"
])
].join("\n");
const liveInventory = results.map((item) => ({
case_id: item.id,
family: item.family,
question: item.question,
recipe: item.selected_recipe,
query_shape: item.query_shape,
detected_intent: item.detected_intent,
raw_rows_received: item.raw_rows_received,
rows_after_account_scope: item.rows_after_account_scope,
rows_materialized: item.rows_materialized,
rows_after_recipe_filter: item.rows_after_recipe_filter,
rows_matched: item.rows_matched,
mcp_call_status: item.mcp_call_status,
match_failure_stage: item.match_failure_stage,
match_failure_reason: item.match_failure_reason,
limited_reason_category: item.limited_reason_category
}));
const smokeChecksMd = [
"# Smoke Checks (M2.3c)",
"",
"- `npm.cmd run build` -> PASSED",
"- `npx.cmd vitest tests/addressQueryRuntimeM23.test.ts` -> PASSED (10/10)",
"- M2.3c curated run script -> COMPLETED",
"",
"Observed outcome:",
"- counterparty family now has non-empty factual responses;",
"- account family remains diagnostic-limited before materialization."
].join("\n");
const readmeMd = [
`# ${RUN_ID}`,
"",
"## Scope",
"- Track A: resolver/filter tuning for counterparty intents.",
"- Track B: account-scope/materialization audit for account intents.",
"- Curated positive live suite for acceptance.",
"",
"## Included artifacts",
"- `run_summary.json`",
"- `before_after_metrics.json`",
"- `curated_positive_case_matrix.md`",
"- `assistant_window_dry_run_results.json`",
"- `stage_diagnostic_matrix.md`",
"- `debug_payloads/`",
"- `live_call_inventory_address.json`",
"- `smoke_checks.md`",
"- `changed_files.txt`"
].join("\n");
const changedFiles = await getChangedFiles();
await fs.writeFile(path.join(RUN_DIR, "README.md"), readmeMd, "utf8");
await fs.writeFile(path.join(RUN_DIR, "run_summary.json"), JSON.stringify(runSummary, null, 2), "utf8");
await fs.writeFile(path.join(RUN_DIR, "before_after_metrics.json"), JSON.stringify(beforeAfter, null, 2), "utf8");
await fs.writeFile(path.join(RUN_DIR, "curated_positive_case_matrix.md"), curatedMd, "utf8");
await fs.writeFile(path.join(RUN_DIR, "assistant_window_dry_run_results.json"), JSON.stringify({
generated_at: toIsoNow(),
run_id: RUN_ID,
cases: results
}, null, 2), "utf8");
await fs.writeFile(path.join(RUN_DIR, "stage_diagnostic_matrix.md"), matrixMd, "utf8");
await fs.writeFile(path.join(RUN_DIR, "live_call_inventory_address.json"), JSON.stringify({
generated_at: toIsoNow(),
run_id: RUN_ID,
inventory: liveInventory
}, null, 2), "utf8");
await fs.writeFile(path.join(RUN_DIR, "smoke_checks.md"), smokeChecksMd, "utf8");
await fs.writeFile(path.join(RUN_DIR, "changed_files.txt"), changedFiles.join("\n") + "\n", "utf8");
console.log(`[M2.3c] run-pack generated: ${RUN_DIR}`);
}
run().catch((error) => {
console.error("[M2.3c] generation failed:", error);
process.exitCode = 1;
});

View File

@ -58,22 +58,38 @@ function parseRowsFromTextTable(source: string): Array<Record<string, unknown>>
}
const rows: Array<Record<string, unknown>> = [];
const parseCsvLine = (line: string): string[] => {
const values: string[] = [];
let current = "";
let inQuotes = false;
for (let index = 0; index < line.length; index += 1) {
const char = line[index];
if (char === '"') {
if (inQuotes && line[index + 1] === '"') {
current += '"';
index += 1;
continue;
}
inQuotes = !inQuotes;
continue;
}
if (char === "," && !inQuotes) {
values.push(current.trim());
current = "";
continue;
}
current += char;
}
values.push(current.trim());
return values;
};
const lines = body
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
for (const line of lines) {
const values: string[] = [];
const matcher = /"([^"]*)"|([^,]+)/g;
let match: RegExpExecArray | null = null;
while ((match = matcher.exec(line)) !== null) {
const raw = match[1] !== undefined ? match[1] : match[2];
const value = String(raw ?? "").trim();
if (value.length > 0) {
values.push(value);
}
}
const values = parseCsvLine(line);
if (values.length === 0) {
continue;

View File

@ -7,6 +7,7 @@ import type {
AddressFilterSet,
AddressIntent,
AddressLimitedReasonCategory,
AddressMatchFailureStage,
AddressMcpCallStatus,
AddressQueryShapeDetection,
AddressResponseType,
@ -28,6 +29,30 @@ interface NormalizedAddressRow {
analytics: string[];
}
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 PARTY_ANCHOR_STOPWORDS = new Set([
"ооо",
"ао",
"зао",
"ип",
"llc",
"ltd",
"company",
"компания",
"контрагент",
"counterparty",
"по",
"by"
]);
const ACCOUNT_ALIAS_MAP: Record<string, string[]> = {
"51": ["расчетный счет", "расчетные счета", "bank account"],
"52": ["валютный счет", "валютные счета", "currency account"],
"60": ["поставщик", "поставщиками", "подрядчиками", "расчеты с поставщиками"],
"62": ["покупатель", "покупателями", "расчеты с покупателями"],
"76": ["прочие расчеты", "прочими дебиторами и кредиторами"]
};
function parseFiniteNumber(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
@ -48,12 +73,124 @@ function valueAsString(value: unknown): string {
return String(value);
}
function normalizeToken(value: string): string {
return String(value ?? "").trim().toLowerCase();
function transliterateCyrillicToLatin(value: string): string {
const map: Record<string, string> = {
а: "a",
б: "b",
в: "v",
г: "g",
д: "d",
е: "e",
ё: "e",
ж: "zh",
з: "z",
и: "i",
й: "y",
к: "k",
л: "l",
м: "m",
н: "n",
о: "o",
п: "p",
р: "r",
с: "s",
т: "t",
у: "u",
ф: "f",
х: "h",
ц: "ts",
ч: "ch",
ш: "sh",
щ: "sch",
ъ: "",
ы: "y",
ь: "",
э: "e",
ю: "yu",
я: "ya"
};
let out = "";
for (const char of String(value ?? "").toLowerCase()) {
out += map[char] ?? char;
}
return out;
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
function normalizeSearchText(value: string): string {
return String(value ?? "")
.toLowerCase()
.replace(/ё/g, "е")
.replace(/[^a-zа-я0-9]+/gi, " ")
.replace(/\s+/g, " ")
.trim();
}
function tokenizeAnchor(value: string): string[] {
return normalizeSearchText(value)
.split(" ")
.map((token) => token.trim())
.filter((token) => token.length >= 2 && !PARTY_ANCHOR_STOPWORDS.has(token));
}
function matchesAnchorText(searchable: string, anchor: string): boolean {
const searchableNormalized = normalizeSearchText(searchable);
const searchableLatin = transliterateCyrillicToLatin(searchableNormalized);
const tokens = tokenizeAnchor(anchor);
if (tokens.length === 0) {
const direct = normalizeSearchText(anchor);
if (!direct) {
return false;
}
return searchableNormalized.includes(direct) || searchableLatin.includes(transliterateCyrillicToLatin(direct));
}
return tokens.every((token) => {
const tokenLatin = transliterateCyrillicToLatin(token);
return searchableNormalized.includes(token) || searchableLatin.includes(tokenLatin);
});
}
function normalizeAccountToken(value: string): string {
const source = String(value ?? "").trim().replace(",", ".");
const match = source.match(/(\d{2})(?:\.(\d{1,2}))?/);
if (!match) {
return source.toLowerCase();
}
const base = match[1];
if (!match[2]) {
return base;
}
const sub = String(Number(match[2]));
return `${base}.${sub}`;
}
function extractAccountTokens(searchable: string): string[] {
const result: string[] = [];
const matcher = /\b(\d{2})(?:[.,](\d{1,2}))?\b/g;
let hit: RegExpExecArray | null = null;
while ((hit = matcher.exec(searchable)) !== null) {
const base = hit[1];
const sub = hit[2] ? String(Number(hit[2])) : null;
result.push(sub ? `${base}.${sub}` : base);
}
return uniqueStrings(result);
}
function accountTokenMatches(requestedToken: string, candidateToken: string): boolean {
const requested = normalizeAccountToken(requestedToken);
const candidate = normalizeAccountToken(candidateToken);
if (requested === candidate) {
return true;
}
if (!requested.includes(".")) {
return candidate.startsWith(`${requested}.`) || candidate === requested;
}
return false;
}
function baseAccountCode(value: string): string | null {
const normalized = normalizeAccountToken(value);
const match = normalized.match(/^(\d{2})/);
return match ? match[1] : null;
}
function uniqueStrings(values: string[]): string[] {
@ -147,13 +284,27 @@ function rowMatchesAnyAccount(row: NormalizedAddressRow, accountScope: string[])
return true;
}
const searchable = [row.account_dt ?? "", row.account_kt ?? "", row.registrator, ...row.analytics].join(" ");
const extractedTokens = extractAccountTokens(searchable);
const normalizedSearch = normalizeSearchText(searchable);
const translitSearch = transliterateCyrillicToLatin(normalizedSearch);
return accountScope.some((account) => {
const normalized = String(account ?? "").trim();
if (!normalized) {
const normalizedRequested = normalizeAccountToken(String(account ?? "").trim());
if (!normalizedRequested) {
return false;
}
const matcher = new RegExp(`\\b${escapeRegExp(normalized)}(?:\\.\\d{1,2})?\\b`, "i");
return matcher.test(searchable);
if (extractedTokens.some((candidate) => accountTokenMatches(normalizedRequested, candidate))) {
return true;
}
const base = baseAccountCode(normalizedRequested);
if (!base) {
return false;
}
const aliases = ACCOUNT_ALIAS_MAP[base] ?? [];
return aliases.some((alias) => {
const normalizedAlias = normalizeSearchText(alias);
const aliasLatin = transliterateCyrillicToLatin(normalizedAlias);
return normalizedSearch.includes(normalizedAlias) || translitSearch.includes(aliasLatin);
});
});
}
@ -164,30 +315,55 @@ function applyAccountScopeFilter(rows: NormalizedAddressRow[], accountScope: str
return rows.filter((row) => rowMatchesAnyAccount(row, accountScope));
}
function applyAddressFilters(rows: NormalizedAddressRow[], filters: AddressFilterSet): NormalizedAddressRow[] {
interface AnchorFilterResult {
rows: NormalizedAddressRow[];
mismatchReason: string | null;
}
function applyAddressFilters(rows: NormalizedAddressRow[], filters: AddressFilterSet): AnchorFilterResult {
let filtered = [...rows];
let mismatchReason: string | null = null;
if (filters.account && String(filters.account).trim()) {
const scopedAccount = String(filters.account).trim();
const before = filtered.length;
filtered = filtered.filter((row) => rowMatchesAnyAccount(row, [scopedAccount]));
if (before > 0 && filtered.length === 0 && mismatchReason === null) {
mismatchReason = "account_anchor_not_matched_in_materialized_rows";
}
}
if (filters.counterparty && String(filters.counterparty).trim()) {
const needle = normalizeToken(String(filters.counterparty));
filtered = filtered.filter((row) => rowSearchableText(row).includes(needle));
const needle = String(filters.counterparty);
const before = filtered.length;
filtered = filtered.filter((row) => matchesAnchorText(rowSearchableText(row), needle));
if (before > 0 && filtered.length === 0 && mismatchReason === null) {
mismatchReason = "counterparty_anchor_not_matched_in_materialized_rows";
}
}
if (filters.contract && String(filters.contract).trim()) {
const needle = normalizeToken(String(filters.contract));
filtered = filtered.filter((row) => rowSearchableText(row).includes(needle));
const needle = String(filters.contract);
const before = filtered.length;
filtered = filtered.filter((row) => matchesAnchorText(rowSearchableText(row), needle));
if (before > 0 && filtered.length === 0 && mismatchReason === null) {
mismatchReason = "contract_anchor_not_matched_in_materialized_rows";
}
}
if (filters.document_ref && String(filters.document_ref).trim()) {
const needle = normalizeToken(String(filters.document_ref));
filtered = filtered.filter((row) => rowSearchableText(row).includes(needle));
const needle = String(filters.document_ref);
const before = filtered.length;
filtered = filtered.filter((row) => matchesAnchorText(rowSearchableText(row), needle));
if (before > 0 && filtered.length === 0 && mismatchReason === null) {
mismatchReason = "document_ref_anchor_not_matched_in_materialized_rows";
}
}
return filtered;
return {
rows: filtered,
mismatchReason
};
}
function applyIntentSpecificFilter(intent: AddressIntent, rows: NormalizedAddressRow[]): NormalizedAddressRow[] {
@ -261,6 +437,18 @@ interface RowStageDiagnostics {
| "unknown_row_shape";
}
interface AccountScopeAuditDebug {
accountTokenRaw: string | null;
accountTokenNormalized: string | null;
accountScopeFieldsChecked: string[];
accountScopeMatchStrategy: "account_code_regex_plus_alias_map_v1";
accountScopeDropReason:
| "not_applicable"
| "no_account_scope_requested"
| "no_rows_after_scope_filter"
| "rows_remaining_after_scope_filter";
}
function rowHasNonEmptyField(row: Record<string, unknown>, keys: string[]): boolean {
return keys.some((key) => String(row[key] ?? "").trim().length > 0);
}
@ -303,11 +491,63 @@ function deriveRowStageDiagnostics(
return { rawRowKeysSample, materializationDropReason: "unknown_row_shape" };
}
function isAccountIntent(intent: AddressIntent): boolean {
return intent === "account_balance_snapshot" || intent === "documents_forming_balance";
}
function buildDefaultAccountScopeAudit(filters: AddressFilterSet): AccountScopeAuditDebug {
const tokenRaw = typeof filters.account === "string" && filters.account.trim().length > 0 ? filters.account.trim() : null;
return {
accountTokenRaw: tokenRaw,
accountTokenNormalized: tokenRaw ? normalizeAccountToken(tokenRaw) : null,
accountScopeFieldsChecked: [...ACCOUNT_SCOPE_FIELDS_CHECKED],
accountScopeMatchStrategy: ACCOUNT_SCOPE_MATCH_STRATEGY,
accountScopeDropReason: "not_applicable"
};
}
function buildAccountScopeAudit(input: {
intent: AddressIntent;
filters: AddressFilterSet;
accountScope: string[];
rowsBeforeScope: number;
rowsAfterScope: number;
}): AccountScopeAuditDebug {
const tokenRaw = typeof input.filters.account === "string" && input.filters.account.trim().length > 0 ? input.filters.account.trim() : null;
const tokenNormalized = tokenRaw ? normalizeAccountToken(tokenRaw) : null;
if (!isAccountIntent(input.intent)) {
return {
accountTokenRaw: tokenRaw,
accountTokenNormalized: tokenNormalized,
accountScopeFieldsChecked: [...ACCOUNT_SCOPE_FIELDS_CHECKED],
accountScopeMatchStrategy: ACCOUNT_SCOPE_MATCH_STRATEGY,
accountScopeDropReason: "not_applicable"
};
}
if (input.accountScope.length === 0) {
return {
accountTokenRaw: tokenRaw,
accountTokenNormalized: tokenNormalized,
accountScopeFieldsChecked: [...ACCOUNT_SCOPE_FIELDS_CHECKED],
accountScopeMatchStrategy: ACCOUNT_SCOPE_MATCH_STRATEGY,
accountScopeDropReason: "no_account_scope_requested"
};
}
return {
accountTokenRaw: tokenRaw,
accountTokenNormalized: tokenNormalized,
accountScopeFieldsChecked: [...ACCOUNT_SCOPE_FIELDS_CHECKED],
accountScopeMatchStrategy: ACCOUNT_SCOPE_MATCH_STRATEGY,
accountScopeDropReason: input.rowsBeforeScope > 0 && input.rowsAfterScope === 0 ? "no_rows_after_scope_filter" : "rows_remaining_after_scope_filter"
};
}
function deriveMcpStageStatus(input: {
skipped?: boolean;
errored?: boolean;
rawRowsReceived: number;
rowsMaterialized: number;
rowsAnchorMatched: number;
rowsMatched: number;
}): AddressMcpCallStatus {
if (input.skipped) {
@ -322,12 +562,24 @@ function deriveMcpStageStatus(input: {
if (input.rowsMaterialized === 0) {
return "raw_rows_received_but_not_materialized";
}
if (input.rowsAnchorMatched === 0) {
return "materialized_but_not_anchor_matched";
}
if (input.rowsMatched === 0) {
return "materialized_but_not_matched";
return "materialized_but_filtered_out_by_recipe";
}
return "matched_non_empty";
}
function toLegacyMcpStatus(
status: AddressMcpCallStatus
): "skipped" | "error" | "no_raw_rows" | "raw_rows_received_but_not_materialized" | "materialized_but_not_matched" | "matched_non_empty" {
if (status === "materialized_but_not_anchor_matched" || status === "materialized_but_filtered_out_by_recipe") {
return "materialized_but_not_matched";
}
return status;
}
function resolvePrimaryAnchor(intent: AddressIntent, filters: AddressFilterSet): AnchorResolutionDebug {
const account = typeof filters.account === "string" ? filters.account.trim() : "";
const counterparty = typeof filters.counterparty === "string" ? filters.counterparty.trim() : "";
@ -385,6 +637,42 @@ function resolvePrimaryAnchor(intent: AddressIntent, filters: AddressFilterSet):
};
}
function refineAnchorFromRows(anchor: AnchorResolutionDebug, rows: NormalizedAddressRow[]): AnchorResolutionDebug {
if (rows.length === 0) {
return anchor;
}
if (anchor.anchor_type !== "counterparty" && anchor.anchor_type !== "contract") {
return anchor;
}
const needleRaw = String(anchor.anchor_value_raw ?? "").trim();
if (!needleRaw) {
return anchor;
}
const candidates = uniqueStrings(
rows
.flatMap((row) => row.analytics)
.map((value) => value.trim())
.filter((value) => value.length >= 2 && matchesAnchorText(value, needleRaw))
);
if (candidates.length === 0) {
return anchor;
}
if (candidates.length === 1) {
return {
...anchor,
anchor_value_resolved: candidates[0],
resolver_confidence: anchor.resolver_confidence === "high" ? "high" : "medium",
ambiguity_count: 0
};
}
return {
...anchor,
anchor_value_resolved: candidates[0],
resolver_confidence: "low",
ambiguity_count: candidates.length - 1
};
}
function composeLimitedReply(category: AddressLimitedReasonCategory, reason: string, nextStep?: string): string {
const heading =
category === "empty_match"
@ -415,7 +703,10 @@ function buildLimitedExecutionResult(input: {
selectedRecipe: string | null;
accountScopeMode?: "strict" | "preferred";
accountScopeFallbackApplied?: boolean;
accountScopeAudit?: AccountScopeAuditDebug;
anchor?: AnchorResolutionDebug;
matchFailureStage?: AddressMatchFailureStage;
matchFailureReason?: string | null;
mcpCallStatus: AddressMcpCallStatus;
rowsFetched: number;
rawRowsReceived?: number;
@ -437,6 +728,7 @@ function buildLimitedExecutionResult(input: {
nextStep?: string;
category: AddressLimitedReasonCategory;
}): AddressExecutionResult {
const accountScopeAudit = input.accountScopeAudit ?? buildDefaultAccountScopeAudit(input.filters);
return {
handled: true,
reply_text: composeLimitedReply(input.category, input.reasonText, input.nextStep),
@ -452,6 +744,7 @@ function buildLimitedExecutionResult(input: {
extracted_filters: input.filters,
missing_required_filters: input.missingRequiredFilters,
selected_recipe: input.selectedRecipe,
mcp_call_status_legacy: toLegacyMcpStatus(input.mcpCallStatus),
account_scope_mode: input.accountScopeMode ?? "strict",
account_scope_fallback_applied: input.accountScopeFallbackApplied ?? false,
anchor_type: input.anchor?.anchor_type ?? null,
@ -459,6 +752,8 @@ function buildLimitedExecutionResult(input: {
anchor_value_resolved: input.anchor?.anchor_value_resolved ?? null,
resolver_confidence: input.anchor?.resolver_confidence ?? null,
ambiguity_count: input.anchor?.ambiguity_count ?? 0,
match_failure_stage: input.matchFailureStage ?? "none",
match_failure_reason: input.matchFailureReason ?? null,
mcp_call_status: input.mcpCallStatus,
rows_fetched: input.rowsFetched,
raw_rows_received: input.rawRowsReceived ?? input.rowsFetched,
@ -468,6 +763,11 @@ function buildLimitedExecutionResult(input: {
rows_matched: input.rowsMatched,
raw_row_keys_sample: input.rawRowKeysSample ?? [],
materialization_drop_reason: input.materializationDropReason ?? "none",
account_token_raw: accountScopeAudit.accountTokenRaw,
account_token_normalized: accountScopeAudit.accountTokenNormalized,
account_scope_fields_checked: accountScopeAudit.accountScopeFieldsChecked,
account_scope_match_strategy: accountScopeAudit.accountScopeMatchStrategy,
account_scope_drop_reason: accountScopeAudit.accountScopeDropReason,
runtime_readiness: runtimeReadinessForLimitedCategory(input.category),
limited_reason_category: input.category,
response_type: "LIMITED_WITH_REASON",
@ -605,7 +905,7 @@ export class AddressQueryService {
const intent = resolveAddressIntent(userMessage);
const filters = extractAddressFilters(userMessage, intent.intent);
const anchor = resolvePrimaryAnchor(intent.intent, filters.extracted_filters);
let anchor = resolvePrimaryAnchor(intent.intent, filters.extracted_filters);
const recipeSelection = selectAddressRecipe(intent.intent, filters.extracted_filters);
const baseReasons = [...mode.reasons, ...shape.reasons, ...intent.reasons];
@ -720,6 +1020,7 @@ export class AddressQueryService {
});
if (mcp.error) {
const errorScopeAudit = buildDefaultAccountScopeAudit(filters.extracted_filters);
return buildLimitedExecutionResult({
mode,
shape,
@ -733,8 +1034,10 @@ export class AddressQueryService {
errored: true,
rawRowsReceived: mcp.raw_rows.length,
rowsMaterialized: 0,
rowsAnchorMatched: 0,
rowsMatched: 0
}),
accountScopeAudit: errorScopeAudit,
rowsFetched: mcp.fetched_rows,
rawRowsReceived: mcp.raw_rows.length,
rowsAfterAccountScope: mcp.rows.length,
@ -759,16 +1062,44 @@ export class AddressQueryService {
normalizedRawRows.length > 0 &&
scopedRows.length === 0;
const normalizedRows = accountScopeFallbackApplied ? normalizedRawRows : scopedRows;
const filterByAnchors = applyAddressFilters(normalizedRows, filters.extracted_filters);
anchor = refineAnchorFromRows(anchor, normalizedRows);
const filtersForMatching: AddressFilterSet =
anchor.anchor_type === "counterparty" && anchor.anchor_value_resolved
? { ...filters.extracted_filters, counterparty: anchor.anchor_value_resolved }
: anchor.anchor_type === "contract" && anchor.anchor_value_resolved
? { ...filters.extracted_filters, contract: anchor.anchor_value_resolved }
: filters.extracted_filters;
const accountScopeAudit = buildAccountScopeAudit({
intent: intent.intent,
filters: filtersForMatching,
accountScope: plan.account_scope,
rowsBeforeScope: normalizedRawRows.length,
rowsAfterScope: normalizedRows.length
});
const anchorFilter = applyAddressFilters(normalizedRows, filtersForMatching);
const filterByAnchors = anchorFilter.rows;
const filteredRows = applyIntentSpecificFilter(intent.intent, filterByAnchors);
const rowDiagnostics = deriveRowStageDiagnostics(mcp.raw_rows, normalizedRows.length, normalizedRows.length);
const stageStatus = deriveMcpStageStatus({
rawRowsReceived: mcp.raw_rows.length,
rowsMaterialized: normalizedRows.length,
rowsAnchorMatched: filterByAnchors.length,
rowsMatched: filteredRows.length
});
const matchFailureStage: AddressMatchFailureStage =
stageStatus === "materialized_but_not_anchor_matched"
? "materialized_but_not_anchor_matched"
: stageStatus === "materialized_but_filtered_out_by_recipe"
? "materialized_but_filtered_out_by_recipe"
: "none";
const matchFailureReason =
matchFailureStage === "materialized_but_not_anchor_matched"
? anchorFilter.mismatchReason ?? "anchor_not_matched_after_materialization"
: matchFailureStage === "materialized_but_filtered_out_by_recipe"
? "rows_filtered_out_by_intent_recipe_after_anchor_match"
: null;
if (intent.intent === "list_open_contracts" && contractCandidatesFromRows(filteredRows).length === 0) {
if (intent.intent === "list_open_contracts" && filteredRows.length > 0 && contractCandidatesFromRows(filteredRows).length === 0) {
return buildLimitedExecutionResult({
mode,
shape,
@ -778,7 +1109,10 @@ export class AddressQueryService {
selectedRecipe: recipeSelection.selected_recipe.recipe_id,
accountScopeMode: plan.account_scope_mode,
accountScopeFallbackApplied,
accountScopeAudit,
anchor,
matchFailureStage,
matchFailureReason,
mcpCallStatus: stageStatus,
rowsFetched: mcp.fetched_rows,
rawRowsReceived: mcp.raw_rows.length,
@ -803,6 +1137,38 @@ export class AddressQueryService {
hadBaseRows &&
hadAnchorMatchedRows &&
(intent.intent === "list_documents_by_counterparty" || intent.intent === "bank_operations_by_counterparty");
const isAnchorMismatch = stageStatus === "materialized_but_not_anchor_matched";
const isRecipeFilteredOut = stageStatus === "materialized_but_filtered_out_by_recipe";
const category: AddressLimitedReasonCategory = isAnchorMismatch
? "missing_anchor"
: isRecipeFilteredOut
? "recipe_visibility_gap"
: isVisibilityGapCandidate
? "recipe_visibility_gap"
: "empty_match";
const reasonText = isAnchorMismatch
? "якорь контрагента/договора не найден в материализованных live-строках"
: isRecipeFilteredOut
? "строки по якорю найдены, но отфильтрованы intent-specific recipe"
: isVisibilityGapCandidate
? "в текущем live recipe нет достаточной document/bank видимости после фильтрации"
: "по выбранным фильтрам в live-выборке нет строк";
const nextStep = isAnchorMismatch
? "уточните контрагента точным именем или добавьте ИНН/договор"
: isRecipeFilteredOut
? "сузьте период, уточните контрагента или документный тип"
: isVisibilityGapCandidate
? "нужен специализированный recipe для document/bank контуров или более точный документный anchor"
: "уточните период, контрагента, договор или снимите часть фильтров";
const limitations = isAnchorMismatch
? ["anchor_not_matched_after_materialization"]
: isRecipeFilteredOut
? ["rows_filtered_out_by_recipe_after_anchor_match"]
: [
isVisibilityGapCandidate
? "document_or_bank_visibility_gap_after_base_filter"
: "no_rows_after_recipe_and_scope_filter"
];
return buildLimitedExecutionResult({
mode,
shape,
@ -812,7 +1178,10 @@ export class AddressQueryService {
selectedRecipe: recipeSelection.selected_recipe.recipe_id,
accountScopeMode: plan.account_scope_mode,
accountScopeFallbackApplied,
accountScopeAudit,
anchor,
matchFailureStage,
matchFailureReason,
mcpCallStatus: stageStatus,
rowsFetched: mcp.fetched_rows,
rawRowsReceived: mcp.raw_rows.length,
@ -822,18 +1191,10 @@ export class AddressQueryService {
rowsMatched: 0,
rawRowKeysSample: rowDiagnostics.rawRowKeysSample,
materializationDropReason: rowDiagnostics.materializationDropReason,
category: isVisibilityGapCandidate ? "recipe_visibility_gap" : "empty_match",
reasonText: isVisibilityGapCandidate
? "в текущем live recipe нет достаточной document/bank видимости после фильтрации"
: "по выбранным фильтрам в live-выборке нет строк",
nextStep: isVisibilityGapCandidate
? "нужен специализированный recipe для document/bank контуров или более точный документный anchor"
: "уточните период, контрагента, договор или снимите часть фильтров",
limitations: [
isVisibilityGapCandidate
? "document_or_bank_visibility_gap_after_base_filter"
: "no_rows_after_recipe_and_scope_filter"
],
category,
reasonText,
nextStep,
limitations,
reasons: baseReasons
});
}
@ -854,6 +1215,7 @@ export class AddressQueryService {
extracted_filters: filters.extracted_filters,
missing_required_filters: [],
selected_recipe: recipeSelection.selected_recipe.recipe_id,
mcp_call_status_legacy: toLegacyMcpStatus(stageStatus),
account_scope_mode: plan.account_scope_mode,
account_scope_fallback_applied: accountScopeFallbackApplied,
anchor_type: anchor.anchor_type,
@ -861,6 +1223,8 @@ export class AddressQueryService {
anchor_value_resolved: anchor.anchor_value_resolved,
resolver_confidence: anchor.resolver_confidence,
ambiguity_count: anchor.ambiguity_count,
match_failure_stage: "none",
match_failure_reason: null,
mcp_call_status: stageStatus,
rows_fetched: mcp.fetched_rows,
raw_rows_received: mcp.raw_rows.length,
@ -870,6 +1234,11 @@ export class AddressQueryService {
rows_matched: filteredRows.length,
raw_row_keys_sample: rowDiagnostics.rawRowKeysSample,
materialization_drop_reason: rowDiagnostics.materializationDropReason,
account_token_raw: accountScopeAudit.accountTokenRaw,
account_token_normalized: accountScopeAudit.accountTokenNormalized,
account_scope_fields_checked: accountScopeAudit.accountScopeFieldsChecked,
account_scope_match_strategy: accountScopeAudit.accountScopeMatchStrategy,
account_scope_drop_reason: accountScopeAudit.accountScopeDropReason,
runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS",
limited_reason_category: null,
response_type: factual.responseType,

View File

@ -19,6 +19,32 @@ __WHERE_CLAUSE__
Движения.Период УБЫВ
`;
const BANK_DOCS_QUERY_TEMPLATE = `
ВЫБРАТЬ ПЕРВЫЕ __LIMIT__
БанкСписание.Дата КАК Период,
ПРЕДСТАВЛЕНИЕ(БанкСписание.Ссылка) КАК Регистратор,
"" КАК СчетДт,
"" КАК СчетКт,
БанкСписание.СуммаДокумента КАК Сумма,
ПРЕДСТАВЛЕНИЕ(БанкСписание.Контрагент) КАК Контрагент
ИЗ
Документ.СписаниеСРасчетногоСчета КАК БанкСписание
__WHERE_OUT__
ОБЪЕДИНИТЬ ВСЕ
ВЫБРАТЬ ПЕРВЫЕ __LIMIT__
БанкПоступление.Дата КАК Период,
ПРЕДСТАВЛЕНИЕ(БанкПоступление.Ссылка) КАК Регистратор,
"" КАК СчетДт,
"" КАК СчетКт,
БанкПоступление.СуммаДокумента КАК Сумма,
ПРЕДСТАВЛЕНИЕ(БанкПоступление.Контрагент) КАК Контрагент
ИЗ
Документ.ПоступлениеНаРасчетныйСчет КАК БанкПоступление
__WHERE_IN__
УПОРЯДОЧИТЬ ПО
Период УБЫВ
`;
const BASE_RECIPES: AddressRecipeDefinition[] = [
{
recipe_id: "address_movements_payables_v1",
@ -68,7 +94,8 @@ const BASE_RECIPES: AddressRecipeDefinition[] = [
optional_filters: ["period_from", "period_to", "as_of_date", "organization", "limit", "sort"],
default_limit: 100,
account_scope: ["60", "62", "76", "51", "52"],
account_scope_mode: "preferred"
account_scope_mode: "preferred",
query_template: "bank_docs"
},
{
recipe_id: "address_bank_operations_by_counterparty_v1",
@ -78,7 +105,8 @@ const BASE_RECIPES: AddressRecipeDefinition[] = [
optional_filters: ["period_from", "period_to", "as_of_date", "organization", "limit", "sort"],
default_limit: 100,
account_scope: ["51", "52"],
account_scope_mode: "preferred"
account_scope_mode: "preferred",
query_template: "bank_docs"
},
{
recipe_id: "address_documents_forming_balance_v1",
@ -125,7 +153,7 @@ function toDateTimeExpr(isoDate: string, endOfDay: boolean): string | null {
return `ДАТАВРЕМЯ(${year}, ${month}, ${day}, ${hour}, ${minute}, ${second})`;
}
function buildWhereClause(filters: AddressFilterSet): string {
function buildWhereClause(filters: AddressFilterSet, fieldPath: string): string {
const periodFromExpr =
typeof filters.period_from === "string" && filters.period_from.trim().length > 0
? toDateTimeExpr(filters.period_from, false)
@ -140,16 +168,16 @@ function buildWhereClause(filters: AddressFilterSet): string {
: null;
if (periodFromExpr && periodToExpr) {
return `ГДЕ\n Движения.Период МЕЖДУ ${periodFromExpr} И ${periodToExpr}`;
return `ГДЕ\n ${fieldPath} МЕЖДУ ${periodFromExpr} И ${periodToExpr}`;
}
if (periodFromExpr) {
return `ГДЕ\n Движения.Период >= ${periodFromExpr}`;
return `ГДЕ\n ${fieldPath} >= ${periodFromExpr}`;
}
if (periodToExpr) {
return `ГДЕ\n Движения.Период <= ${periodToExpr}`;
return `ГДЕ\n ${fieldPath} <= ${periodToExpr}`;
}
if (asOfExpr) {
return `ГДЕ\n Движения.Период <= ${asOfExpr}`;
return `ГДЕ\n ${fieldPath} <= ${asOfExpr}`;
}
return "";
@ -194,11 +222,16 @@ export function buildAddressRecipePlan(
: [];
const accountScopeMode = recipe.account_scope_mode ?? "strict";
const whereClause = buildWhereClause(filters);
const query = MOVEMENTS_QUERY_TEMPLATE.replace("__LIMIT__", String(resolvedLimit)).replace(
"__WHERE_CLAUSE__",
whereClause
);
const query =
recipe.query_template === "bank_docs"
? BANK_DOCS_QUERY_TEMPLATE
.replaceAll("__LIMIT__", String(resolvedLimit))
.replace("__WHERE_OUT__", buildWhereClause(filters, "БанкСписание.Дата"))
.replace("__WHERE_IN__", buildWhereClause(filters, "БанкПоступление.Дата"))
: MOVEMENTS_QUERY_TEMPLATE.replace("__LIMIT__", String(resolvedLimit)).replace(
"__WHERE_CLAUSE__",
buildWhereClause(filters, "Движения.Период")
);
return {
recipe,

View File

@ -1725,6 +1725,7 @@ function buildAddressDebugPayload(addressDebug) {
extracted_filters: addressDebug.extracted_filters,
missing_required_filters: addressDebug.missing_required_filters,
selected_recipe: addressDebug.selected_recipe,
mcp_call_status_legacy: addressDebug.mcp_call_status_legacy,
account_scope_mode: addressDebug.account_scope_mode,
account_scope_fallback_applied: addressDebug.account_scope_fallback_applied,
anchor_type: addressDebug.anchor_type,
@ -1732,6 +1733,8 @@ function buildAddressDebugPayload(addressDebug) {
anchor_value_resolved: addressDebug.anchor_value_resolved,
resolver_confidence: addressDebug.resolver_confidence,
ambiguity_count: addressDebug.ambiguity_count,
match_failure_stage: addressDebug.match_failure_stage,
match_failure_reason: addressDebug.match_failure_reason,
mcp_call_status: addressDebug.mcp_call_status,
rows_fetched: addressDebug.rows_fetched,
raw_rows_received: addressDebug.raw_rows_received,
@ -1741,6 +1744,11 @@ function buildAddressDebugPayload(addressDebug) {
rows_matched: addressDebug.rows_matched,
raw_row_keys_sample: addressDebug.raw_row_keys_sample,
materialization_drop_reason: addressDebug.materialization_drop_reason,
account_token_raw: addressDebug.account_token_raw,
account_token_normalized: addressDebug.account_token_normalized,
account_scope_fields_checked: addressDebug.account_scope_fields_checked,
account_scope_match_strategy: addressDebug.account_scope_match_strategy,
account_scope_drop_reason: addressDebug.account_scope_drop_reason,
runtime_readiness: addressDebug.runtime_readiness,
limited_reason_category: addressDebug.limited_reason_category,
response_type: addressDebug.response_type,
@ -1817,10 +1825,13 @@ export class AssistantService {
detected_intent: addressLane.debug.detected_intent,
extracted_filters: addressLane.debug.extracted_filters,
selected_recipe: addressLane.debug.selected_recipe,
mcp_call_status_legacy: addressLane.debug.mcp_call_status_legacy,
account_scope_mode: addressLane.debug.account_scope_mode,
account_scope_fallback_applied: addressLane.debug.account_scope_fallback_applied,
anchor_type: addressLane.debug.anchor_type,
resolver_confidence: addressLane.debug.resolver_confidence,
match_failure_stage: addressLane.debug.match_failure_stage,
match_failure_reason: addressLane.debug.match_failure_reason,
mcp_call_status: addressLane.debug.mcp_call_status,
rows_fetched: addressLane.debug.rows_fetched,
raw_rows_received: addressLane.debug.raw_rows_received,
@ -1829,6 +1840,11 @@ export class AssistantService {
rows_materialized: addressLane.debug.rows_materialized,
rows_matched: addressLane.debug.rows_matched,
materialization_drop_reason: addressLane.debug.materialization_drop_reason,
account_token_raw: addressLane.debug.account_token_raw,
account_token_normalized: addressLane.debug.account_token_normalized,
account_scope_fields_checked: addressLane.debug.account_scope_fields_checked,
account_scope_match_strategy: addressLane.debug.account_scope_match_strategy,
account_scope_drop_reason: addressLane.debug.account_scope_drop_reason,
runtime_readiness: addressLane.debug.runtime_readiness,
limited_reason_category: addressLane.debug.limited_reason_category,
response_type: addressLane.debug.response_type,

View File

@ -42,11 +42,18 @@ export type AddressMcpCallStatus =
| "error"
| "no_raw_rows"
| "raw_rows_received_but_not_materialized"
| "materialized_but_not_anchor_matched"
| "materialized_but_filtered_out_by_recipe"
| "materialized_but_not_matched"
| "matched_non_empty";
export type AddressAccountScopeMode = "strict" | "preferred";
export type AddressMatchFailureStage =
| "none"
| "materialized_but_not_anchor_matched"
| "materialized_but_filtered_out_by_recipe";
export interface AddressModeDetection {
mode: AddressQuestionMode;
confidence: "high" | "medium" | "low";
@ -90,6 +97,7 @@ export interface AddressRecipeDefinition {
recipe_id: string;
intent: Exclude<AddressIntent, "unknown">;
purpose: string;
query_template?: "movements" | "bank_docs";
required_filters: Array<keyof AddressFilterSet>;
optional_filters: Array<keyof AddressFilterSet>;
default_limit: number;
@ -120,6 +128,7 @@ export interface AddressExecutionDebug {
extracted_filters: AddressFilterSet;
missing_required_filters: string[];
selected_recipe: string | null;
mcp_call_status_legacy: Exclude<AddressMcpCallStatus, "materialized_but_not_anchor_matched" | "materialized_but_filtered_out_by_recipe">;
account_scope_mode: AddressAccountScopeMode;
account_scope_fallback_applied: boolean;
anchor_type: "account" | "counterparty" | "contract" | "document_ref" | "unknown" | null;
@ -127,6 +136,8 @@ export interface AddressExecutionDebug {
anchor_value_resolved: string | null;
resolver_confidence: "high" | "medium" | "low" | null;
ambiguity_count: number;
match_failure_stage: AddressMatchFailureStage;
match_failure_reason: string | null;
mcp_call_status: AddressMcpCallStatus;
rows_fetched: number;
raw_rows_received: number;
@ -142,6 +153,15 @@ export interface AddressExecutionDebug {
| "missing_period_field"
| "missing_registrator_field"
| "unknown_row_shape";
account_token_raw: string | null;
account_token_normalized: string | null;
account_scope_fields_checked: string[];
account_scope_match_strategy: "account_code_regex_plus_alias_map_v1";
account_scope_drop_reason:
| "not_applicable"
| "no_account_scope_requested"
| "no_rows_after_scope_filter"
| "rows_remaining_after_scope_filter";
runtime_readiness: AddressRuntimeReadiness;
limited_reason_category: AddressLimitedReasonCategory | null;
response_type: AddressResponseType;

View File

@ -329,6 +329,7 @@ export interface AssistantDebugPayload {
extracted_filters?: Record<string, unknown>;
missing_required_filters?: string[];
selected_recipe?: string | null;
mcp_call_status_legacy?: "skipped" | "error" | "no_raw_rows" | "raw_rows_received_but_not_materialized" | "materialized_but_not_matched" | "matched_non_empty";
account_scope_mode?: "strict" | "preferred";
account_scope_fallback_applied?: boolean;
anchor_type?: "account" | "counterparty" | "contract" | "document_ref" | "unknown" | null;
@ -336,11 +337,15 @@ export interface AssistantDebugPayload {
anchor_value_resolved?: string | null;
resolver_confidence?: "high" | "medium" | "low" | null;
ambiguity_count?: number;
match_failure_stage?: "none" | "materialized_but_not_anchor_matched" | "materialized_but_filtered_out_by_recipe";
match_failure_reason?: string | null;
mcp_call_status?:
| "skipped"
| "error"
| "no_raw_rows"
| "raw_rows_received_but_not_materialized"
| "materialized_but_not_anchor_matched"
| "materialized_but_filtered_out_by_recipe"
| "materialized_but_not_matched"
| "matched_non_empty";
rows_fetched?: number;
@ -357,6 +362,11 @@ export interface AssistantDebugPayload {
| "missing_period_field"
| "missing_registrator_field"
| "unknown_row_shape";
account_token_raw?: string | null;
account_token_normalized?: string | null;
account_scope_fields_checked?: string[];
account_scope_match_strategy?: "account_code_regex_plus_alias_map_v1";
account_scope_drop_reason?: "not_applicable" | "no_account_scope_requested" | "no_rows_after_scope_filter" | "rows_remaining_after_scope_filter";
runtime_readiness?: "LIVE_QUERYABLE" | "LIVE_QUERYABLE_WITH_LIMITS" | "REQUIRES_SPECIALIZED_RECIPE" | "DEEP_ONLY" | "UNKNOWN";
limited_reason_category?: "empty_match" | "missing_anchor" | "recipe_visibility_gap" | "execution_error" | "unsupported" | null;
response_type?: "FACTUAL_LIST" | "FACTUAL_SUMMARY" | "LIMITED_WITH_REASON";

View File

@ -82,15 +82,22 @@ describe("address query limited taxonomy and stage diagnostics", () => {
expect(result?.debug.rows_matched).toBeTypeOf("number");
expect(["strict", "preferred"]).toContain(result?.debug.account_scope_mode);
expect(result?.debug.account_scope_fallback_applied).toBeTypeOf("boolean");
expect(result?.debug.mcp_call_status_legacy).toBeDefined();
expect(result?.debug.match_failure_stage).toBeDefined();
expect([
"no_raw_rows",
"raw_rows_received_but_not_materialized",
"materialized_but_not_anchor_matched",
"materialized_but_filtered_out_by_recipe",
"materialized_but_not_matched",
"matched_non_empty"
]).toContain(result?.debug.mcp_call_status);
expect(result?.debug.raw_row_keys_sample).toBeDefined();
expect(result?.debug.materialization_drop_reason).toBeDefined();
expect(result?.debug.account_scope_fields_checked).toBeDefined();
expect(result?.debug.account_scope_match_strategy).toBe("account_code_regex_plus_alias_map_v1");
expect(result?.debug.account_scope_drop_reason).toBeDefined();
});
});