АДРЕСНЫЙ РЕЖИМ - локальная подель на декомпозе

This commit is contained in:
dctouch 2026-04-01 17:55:02 +03:00
parent 4060a5e575
commit 4d59672576
90 changed files with 19595 additions and 785 deletions

26
AGENT.md Normal file
View File

@ -0,0 +1,26 @@
# Agent Guardrails (NDC_1C)
## Scope
This repository has two assistant lanes:
- `address_query` for fast factual MCP lookups.
- deep/canonical normalizer lane for heavy analysis.
Do not route plain address lookups to deep lane when an address fallback is possible.
## Encoding Policy (Mandatory)
To prevent recurring mojibake (`Р...`, `С...`) issues:
1. Use UTF-8 for all source and exported text artifacts.
2. When reading or writing text in PowerShell, always set encoding explicitly (`-Encoding utf8`).
3. For MCP/live payloads, normalize potentially broken UTF-8/CP1251 strings before matching/filtering.
4. Never store business-specific names in static normalization dictionaries.
- Use runtime anchor resolution from live rows.
5. Before finishing a change touching assistant text/debug/export:
- run tests that cover conversation export and address runtime,
- verify no new mojibake markers in changed files.
## Address Runtime Rules
1. MSP/MCP-first. Snapshots are fallback only.
2. `false_factual_rate` must remain `0`.
3. For short follow-ups with loose anchor form (`по <anchor>`), keep in address lane.
4. If factual result is unavailable, return operational limited reason, never hallucinate.

585
docs/ADDRESS/1.txt Normal file
View File

@ -0,0 +1,585 @@
# Assistant conversation export
session_id: asst--iAIydc_EC
export_mode: technical
exported_at: 2026-04-01T12:33:31.795Z
## 1. user
message_id: msg-2d0MoaXT6W
created_at: 2026-04-01T12:32:28.939Z
reply_type: n/a
Какой остаток по счету 60 на дату 2020-07-31?
## 2. assistant
message_id: msg-MVrv4gqMpT
created_at: 2026-04-01T12:32:29.818Z
reply_type: partial_coverage
trace_id: address-Q_QdUVA19F
В live-данных по текущему фильтру записи не найдены.
Причина: по выбранным фильтрам в live-выборке нет строк.
Что нужно уточнить: уточните период, контрагента, договор или снимите часть фильтров.
### technical_debug_payload_json
```json
{
"trace_id": "address-Q_QdUVA19F",
"prompt_version": "address_query_runtime_v1",
"schema_version": "address_query_runtime_v1",
"fallback_type": "partial",
"route_summary": null,
"fragments": [],
"requirements_extracted": [],
"coverage_report": {
"requirements_total": 0,
"requirements_covered": 0,
"requirements_uncovered": [],
"requirements_partially_covered": [],
"clarification_needed_for": [],
"out_of_scope_requirements": []
},
"routes": [],
"retrieval_status": [],
"retrieval_results": [],
"answer_grounding_check": {
"status": "partial",
"route_subject_match": true,
"missing_requirements": [],
"reasons": [
"address_action_detected",
"address_entity_detected",
"aggregate_signal_detected",
"account_balance_signal_detected"
],
"why_included_summary": [],
"selection_reason_summary": []
},
"dropped_intent_segments": [],
"detected_mode": "address_query",
"detected_mode_confidence": "high",
"query_shape": "AGGREGATE_LOOKUP",
"query_shape_confidence": "high",
"detected_intent": "account_balance_snapshot",
"detected_intent_confidence": "high",
"extracted_filters": {
"sort": "period_desc",
"limit": 20,
"account": "60",
"as_of_date": "2020-07-31"
},
"missing_required_filters": [],
"selected_recipe": "address_movements_account_snapshot_v1",
"mcp_call_status_legacy": "raw_rows_received_but_not_materialized",
"account_scope_mode": "strict",
"account_scope_fallback_applied": false,
"anchor_type": "account",
"anchor_value_raw": "60",
"anchor_value_resolved": "60",
"resolver_confidence": "high",
"ambiguity_count": 0,
"match_failure_stage": "none",
"match_failure_reason": null,
"mcp_call_status": "raw_rows_received_but_not_materialized",
"rows_fetched": 20,
"raw_rows_received": 20,
"rows_after_account_scope": 0,
"rows_after_recipe_filter": 0,
"rows_materialized": 0,
"rows_matched": 0,
"raw_row_keys_sample": [
"Период",
"Регистратор",
"СчетДт",
"СчетКт",
"Сумма",
"Period",
"Registrator",
"AccountDt",
"AccountKt",
"Amount"
],
"materialization_drop_reason": "dropped_by_account_scope_filter",
"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",
"runtime_readiness": "LIVE_QUERYABLE_WITH_LIMITS",
"limited_reason_category": "empty_match",
"response_type": "LIMITED_WITH_REASON",
"answer_structure_v11": null,
"investigation_state_snapshot": null,
"normalized": null,
"normalizer_output": null
}
```
## 3. user
message_id: msg-tPODcsFT2j
created_at: 2026-04-01T12:32:43.787Z
reply_type: n/a
Какой остаток по счету 76 на дату 2020-12-31?
## 4. assistant
message_id: msg-sbyXymS8Tu
created_at: 2026-04-01T12:32:44.036Z
reply_type: partial_coverage
trace_id: address-tWfGlq9Bif
В live-данных по текущему фильтру записи не найдены.
Причина: по выбранным фильтрам в live-выборке нет строк.
Что нужно уточнить: уточните период, контрагента, договор или снимите часть фильтров.
### technical_debug_payload_json
```json
{
"trace_id": "address-tWfGlq9Bif",
"prompt_version": "address_query_runtime_v1",
"schema_version": "address_query_runtime_v1",
"fallback_type": "partial",
"route_summary": null,
"fragments": [],
"requirements_extracted": [],
"coverage_report": {
"requirements_total": 0,
"requirements_covered": 0,
"requirements_uncovered": [],
"requirements_partially_covered": [],
"clarification_needed_for": [],
"out_of_scope_requirements": []
},
"routes": [],
"retrieval_status": [],
"retrieval_results": [],
"answer_grounding_check": {
"status": "partial",
"route_subject_match": true,
"missing_requirements": [],
"reasons": [
"address_action_detected",
"address_entity_detected",
"aggregate_signal_detected",
"account_balance_signal_detected"
],
"why_included_summary": [],
"selection_reason_summary": []
},
"dropped_intent_segments": [],
"detected_mode": "address_query",
"detected_mode_confidence": "high",
"query_shape": "AGGREGATE_LOOKUP",
"query_shape_confidence": "high",
"detected_intent": "account_balance_snapshot",
"detected_intent_confidence": "high",
"extracted_filters": {
"sort": "period_desc",
"limit": 20,
"account": "76",
"as_of_date": "2020-12-31"
},
"missing_required_filters": [],
"selected_recipe": "address_movements_account_snapshot_v1",
"mcp_call_status_legacy": "raw_rows_received_but_not_materialized",
"account_scope_mode": "strict",
"account_scope_fallback_applied": false,
"anchor_type": "account",
"anchor_value_raw": "76",
"anchor_value_resolved": "76",
"resolver_confidence": "high",
"ambiguity_count": 0,
"match_failure_stage": "none",
"match_failure_reason": null,
"mcp_call_status": "raw_rows_received_but_not_materialized",
"rows_fetched": 20,
"raw_rows_received": 20,
"rows_after_account_scope": 0,
"rows_after_recipe_filter": 0,
"rows_materialized": 0,
"rows_matched": 0,
"raw_row_keys_sample": [
"Период",
"Регистратор",
"СчетДт",
"СчетКт",
"Сумма",
"Period",
"Registrator",
"AccountDt",
"AccountKt",
"Amount"
],
"materialization_drop_reason": "dropped_by_account_scope_filter",
"account_token_raw": "76",
"account_token_normalized": "76",
"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",
"runtime_readiness": "LIVE_QUERYABLE_WITH_LIMITS",
"limited_reason_category": "empty_match",
"response_type": "LIMITED_WITH_REASON",
"answer_structure_v11": null,
"investigation_state_snapshot": null,
"normalized": null,
"normalizer_output": null
}
```
## 5. user
message_id: msg-_0vMruaosw
created_at: 2026-04-01T12:32:52.525Z
reply_type: n/a
Какие документы формируют остаток по счету 62 на дату 2020-07-31?
## 6. assistant
message_id: msg-vDgQf1_P6u
created_at: 2026-04-01T12:32:53.206Z
reply_type: partial_coverage
trace_id: address-D2al1c11rV
В live-данных по текущему фильтру записи не найдены.
Причина: по выбранным фильтрам в live-выборке нет строк.
Что нужно уточнить: уточните период, контрагента, договор или снимите часть фильтров.
### technical_debug_payload_json
```json
{
"trace_id": "address-D2al1c11rV",
"prompt_version": "address_query_runtime_v1",
"schema_version": "address_query_runtime_v1",
"fallback_type": "partial",
"route_summary": null,
"fragments": [],
"requirements_extracted": [],
"coverage_report": {
"requirements_total": 0,
"requirements_covered": 0,
"requirements_uncovered": [],
"requirements_partially_covered": [],
"clarification_needed_for": [],
"out_of_scope_requirements": []
},
"routes": [],
"retrieval_status": [],
"retrieval_results": [],
"answer_grounding_check": {
"status": "partial",
"route_subject_match": true,
"missing_requirements": [],
"reasons": [
"address_action_detected",
"address_entity_detected",
"drilldown_signal_detected",
"documents_forming_balance_signal_detected"
],
"why_included_summary": [],
"selection_reason_summary": []
},
"dropped_intent_segments": [],
"detected_mode": "address_query",
"detected_mode_confidence": "high",
"query_shape": "DRILLDOWN_REQUEST",
"query_shape_confidence": "high",
"detected_intent": "documents_forming_balance",
"detected_intent_confidence": "high",
"extracted_filters": {
"sort": "period_desc",
"limit": 20,
"account": "62",
"as_of_date": "2020-07-31"
},
"missing_required_filters": [],
"selected_recipe": "address_documents_forming_balance_v1",
"mcp_call_status_legacy": "raw_rows_received_but_not_materialized",
"account_scope_mode": "strict",
"account_scope_fallback_applied": false,
"anchor_type": "account",
"anchor_value_raw": "62",
"anchor_value_resolved": "62",
"resolver_confidence": "high",
"ambiguity_count": 0,
"match_failure_stage": "none",
"match_failure_reason": null,
"mcp_call_status": "raw_rows_received_but_not_materialized",
"rows_fetched": 20,
"raw_rows_received": 20,
"rows_after_account_scope": 0,
"rows_after_recipe_filter": 0,
"rows_materialized": 0,
"rows_matched": 0,
"raw_row_keys_sample": [
"Период",
"Регистратор",
"СчетДт",
"СчетКт",
"Сумма",
"Period",
"Registrator",
"AccountDt",
"AccountKt",
"Amount"
],
"materialization_drop_reason": "dropped_by_account_scope_filter",
"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",
"runtime_readiness": "LIVE_QUERYABLE_WITH_LIMITS",
"limited_reason_category": "empty_match",
"response_type": "LIMITED_WITH_REASON",
"answer_structure_v11": null,
"investigation_state_snapshot": null,
"normalized": null,
"normalizer_output": null
}
```
## 7. user
message_id: msg-n4DAnggWmb
created_at: 2026-04-01T12:33:02.902Z
reply_type: n/a
Что формирует остаток по счету 76 на дату 2020-12-31?
## 8. assistant
message_id: msg-46fXowQ8cs
created_at: 2026-04-01T12:33:03.352Z
reply_type: partial_coverage
trace_id: address-o7q7qa-FOF
В live-данных по текущему фильтру записи не найдены.
Причина: по выбранным фильтрам в live-выборке нет строк.
Что нужно уточнить: уточните период, контрагента, договор или снимите часть фильтров.
### technical_debug_payload_json
```json
{
"trace_id": "address-o7q7qa-FOF",
"prompt_version": "address_query_runtime_v1",
"schema_version": "address_query_runtime_v1",
"fallback_type": "partial",
"route_summary": null,
"fragments": [],
"requirements_extracted": [],
"coverage_report": {
"requirements_total": 0,
"requirements_covered": 0,
"requirements_uncovered": [],
"requirements_partially_covered": [],
"clarification_needed_for": [],
"out_of_scope_requirements": []
},
"routes": [],
"retrieval_status": [],
"retrieval_results": [],
"answer_grounding_check": {
"status": "partial",
"route_subject_match": true,
"missing_requirements": [],
"reasons": [
"address_action_detected",
"address_entity_detected",
"aggregate_signal_detected",
"account_balance_signal_detected"
],
"why_included_summary": [],
"selection_reason_summary": []
},
"dropped_intent_segments": [],
"detected_mode": "address_query",
"detected_mode_confidence": "high",
"query_shape": "AGGREGATE_LOOKUP",
"query_shape_confidence": "high",
"detected_intent": "account_balance_snapshot",
"detected_intent_confidence": "high",
"extracted_filters": {
"sort": "period_desc",
"limit": 20,
"account": "76",
"as_of_date": "2020-12-31"
},
"missing_required_filters": [],
"selected_recipe": "address_movements_account_snapshot_v1",
"mcp_call_status_legacy": "raw_rows_received_but_not_materialized",
"account_scope_mode": "strict",
"account_scope_fallback_applied": false,
"anchor_type": "account",
"anchor_value_raw": "76",
"anchor_value_resolved": "76",
"resolver_confidence": "high",
"ambiguity_count": 0,
"match_failure_stage": "none",
"match_failure_reason": null,
"mcp_call_status": "raw_rows_received_but_not_materialized",
"rows_fetched": 20,
"raw_rows_received": 20,
"rows_after_account_scope": 0,
"rows_after_recipe_filter": 0,
"rows_materialized": 0,
"rows_matched": 0,
"raw_row_keys_sample": [
"Период",
"Регистратор",
"СчетДт",
"СчетКт",
"Сумма",
"Period",
"Registrator",
"AccountDt",
"AccountKt",
"Amount"
],
"materialization_drop_reason": "dropped_by_account_scope_filter",
"account_token_raw": "76",
"account_token_normalized": "76",
"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",
"runtime_readiness": "LIVE_QUERYABLE_WITH_LIMITS",
"limited_reason_category": "empty_match",
"response_type": "LIMITED_WITH_REASON",
"answer_structure_v11": null,
"investigation_state_snapshot": null,
"normalized": null,
"normalizer_output": null
}
```
## 9. user
message_id: msg-ATLBoTgwlD
created_at: 2026-04-01T12:33:20.966Z
reply_type: n/a
Покажи банковские операции по контрагенту СВК с 2020-07-01 по 2020-07-31
## 10. assistant
message_id: msg-p1biGQOTcr
created_at: 2026-04-01T12:33:21.620Z
reply_type: factual
trace_id: address-JwD-3OLHgc
Собран список банковских операций по контрагенту (live address lane).
Строк отобрано: 2.
1. 2020-07-30T12:00:00Z | Поступление на расчетный счет 00000000029 от 30.07.2020 12:00:00 | 0 / 0 | 20000 | аналитика: Группа СВК
2. 2020-07-27T12:00:00Z | Поступление на расчетный счет 00000000028 от 27.07.2020 12:00:00 | 0 / 0 | 40860 | аналитика: Группа СВК
### technical_debug_payload_json
```json
{
"trace_id": "address-JwD-3OLHgc",
"prompt_version": "address_query_runtime_v1",
"schema_version": "address_query_runtime_v1",
"fallback_type": "none",
"route_summary": null,
"fragments": [],
"requirements_extracted": [],
"coverage_report": {
"requirements_total": 0,
"requirements_covered": 0,
"requirements_uncovered": [],
"requirements_partially_covered": [],
"clarification_needed_for": [],
"out_of_scope_requirements": []
},
"routes": [],
"retrieval_status": [],
"retrieval_results": [],
"answer_grounding_check": {
"status": "grounded",
"route_subject_match": true,
"missing_requirements": [],
"reasons": [
"address_action_detected",
"address_entity_detected",
"object_signal_detected",
"bank_ops_by_counterparty_signal_detected"
],
"why_included_summary": [],
"selection_reason_summary": []
},
"dropped_intent_segments": [],
"detected_mode": "address_query",
"detected_mode_confidence": "high",
"query_shape": "OBJECT_LOOKUP",
"query_shape_confidence": "medium",
"detected_intent": "bank_operations_by_counterparty",
"detected_intent_confidence": "medium",
"extracted_filters": {
"sort": "period_desc",
"limit": 20,
"counterparty": "СВК",
"period_from": "2020-07-01",
"period_to": "2020-07-31"
},
"missing_required_filters": [],
"selected_recipe": "address_bank_operations_by_counterparty_v1",
"mcp_call_status_legacy": "matched_non_empty",
"account_scope_mode": "preferred",
"account_scope_fallback_applied": false,
"anchor_type": "counterparty",
"anchor_value_raw": "СВК",
"anchor_value_resolved": "Группа СВК",
"resolver_confidence": "medium",
"ambiguity_count": 0,
"match_failure_stage": "none",
"match_failure_reason": null,
"mcp_call_status": "matched_non_empty",
"rows_fetched": 19,
"raw_rows_received": 19,
"rows_after_account_scope": 3,
"rows_after_recipe_filter": 2,
"rows_materialized": 3,
"rows_matched": 2,
"raw_row_keys_sample": [
"Период",
"Регистратор",
"СчетДт",
"СчетКт",
"Сумма",
"Контрагент",
"Period",
"Registrator",
"Amount"
],
"materialization_drop_reason": "none",
"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",
"runtime_readiness": "LIVE_QUERYABLE_WITH_LIMITS",
"limited_reason_category": null,
"response_type": "FACTUAL_LIST",
"answer_structure_v11": null,
"investigation_state_snapshot": null,
"normalized": null,
"normalizer_output": null
}
```

View File

@ -9,6 +9,7 @@
- `query_recipes_v1.md` - каталог фильтров и recipe-контракты.
- `runtime_integration_plan.md` - план встраивания `question_mode=address_query`.
- `address_runtime_contracts.md` - контракты runtime/debug/result для address lane.
- `address_architecture_contract_v1.md` - архитектурные границы `Decompose -> Resolve -> Execute -> Compose` и политика data-agnostic runtime.
- `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.
@ -23,4 +24,5 @@
- `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/`
- `docs/ADDRESS/runs/2026-04-01_Address_Query_Runtime_V1_M2_3D_Query_Variants_Expansion/`

View File

@ -0,0 +1,154 @@
# Address Architecture Contract V1
Дата: 2026-04-01
## 1) Зачем документ
Этот контракт фиксирует архитектурные границы `address_query`-контура, чтобы система оставалась переносимой между разными 1С-базами и не обрастала company-specific логикой.
Контракт обязателен для всех следующих инкрементов (`M2.4+`), рефакторов и новых intent/recipe.
## 2) Непересекаемые принципы
- `MCP/live-first`: основной источник фактов - live MCP.
- `MSP-only` в runtime: production path работает через MCP/MSP; snapshot - только controlled fallback.
- `snapshot` допускается только как явный fallback с reason code, а не как скрытая подмена.
- `runtime = data-agnostic`: никаких хардкодов под конкретную компанию.
- `acceptance = data-aware`: positive-кейсы можно подбирать на текущей базе только для проверки.
- `false_factual_rate = 0`: factual-ответ только при подтвержденных `rows_matched > 0`.
- `whitelist execution only`: никаких свободных NL->query генераторов.
## 3) Канонический pipeline
## Stage A: Decompose
Назначение: интерпретация текста вопроса, без обращения к данным компании.
Выход stage:
- `question_mode`
- `query_shape`
- `intent_candidates`
- `anchors_raw`
- `time_scope_raw`
- `filters_raw`
- `decomposition_plan` (опционально, для compound)
Запрещено на этапе Decompose:
- резолвить реальные объекты базы (контрагентов, договоры, документы);
- тянуть company-specific словари;
- генерировать запросы к 1С.
## Stage B: Resolve
Назначение: привязка raw-якорей к живым объектам через MCP.
Выход stage:
- `anchor_type`
- `anchor_value_raw`
- `anchor_value_resolved`
- `resolver_confidence`
- `ambiguity_count`
Правило:
- если якорь не подтвержден, runtime не выдумывает факт и идет в `LIMITED_WITH_REASON`.
## Stage C: Execute
Назначение: выполнение только через recipe whitelist.
Правила:
- `intent -> recipe_id` только из каталога;
- fixed `limit/sort/window` политика;
- `MCP` read-only;
- `MSP/MCP-only` execution path в production;
- snapshot fallback только явный.
Диагностика по стадиям:
- `no_raw_rows`
- `raw_rows_received_but_not_materialized`
- `materialized_but_not_anchor_matched`
- `materialized_but_filtered_out_by_recipe`
- `matched_non_empty`
- `error`
## Stage D: Compose
Назначение: финальный ответ строго по execution-результату.
Правила:
- factual только из `rows_matched`;
- если пусто - `LIMITED_WITH_REASON` с конкретной причиной;
- без reasoning-галлюцинаций и без “догадки по смыслу”.
## 4) Политика словарей
Разрешено (статически в коде):
- доменная типовая лексика (`доки`, `остаток`, `договор`, `дебиторка` и т.д.);
- правила парсинга дат/периодов/счетов;
- stop-слова и служебные alias-правила.
Запрещено:
- хранить в коде списки компаний, ИНН, договоров, документов конкретной базы;
- пополнять глобальные normalization-библиотеки живыми entity-именами;
- строить скрытые “памяти компании” вне runtime-сессии.
Допустимо:
- использовать runtime-сессионный контекст диалога (`followup context`) без записи в глобальные словари.
## 5) Критерии переносимости между компаниями
Система считается переносимой, если:
- новая база подключается без code change в resolver/intent logic;
- меняются только live-данные MCP, а не кодовые словари;
- question-bank остается валиден (с ожидаемыми различиями factual/limited по данным).
## 6) Антипаттерны (нельзя делать)
- Добавлять company alias map в `src/services/*` с реальными названиями контрагентов.
- Перекладывать проблему резолвинга в hardcoded `if company == ...`.
- Подмешивать deep-analysis ответ в address factual-блок без явного route handoff.
- Поднимать “временные” exceptions, которые ломают stage-контракт.
## 7) Техническая дисциплина кода
Новая логика должна ложиться в явные stage-модули:
- decompose
- resolve
- execute
- compose
- diagnostics
Если функция не относится к stage - это smell и повод к вынесению.
## 8) Как подключать LLM decompose
LLM на первом этапе нужен не для “знания компании”, а для структурной интерпретации вопроса.
LLM должен возвращать схему-stage-output:
- intent candidates
- shape
- anchor spans
- time scope
- filter hints
- confidence
Дальше все company-specific подтверждается только Resolver/Execute через MCP.
Итог:
- LLM decompose уменьшает NLP-хрупкость;
- не требует жирных живых словарей компаний;
- не нарушает data-agnostic принцип runtime.

View File

@ -2,11 +2,18 @@
Дата: 2026-03-29
Reference:
- `address_architecture_contract_v1.md` (architecture guardrails and stage boundaries).
## Runtime Policy
- Runtime lane is `data-agnostic`: no hardcoded counterparties/contracts/accounts from one concrete base.
- Acceptance lane is `data-aware`: positive cases are curated after exploratory live pass.
- Address lane remains MCP/live-first, whitelist-only, read-only.
- Runtime execution is MSP-only in production; snapshot usage is explicit fallback only.
- Canonical pipeline boundary: `Decompose -> Resolve -> Execute -> Compose` (no cross-stage leakage).
- LLM decompose stage interprets question structure only; company entities are resolved only in live resolver stage.
## Input Contract

View File

@ -10,6 +10,9 @@
- какие хвосты висят по договору
- у кого самый большой долг перед нами
- кому больше всего должны мы
- покажи дебиторку по контрагентам на дату
- покажи кредиторку по поставщикам на дату
- что висит по взаиморасчетам на текущую дату
## B. Счета и остатки
@ -18,6 +21,9 @@
- что висит на 60 счете
- какие документы формируют остаток по 62
- оборот по 60 за период
- раскрой остаток по счету 62 до документов
- покажи сальдо по счету 60.01 на дату
- из чего сложился остаток по 76 счету
## C. Договоры
@ -25,6 +31,9 @@
- что по договору 15/24
- есть ли долг по договору с Альфой
- какие документы связаны с этим договором
- покажи незакрытые договоры по контрагенту
- какие хвосты по договору №15/24 на дату
- есть ли открытые позиции по договору
## D. Документы
@ -32,6 +41,9 @@
- покажи документы по договору за период
- найди документ по номеру и дате
- покажи проведенные документы по организации
- какие документы доступны по компании СВК за 2021 год
- выведи документы по клиенту Бета за июль 2020
- покажи документы по поставщику Альфа за весь период
## E. Bank/Payment lookup
@ -39,24 +51,33 @@
- были ли поступления от Беты
- покажи списания с расчетного счета по договору
- найди оплату на сумму 150000
- покажи банковские операции по контрагенту Альфа
- покажи поступления и списания по клиенту Бета
- выведи bank operations by counterparty Alfa for all time
## F. Drilldown
- кто должен нам и какие документы это формируют
- что висит по Альфе и раскрой по документам
- по 60 счету что висит и раскрой по контрагентам
- какие документы формируют остаток по счету 62 на 2020-07-31
- расшифруй остаток по 60 счету до документов
## G. Составные factual вопросы
- покажи хвосты по Альфе и отдельно по Бете
- кто должен нам и отдельно кому должны мы
- найди долг по договору и покажи документы
- покажи документы по контрагенту и сразу банковские операции
- остаток по счету 60 и какие документы его формируют
## H. Check/verify (still factual)
- проверь, есть ли долг по Альфе
- проверь, есть ли незакрытые документы
- проверь, что висит на 60 счете
- проверь, есть ли открытые позиции по договору
- проверь, есть ли документы по контрагенту за июль 2020
## Правило маршрутизации

View File

@ -24,6 +24,18 @@
- `llm_normalizer/backend/src/services/assistantRuntimeGuards.ts`
- `llm_normalizer/backend/src/services/answerComposer.ts`
## 2.1) Architecture Reference (mandatory)
Перед любыми изменениями address lane сверяться с:
- `address_architecture_contract_v1.md`
Ключевая рамка:
- `Decompose -> Resolve -> Execute -> Compose`
- runtime не хранит company-specific словари
- company entities подтверждаются только через live resolver/MCP
## 3) To-Be: Separate Address Lane
Новый high-level flow:

View File

@ -0,0 +1,44 @@
# Address Query Runtime V1 — M2.3d Query Variants Expansion
Дата: 2026-04-01
## Цель шага
Подбить текущий этап M2.3c и расширить вариативность пользовательских формулировок для уже существующих intent'ов без изменения архитектурной рамки.
## Что сделано
1. Расширены сигналы address-mode:
- добавлены entity-токены `компания/организация/поставщик/клиент/партнер` и английские эквиваленты.
2. Расширен intent-resolver:
- добавлены вариации `documents_by_counterparty` и `bank_operations_by_counterparty` для формулировок через поставщика/клиента/компанию;
- добаван loose by-anchor trigger (`по <якорь>`) для short/slang формулировок.
3. Усилен filter-extractor:
- расширен extraction якоря контрагента (`по поставщику`, `по клиенту`, `по партнеру`, `по организации`, `by supplier/customer/client/partner`);
- добавлены all-time варианты (`за весь период`, `за весь срок`, `за всю историю`, `for entire period`, `full history`);
- добавлен month-period extraction (`за июль 2020`, `for july 2020`, `за 07.2020`);
- добавлен fallback extraction по loose phrase (`по свк`).
4. Закрыт регресс из live export:
- `какие доки есть по свк за 2021` больше не уходит в deep/canonical fallback;
- запрос остаётся в `address_query` и резолвится как `list_documents_by_counterparty`.
5. Расширен тестовый контур:
- добавлены новые unit-тесты в `addressQueryRuntimeM23.test.ts` под новые вариации и регрессионный кейс.
## Что НЕ меняли
- Не добавлялись новые intent'ы.
- Не менялась deep-analysis ветка.
- Не добавлялся free-form query builder.
## Проверка
- Команда: `npx vitest tests/addressQueryRuntimeM23.test.ts`
- Результат: `1 file passed, 25 tests passed`
## Следующий шаг
- Запустить dry/live прогон в окне ассистента на расширенном наборе формулировок и зафиксировать non-empty/limited распределение по counterparty/account семействам.

View File

@ -0,0 +1,24 @@
{
"run_id": "2026-04-01_Address_Query_Runtime_V1_M2_3D_Query_Variants_Expansion",
"comparison": {
"baseline_ref": "HEAD (before M2.3d patch)",
"current_ref": "workspace (after M2.3d patch + regression fix)"
},
"metrics": {
"address_m23_test_cases": {
"before": 15,
"after": 25,
"delta": 10
},
"address_m23_test_failures": {
"before": 0,
"after": 0,
"delta": 0
}
},
"notes": [
"delta reflects additional query-variation coverage for mode, intent, and filters",
"includes regression fix for slang docs phrase: 'какие доки есть по svk за 2021'",
"no architecture-scope expansion (no new intents)"
]
}

View File

@ -0,0 +1,15 @@
llm_normalizer/backend/src/services/addressQueryClassifier.ts
llm_normalizer/backend/src/services/addressIntentResolver.ts
llm_normalizer/backend/src/services/addressFilterExtractor.ts
llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts
llm_normalizer/backend/dist/services/addressQueryClassifier.js
llm_normalizer/backend/dist/services/addressIntentResolver.js
llm_normalizer/backend/dist/services/addressFilterExtractor.js
docs/ADDRESS/address_query/question_bank_v1.md
docs/ADDRESS/address_query/README.md
docs/ADDRESS/runs/2026-04-01_Address_Query_Runtime_V1_M2_3D_Query_Variants_Expansion/README.md
docs/ADDRESS/runs/2026-04-01_Address_Query_Runtime_V1_M2_3D_Query_Variants_Expansion/run_summary.json
docs/ADDRESS/runs/2026-04-01_Address_Query_Runtime_V1_M2_3D_Query_Variants_Expansion/before_after_metrics.json
docs/ADDRESS/runs/2026-04-01_Address_Query_Runtime_V1_M2_3D_Query_Variants_Expansion/query_variation_expansion_matrix.md
docs/ADDRESS/runs/2026-04-01_Address_Query_Runtime_V1_M2_3D_Query_Variants_Expansion/smoke_checks.md
docs/ADDRESS/runs/2026-04-01_Address_Query_Runtime_V1_M2_3D_Query_Variants_Expansion/changed_files.txt

View File

@ -0,0 +1,17 @@
# Query Variation Expansion Matrix (M2.3d)
| area | before | after | validation |
|---|---|---|---|
| address mode entity signals | mostly `контрагент/договор/счет/документ` | added `компания/организация/поставщик/клиент/партнер` (+EN aliases) + `доки/док` slang | `addressQueryRuntimeM23.test.ts` |
| docs/bank intent phrasing | counterparty-centric phrasing | supports company/supplier/customer/client/partner phrasing | `addressQueryRuntimeM23.test.ts` |
| counterparty anchor extraction | basic `контрагент/компания` capture | adds supplier/client/partner/org variants (+EN aliases) + loose `по <якорь>` fallback | `addressQueryRuntimeM23.test.ts` |
| all-time phrase detection | mostly `за все время` | adds `за весь период/срок/всю историю` + EN full-history variants | `addressQueryRuntimeM23.test.ts` |
| month period extraction | range/year extraction | adds month phrases (`за июль 2020`, `for july 2020`, `за 07.2020`) | `addressQueryRuntimeM23.test.ts` |
## Added example phrasings
- `Покажи платежи по поставщику Альфа за июль 2020`
- `Выведи документы по клиенту Бета за 2020-07`
- `Покажи банковские операции по клиенту Бета за весь период`
- `Какие компании есть в базе`
- `Какие доки есть по свк за 2021`

View File

@ -0,0 +1,32 @@
{
"run_id": "2026-04-01_Address_Query_Runtime_V1_M2_3D_Query_Variants_Expansion",
"date": "2026-04-01",
"stage": "Address Query Runtime V1",
"wave": "M2.3d",
"goal": "Query variants expansion for existing intents + slang/loose-anchor regression fix",
"status": "COMPLETED",
"scope": {
"new_intents": false,
"deep_analysis_changes": false,
"focus": [
"mode signals",
"intent hints",
"counterparty anchor extraction",
"all-time phrase handling",
"month-period extraction",
"slang docs + loose by-anchor handling",
"unit test coverage"
]
},
"checks": {
"test_command": "npx vitest tests/addressQueryRuntimeM23.test.ts",
"test_files_passed": 1,
"tests_passed": 25,
"tests_failed": 0
},
"guardrails": {
"false_factual_rate_target": 0,
"free_form_query_builder": "not_added",
"whitelist_recipe_policy": "unchanged"
}
}

View File

@ -0,0 +1,13 @@
# Smoke Checks
## Backend tests
- Command: `npx vitest tests/addressQueryRuntimeM23.test.ts`
- Result: PASS
- Details: `1 passed file`, `25 passed tests`, `0 failed`
## Build
- Command: `npm run build`
- Result: PASS
- Details: TypeScript build completed without errors.

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,157 @@
Отчет по анализу архива address_query
Инвентаризация разметки 1С, покрытие доменов и статус runtime-сценариев
Источник: архив address_query.zip | Дата документов внутри архива: 2026-03-29
Главный вывод
Архив посвящен не общему налоговому/НДС-контуру, а отдельному runtime-режиму address_query для factual lookup по взаиморасчетам, договорам, документам, банковским операциям и остаткам по счетам.
Масштаб inventory
Обработано 7 039 648 строк из 12 monthly snapshot-файлов за 2020 год, parse errors = 0, найдено 286 уникальных сущностей в 10 family-группах.
Что реально покрыто сейчас
На уровне V1/M2.3c подтверждены прежде всего сценарии по контрагентам и документам; account-сценарии видны, но еще ограничены materialization/account-scope проблемами; contract-сценарии требуют специализированных recipe.
Что с НДС
НДС-сущности и related entities в inventory присутствуют, но в текущий whitelist intents/runtime V1 они не входят как отдельный поддержанный домен.
1. Что находится в архиве
• Главный README описывает пакет как набор документов для перехода к отдельному runtime-режиму `question_mode=address_query`.
• Есть слой системной инвентаризации сущностей 1С по snapshot-корпусу 2020: entity inventory JSON-артефакты, relation/intention support sets, readable summary, run summary.
• Есть продуктовый слой: scenario matrix, query recipes, runtime contracts, integration plan, readiness matrix, question bank, acceptance suites и UI dry-run наборы.
То есть архив сочетает два уровня: исследование структуры 1С-базы и проектирование прикладного factual-query runtime поверх этой структуры.
2. Структура пакета по смысловым блокам
Блок
Ключевые файлы
Зачем нужен
Инвентаризация 1С
entity_map_1c_2020.md, entity_inventory_*.json, run_summary.json
Показывает, какие сущности есть в snapshot-корпусе, насколько они query-suitable и какие relation patterns обнаружены.
Сценарии и intents
address_scenario_matrix.md, question_bank_v1.md
Фиксирует пользовательские вопросы, intent mapping, приоритеты и expected response types.
Recipe-слой
query_recipes_v1.md, address_runtime_contracts.md
Задает безопасный runtime-контур: intent -> filters -> recipe -> MCP -> factual result.
Интеграция и готовность
runtime_integration_plan.md, runtime_readiness_matrix_v1.md, execution_lineup_v1_2026-03-29.md
Показывает, как это встраивается и какие сценарии уже живы, а какие еще нет.
Acceptance и dry-run
curated_positive_live_suite_v1.md, data_aware_positive_acceptance_suite_v1.md, ui_dry_run_*
Нужны для live-проверки, контроля false factual и ручного прогона поддержанных вопросов.
3. Масштаб и состав inventory по базе 1С
• Источник данных: 12 monthly NDJSON snapshots за 2020 год.
• Обработано 7 039 648 строк без parse errors.
• Найдено 286 сущностей в 10 family-группах.
• Наиболее крупный пласт по строкам - INFORMATION_REGISTER (6,3 млн строк), но адресный runtime строится прежде всего на регистрах, документах, журналах и справочниках.
Family
Сущностей / строк
Приоритет
Комментарий
DOCUMENT
77 / 202 636
все P0
Главный источник document-level factual lookup.
ACCOUNTING_REGISTER
2 / 190 244
оба P0
Ключ к остаткам, хвостам и drilldown по проводкам.
NSI_CATALOG
50 / 140 050
3 P0 / 47 P1
Нужен для договоров, контрагентов и фильтровых резолверов.
DOCUMENT_JOURNAL
12 / 126 868
все P1
Быстрый индекс документов и банковских выписок.
ACCUMULATION_REGISTER
36 / 73 022
все P0
В inventory высоко видимы, но в runtime V1 почти не заведены как отдельные intents.
4. Какие домены реально прорабатываются
• Ключевой продуктовый домен - адресные factual-вопросы по взаиморасчетам и документам, а не произвольная аналитика по всей базе.
• Судя по scenario matrix, question bank и runtime contracts, текущий V1 разбит не по классическим бухгалтерским разделам, а по operational query-доменам.
Домен
Что входит
Статус
Комментарий
Контрагенты и задолженность
payables, receivables, open items by counterparty
ядро V1
Самый зрелый блок; есть curated positive cases и live-with-limits.
Счета и остатки
account balance, balance docs, turnover
частично
Структурно готово, но account-scope/materialization ограничивает стабильные non-empty ответы.
Договоры
open contracts, docs/open items by contract
не дожато
Есть в дизайне, но нужны specialized recipe и resolver path.
Документы и банковские операции
docs by counterparty, bank ops, list by type
ближе к рабочему
Именно здесь подтверждены live non-empty кейсы по counterparty family.
НДС и налоговые сущности
НДС-регистры, счета-фактуры, НДС-документы в inventory
не runtime V1
Есть в инвентаризации, но не заведены как whitelist intents.
5. Какие intents поддержаны по документам
• P0-ядро, зафиксированное в bootstrap report: `list_open_contracts`, `list_payables_counterparties`, `list_receivables_counterparties`, `account_balance_snapshot`, `open_items_by_counterparty_or_contract`.
• Как ближайшее расширение v1.1 перечислены: `list_documents_by_counterparty`, `list_documents_by_contract`, `documents_forming_balance`.
• Фактически в live runtime на момент пакета явно реализованы: `list_documents_by_counterparty`, `bank_operations_by_counterparty`, `documents_forming_balance`.
ID
Сценарий
Статус
Что мешает
AQ-P0-02
payables by counterparty
live, но с ограничениями
Широкие промпты пока дают sparse matches.
AQ-P0-03
receivables by counterparty
live, но с ограничениями
Нужны более точные period hints и anchor refinement.
AQ-P0-04
account balance snapshot
live, но с ограничениями
Строки приходят, но выпадают до materialization.
AQ-P0-05
open items by counterparty
live, но с ограничениями
Нужен явный counterparty anchor для стабильного non-empty.
AQ-P0-07
documents by counterparty
live, но с ограничениями
Есть positive cases, но якоря еще хрупкие.
AQ-P0-07B
bank ops by counterparty
live, но с ограничениями
Позитив подтвержден, но узкие/широкие варианты нестабильны.
AQ-P0-01/06/08
contract-related scenarios
требует спец. recipe
Не хватает contract-aware recipe и contract resolver.
AQ-P0-09
documents forming balance
live, но с ограничениями
Account family still blocked before materialization.
6. Какие сущности являются опорными
• Главная структурная опора - `AccountingRegister_Хозрасчетный_RecordType`: через него идут account, document, organization и часть business drilldown связей.
• Из документов центральны `СписаниеСРасчетногоСчета`, `ПоступлениеНаРасчетныйСчет`, их строки расшифровки платежа, а также `АктСверкиВзаиморасчетов`.
• Из справочников критичен `Catalog_ДоговорыКонтрагентов`; при этом `Catalog_Контрагенты`, `Catalog_Организации`, `Catalog_БанковскиеСчета` формально в triage отмечены как P1, но фактически обязательны для фильтров и resolver-логики.
• Document journals (`ДокументыПоставщиков`, `ДокументыПокупателей`, `БанковскиеВыписки`) играют роль быстрого индексного слоя для list/drilldown сценариев.
7. Что можно сказать про НДС
В inventory присутствуют НДС-регистры и связанные сущности: `AccumulationRegister_НДСПредъявленный`, `AccumulationRegister_НДСЗаписиКнигиПокупок`, `AccumulationRegister_НДСЗаписиКнигиПродаж`, документы `СчетФактураПолученный`, `СчетФактураВыданный`, `СписаниеНДС`, журнал `РегламентныеДокументыНДС`.
• Однако в scenario matrix, recipe catalog и readiness matrix они не оформлены как отдельный runtime-домен текущего `address_query`.
• Поэтому корректный вывод такой: НДС уже есть в структурной инвентаризации базы, но в текущем пакете это не целевое продуктовое покрытие V1.
8. Ограничения и риски
• Часть labels и entity names в исходном экспорте испорчена cp1251/utf8 mojibake; потребуется финальный decoding/cleanup перед production binding.
• Без business resolvers нельзя надежно переходить от имени контрагента, договора или счета к ID.
• Free-form query builder специально запрещен; доступ предполагается только через whitelist recipe.
• Compound factual вопросы пока только детектируются, но не исполняются как multi-intent decomposition.
• Account-семейство пока не дает стабильный поток до materialization, хотя сырье на входе уже есть.
9. Выводы по текущему состоянию
• Архив уже дает хорошую картину по 1С-сущностям и будущему runtime.
• Сейчас реально прорабатывается не вся база и не весь бухгалтерский домен, а конкретный address-query слой: контрагенты, задолженность, договоры, документы, банковские операции, остатки/расшифровка по счетам.
• Самый зрелый operational блок - counterparty/document lookup.
• Contract-specific и account-specific сценарии структурно готовы, но требуют еще одного цикла реализации и настройки.
• НДС виден в inventory и может стать следующим отдельным доменом, но в данном архиве это пока не активное runtime-покрытие.
10. Практический next step
• Собрать отдельную матрицу: “что есть в inventory” vs “что заведено в intents/recipes” vs “что уже реально работает live”.
• Развести домены на 3 слоя: текущий рабочий V1, design-only backlog и visible-inventory but not productized.
• Отдельно сделать shortlist по НДС/налоговым сущностям, если следующая цель - расширить покрытие за пределы address_query.

Binary file not shown.

View File

@ -0,0 +1,2 @@
<?xml version='1.0' encoding='UTF-8' standalone='yes'?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="jpeg" ContentType="image/jpeg"/><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/customXml/itemProps1.xml" ContentType="application/vnd.openxmlformats-officedocument.customXmlProperties+xml"/><Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/><Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Override PartName="/word/fontTable.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.fontTable+xml"/><Override PartName="/word/numbering.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml"/><Override PartName="/word/settings.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.settings+xml"/><Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/><Override PartName="/word/stylesWithEffects.xml" ContentType="application/vnd.ms-word.stylesWithEffects+xml"/><Override PartName="/word/theme/theme1.xml" ContentType="application/vnd.openxmlformats-officedocument.theme+xml"/><Override PartName="/word/webSettings.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.webSettings+xml"/></Types>

View File

@ -0,0 +1,2 @@
<?xml version='1.0' encoding='UTF-8' standalone='yes'?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId3" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/><Relationship Id="rId4" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" Target="docProps/app.xml"/><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/thumbnail" Target="docProps/thumbnail.jpeg"/></Relationships>

View File

@ -0,0 +1,2 @@
<?xml version='1.0' encoding='UTF-8' standalone='yes'?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/customXmlProps" Target="itemProps1.xml"/></Relationships>

View File

@ -0,0 +1,2 @@
<?xml version='1.0' encoding='UTF-8' standalone='yes'?>
<b:Sources xmlns:b="http://schemas.openxmlformats.org/officeDocument/2006/bibliography" xmlns="http://schemas.openxmlformats.org/officeDocument/2006/bibliography" SelectedStyle="/APA.XSL" StyleName="APA"/>

View File

@ -0,0 +1,6 @@
<?xml version='1.0' encoding='UTF-8' standalone='yes'?>
<ds:datastoreItem xmlns:ds="http://schemas.openxmlformats.org/officeDocument/2006/customXml" ds:itemID="{EF278816-EC6F-A645-907D-7F25AECB1D4A}">
<ds:schemaRefs>
<ds:schemaRef ds:uri="http://schemas.openxmlformats.org/officeDocument/2006/bibliography"/>
</ds:schemaRefs>
</ds:datastoreItem>

View File

@ -0,0 +1,36 @@
<?xml version='1.0' encoding='UTF-8' standalone='yes'?>
<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties" xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes">
<Template>Normal.dotm</Template>
<TotalTime>0</TotalTime>
<Pages>1</Pages>
<Words>0</Words>
<Characters>0</Characters>
<Application>Microsoft Macintosh Word</Application>
<DocSecurity>0</DocSecurity>
<Lines>0</Lines>
<Paragraphs>0</Paragraphs>
<ScaleCrop>false</ScaleCrop>
<HeadingPairs>
<vt:vector size="2" baseType="variant">
<vt:variant>
<vt:lpstr>Title</vt:lpstr>
</vt:variant>
<vt:variant>
<vt:i4>1</vt:i4>
</vt:variant>
</vt:vector>
</HeadingPairs>
<TitlesOfParts>
<vt:vector size="1" baseType="lpstr">
<vt:lpstr/>
</vt:vector>
</TitlesOfParts>
<Manager/>
<Company/>
<LinksUpToDate>false</LinksUpToDate>
<CharactersWithSpaces>0</CharactersWithSpaces>
<SharedDoc>false</SharedDoc>
<HyperlinkBase/>
<HyperlinksChanged>false</HyperlinksChanged>
<AppVersion>14.0000</AppVersion>
</Properties>

View File

@ -0,0 +1,2 @@
<?xml version='1.0' encoding='UTF-8' standalone='yes'?>
<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" xmlns:dcmitype="http://purl.org/dc/dcmitype/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"><dc:title/><dc:subject/><dc:creator>python-docx</dc:creator><cp:keywords/><dc:description>generated by python-docx</dc:description><cp:lastModifiedBy/><cp:revision>1</cp:revision><dcterms:created xsi:type="dcterms:W3CDTF">2013-12-23T23:15:00Z</dcterms:created><dcterms:modified xsi:type="dcterms:W3CDTF">2013-12-23T23:15:00Z</dcterms:modified><cp:category/></cp:coreProperties>

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

View File

@ -0,0 +1,2 @@
<?xml version='1.0' encoding='UTF-8' standalone='yes'?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/><Relationship Id="rId4" Type="http://schemas.microsoft.com/office/2007/relationships/stylesWithEffects" Target="stylesWithEffects.xml"/><Relationship Id="rId5" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings" Target="settings.xml"/><Relationship Id="rId6" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/webSettings" Target="webSettings.xml"/><Relationship Id="rId7" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/fontTable" Target="fontTable.xml"/><Relationship Id="rId8" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme" Target="theme/theme1.xml"/><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/customXml" Target="../customXml/item1.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/numbering" Target="numbering.xml"/></Relationships>

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,61 @@
<?xml version='1.0' encoding='UTF-8' standalone='yes'?>
<w:fonts xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:w14="http://schemas.microsoft.com/office/word/2010/wordml" mc:Ignorable="w14">
<w:font w:name="Symbol">
<w:panose1 w:val="00000000000000000000"/>
<w:charset w:val="02"/>
<w:family w:val="auto"/>
<w:pitch w:val="variable"/>
<w:sig w:usb0="00000000" w:usb1="10000000" w:usb2="00000000" w:usb3="00000000" w:csb0="80000000" w:csb1="00000000"/>
</w:font>
<w:font w:name="Times New Roman">
<w:panose1 w:val="02020603050405020304"/>
<w:charset w:val="00"/>
<w:family w:val="auto"/>
<w:pitch w:val="variable"/>
<w:sig w:usb0="E0002AFF" w:usb1="C0007841" w:usb2="00000009" w:usb3="00000000" w:csb0="000001FF" w:csb1="00000000"/>
</w:font>
<w:font w:name="Cambria">
<w:panose1 w:val="02040503050406030204"/>
<w:charset w:val="00"/>
<w:family w:val="auto"/>
<w:pitch w:val="variable"/>
<w:sig w:usb0="E00002FF" w:usb1="400004FF" w:usb2="00000000" w:usb3="00000000" w:csb0="0000019F" w:csb1="00000000"/>
</w:font>
<w:font w:name=" 明朝">
<w:panose1 w:val="00000000000000000000"/>
<w:charset w:val="80"/>
<w:family w:val="roman"/>
<w:notTrueType/>
<w:pitch w:val="fixed"/>
<w:sig w:usb0="00000001" w:usb1="08070000" w:usb2="00000010" w:usb3="00000000" w:csb0="00020000" w:csb1="00000000"/>
</w:font>
<w:font w:name="Calibri">
<w:panose1 w:val="020F0502020204030204"/>
<w:charset w:val="00"/>
<w:family w:val="auto"/>
<w:pitch w:val="variable"/>
<w:sig w:usb0="E10002FF" w:usb1="4000ACFF" w:usb2="00000009" w:usb3="00000000" w:csb0="0000019F" w:csb1="00000000"/>
</w:font>
<w:font w:name=" ゴシック">
<w:panose1 w:val="00000000000000000000"/>
<w:charset w:val="80"/>
<w:family w:val="modern"/>
<w:notTrueType/>
<w:pitch w:val="fixed"/>
<w:sig w:usb0="00000001" w:usb1="08070000" w:usb2="00000010" w:usb3="00000000" w:csb0="00020000" w:csb1="00000000"/>
</w:font>
<w:font w:name="Courier">
<w:panose1 w:val="02000500000000000000"/>
<w:charset w:val="00"/>
<w:family w:val="auto"/>
<w:pitch w:val="variable"/>
<w:sig w:usb0="00000003" w:usb1="00000000" w:usb2="00000000" w:usb3="00000000" w:csb0="00000001" w:csb1="00000000"/>
</w:font>
<w:font w:name="Arial">
<w:panose1 w:val="020B0604020202020204"/>
<w:charset w:val="00"/>
<w:family w:val="auto"/>
<w:pitch w:val="variable"/>
<w:sig w:usb0="E0002AFF" w:usb1="C0007843" w:usb2="00000009" w:usb3="00000000" w:csb0="000001FF" w:csb1="00000000"/>
</w:font>
</w:fonts>

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,2 @@
<?xml version='1.0' encoding='UTF-8' standalone='yes'?>
<w:settings xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w10="urn:schemas-microsoft-com:office:word" xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:w14="http://schemas.microsoft.com/office/word/2010/wordml" xmlns:sl="http://schemas.openxmlformats.org/schemaLibrary/2006/main" mc:Ignorable="w14"><w:zoom w:val="bestFit"/><w:proofState w:spelling="clean" w:grammar="clean"/><w:defaultTabStop w:val="720"/><w:characterSpacingControl w:val="doNotCompress"/><w:savePreviewPicture/><w:compat><w:useFELayout/><w:compatSetting w:name="compatibilityMode" w:uri="http://schemas.microsoft.com/office/word" w:val="14"/><w:compatSetting w:name="overrideTableStyleFontSizeAndJustification" w:uri="http://schemas.microsoft.com/office/word" w:val="1"/><w:compatSetting w:name="enableOpenTypeFeatures" w:uri="http://schemas.microsoft.com/office/word" w:val="1"/><w:compatSetting w:name="doNotFlipMirrorIndents" w:uri="http://schemas.microsoft.com/office/word" w:val="1"/></w:compat><w:rsids><w:rsidRoot w:val="00B47730"/><w:rsid w:val="00034616"/><w:rsid w:val="0006063C"/><w:rsid w:val="0015074B"/><w:rsid w:val="0029639D"/><w:rsid w:val="00326F90"/><w:rsid w:val="00AA1D8D"/><w:rsid w:val="00B47730"/><w:rsid w:val="00CB0664"/><w:rsid w:val="00FC693F"/></w:rsids><m:mathPr><m:mathFont m:val="Cambria Math"/><m:brkBin m:val="before"/><m:brkBinSub m:val="--"/><m:smallFrac m:val="0"/><m:dispDef/><m:lMargin m:val="0"/><m:rMargin m:val="0"/><m:defJc m:val="centerGroup"/><m:wrapIndent m:val="1440"/><m:intLim m:val="subSup"/><m:naryLim m:val="undOvr"/></m:mathPr><w:themeFontLang w:val="en-US" w:eastAsia="ja-JP"/><w:clrSchemeMapping w:bg1="light1" w:t1="dark1" w:bg2="light2" w:t2="dark2" w:accent1="accent1" w:accent2="accent2" w:accent3="accent3" w:accent4="accent4" w:accent5="accent5" w:accent6="accent6" w:hyperlink="hyperlink" w:followedHyperlink="followedHyperlink"/><w:doNotAutoCompressPictures/><w:shapeDefaults><o:shapedefaults v:ext="edit" spidmax="1027"/><o:shapelayout v:ext="edit"><o:idmap v:ext="edit" data="1"/></o:shapelayout></w:shapeDefaults><w:decimalSymbol w:val="."/><w:listSeparator w:val=","/><w14:docId w14:val="24062061"/><w14:defaultImageDpi w14:val="300"/></w:settings>

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,318 @@
<?xml version='1.0' encoding='UTF-8' standalone='yes'?>
<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" name="Office Theme">
<a:themeElements>
<a:clrScheme name="Office">
<a:dk1>
<a:sysClr val="windowText" lastClr="000000"/>
</a:dk1>
<a:lt1>
<a:sysClr val="window" lastClr="FFFFFF"/>
</a:lt1>
<a:dk2>
<a:srgbClr val="1F497D"/>
</a:dk2>
<a:lt2>
<a:srgbClr val="EEECE1"/>
</a:lt2>
<a:accent1>
<a:srgbClr val="4F81BD"/>
</a:accent1>
<a:accent2>
<a:srgbClr val="C0504D"/>
</a:accent2>
<a:accent3>
<a:srgbClr val="9BBB59"/>
</a:accent3>
<a:accent4>
<a:srgbClr val="8064A2"/>
</a:accent4>
<a:accent5>
<a:srgbClr val="4BACC6"/>
</a:accent5>
<a:accent6>
<a:srgbClr val="F79646"/>
</a:accent6>
<a:hlink>
<a:srgbClr val="0000FF"/>
</a:hlink>
<a:folHlink>
<a:srgbClr val="800080"/>
</a:folHlink>
</a:clrScheme>
<a:fontScheme name="Office">
<a:majorFont>
<a:latin typeface="Calibri"/>
<a:ea typeface=""/>
<a:cs typeface=""/>
<a:font script="Jpan" typeface=" ゴシック"/>
<a:font script="Hang" typeface="맑은 고딕"/>
<a:font script="Hans" typeface="宋体"/>
<a:font script="Hant" typeface="新細明體"/>
<a:font script="Arab" typeface="Times New Roman"/>
<a:font script="Hebr" typeface="Times New Roman"/>
<a:font script="Thai" typeface="Angsana New"/>
<a:font script="Ethi" typeface="Nyala"/>
<a:font script="Beng" typeface="Vrinda"/>
<a:font script="Gujr" typeface="Shruti"/>
<a:font script="Khmr" typeface="MoolBoran"/>
<a:font script="Knda" typeface="Tunga"/>
<a:font script="Guru" typeface="Raavi"/>
<a:font script="Cans" typeface="Euphemia"/>
<a:font script="Cher" typeface="Plantagenet Cherokee"/>
<a:font script="Yiii" typeface="Microsoft Yi Baiti"/>
<a:font script="Tibt" typeface="Microsoft Himalaya"/>
<a:font script="Thaa" typeface="MV Boli"/>
<a:font script="Deva" typeface="Mangal"/>
<a:font script="Telu" typeface="Gautami"/>
<a:font script="Taml" typeface="Latha"/>
<a:font script="Syrc" typeface="Estrangelo Edessa"/>
<a:font script="Orya" typeface="Kalinga"/>
<a:font script="Mlym" typeface="Kartika"/>
<a:font script="Laoo" typeface="DokChampa"/>
<a:font script="Sinh" typeface="Iskoola Pota"/>
<a:font script="Mong" typeface="Mongolian Baiti"/>
<a:font script="Viet" typeface="Times New Roman"/>
<a:font script="Uigh" typeface="Microsoft Uighur"/>
<a:font script="Geor" typeface="Sylfaen"/>
</a:majorFont>
<a:minorFont>
<a:latin typeface="Cambria"/>
<a:ea typeface=""/>
<a:cs typeface=""/>
<a:font script="Jpan" typeface=" 明朝"/>
<a:font script="Hang" typeface="맑은 고딕"/>
<a:font script="Hans" typeface="宋体"/>
<a:font script="Hant" typeface="新細明體"/>
<a:font script="Arab" typeface="Arial"/>
<a:font script="Hebr" typeface="Arial"/>
<a:font script="Thai" typeface="Cordia New"/>
<a:font script="Ethi" typeface="Nyala"/>
<a:font script="Beng" typeface="Vrinda"/>
<a:font script="Gujr" typeface="Shruti"/>
<a:font script="Khmr" typeface="DaunPenh"/>
<a:font script="Knda" typeface="Tunga"/>
<a:font script="Guru" typeface="Raavi"/>
<a:font script="Cans" typeface="Euphemia"/>
<a:font script="Cher" typeface="Plantagenet Cherokee"/>
<a:font script="Yiii" typeface="Microsoft Yi Baiti"/>
<a:font script="Tibt" typeface="Microsoft Himalaya"/>
<a:font script="Thaa" typeface="MV Boli"/>
<a:font script="Deva" typeface="Mangal"/>
<a:font script="Telu" typeface="Gautami"/>
<a:font script="Taml" typeface="Latha"/>
<a:font script="Syrc" typeface="Estrangelo Edessa"/>
<a:font script="Orya" typeface="Kalinga"/>
<a:font script="Mlym" typeface="Kartika"/>
<a:font script="Laoo" typeface="DokChampa"/>
<a:font script="Sinh" typeface="Iskoola Pota"/>
<a:font script="Mong" typeface="Mongolian Baiti"/>
<a:font script="Viet" typeface="Arial"/>
<a:font script="Uigh" typeface="Microsoft Uighur"/>
<a:font script="Geor" typeface="Sylfaen"/>
</a:minorFont>
</a:fontScheme>
<a:fmtScheme name="Office">
<a:fillStyleLst>
<a:solidFill>
<a:schemeClr val="phClr"/>
</a:solidFill>
<a:gradFill rotWithShape="1">
<a:gsLst>
<a:gs pos="0">
<a:schemeClr val="phClr">
<a:tint val="50000"/>
<a:satMod val="300000"/>
</a:schemeClr>
</a:gs>
<a:gs pos="35000">
<a:schemeClr val="phClr">
<a:tint val="37000"/>
<a:satMod val="300000"/>
</a:schemeClr>
</a:gs>
<a:gs pos="100000">
<a:schemeClr val="phClr">
<a:tint val="15000"/>
<a:satMod val="350000"/>
</a:schemeClr>
</a:gs>
</a:gsLst>
<a:lin ang="16200000" scaled="1"/>
</a:gradFill>
<a:gradFill rotWithShape="1">
<a:gsLst>
<a:gs pos="0">
<a:schemeClr val="phClr">
<a:tint val="100000"/>
<a:shade val="100000"/>
<a:satMod val="130000"/>
</a:schemeClr>
</a:gs>
<a:gs pos="100000">
<a:schemeClr val="phClr">
<a:tint val="50000"/>
<a:shade val="100000"/>
<a:satMod val="350000"/>
</a:schemeClr>
</a:gs>
</a:gsLst>
<a:lin ang="16200000" scaled="0"/>
</a:gradFill>
</a:fillStyleLst>
<a:lnStyleLst>
<a:ln w="9525" cap="flat" cmpd="sng" algn="ctr">
<a:solidFill>
<a:schemeClr val="phClr">
<a:shade val="95000"/>
<a:satMod val="105000"/>
</a:schemeClr>
</a:solidFill>
<a:prstDash val="solid"/>
</a:ln>
<a:ln w="25400" cap="flat" cmpd="sng" algn="ctr">
<a:solidFill>
<a:schemeClr val="phClr"/>
</a:solidFill>
<a:prstDash val="solid"/>
</a:ln>
<a:ln w="38100" cap="flat" cmpd="sng" algn="ctr">
<a:solidFill>
<a:schemeClr val="phClr"/>
</a:solidFill>
<a:prstDash val="solid"/>
</a:ln>
</a:lnStyleLst>
<a:effectStyleLst>
<a:effectStyle>
<a:effectLst>
<a:outerShdw blurRad="40000" dist="20000" dir="5400000" rotWithShape="0">
<a:srgbClr val="000000">
<a:alpha val="38000"/>
</a:srgbClr>
</a:outerShdw>
</a:effectLst>
</a:effectStyle>
<a:effectStyle>
<a:effectLst>
<a:outerShdw blurRad="40000" dist="23000" dir="5400000" rotWithShape="0">
<a:srgbClr val="000000">
<a:alpha val="35000"/>
</a:srgbClr>
</a:outerShdw>
</a:effectLst>
</a:effectStyle>
<a:effectStyle>
<a:effectLst>
<a:outerShdw blurRad="40000" dist="23000" dir="5400000" rotWithShape="0">
<a:srgbClr val="000000">
<a:alpha val="35000"/>
</a:srgbClr>
</a:outerShdw>
</a:effectLst>
<a:scene3d>
<a:camera prst="orthographicFront">
<a:rot lat="0" lon="0" rev="0"/>
</a:camera>
<a:lightRig rig="threePt" dir="t">
<a:rot lat="0" lon="0" rev="1200000"/>
</a:lightRig>
</a:scene3d>
<a:sp3d>
<a:bevelT w="63500" h="25400"/>
</a:sp3d>
</a:effectStyle>
</a:effectStyleLst>
<a:bgFillStyleLst>
<a:solidFill>
<a:schemeClr val="phClr"/>
</a:solidFill>
<a:gradFill rotWithShape="1">
<a:gsLst>
<a:gs pos="0">
<a:schemeClr val="phClr">
<a:tint val="40000"/>
<a:satMod val="350000"/>
</a:schemeClr>
</a:gs>
<a:gs pos="40000">
<a:schemeClr val="phClr">
<a:tint val="45000"/>
<a:shade val="99000"/>
<a:satMod val="350000"/>
</a:schemeClr>
</a:gs>
<a:gs pos="100000">
<a:schemeClr val="phClr">
<a:shade val="20000"/>
<a:satMod val="255000"/>
</a:schemeClr>
</a:gs>
</a:gsLst>
<a:path path="circle">
<a:fillToRect l="50000" t="-80000" r="50000" b="180000"/>
</a:path>
</a:gradFill>
<a:gradFill rotWithShape="1">
<a:gsLst>
<a:gs pos="0">
<a:schemeClr val="phClr">
<a:tint val="80000"/>
<a:satMod val="300000"/>
</a:schemeClr>
</a:gs>
<a:gs pos="100000">
<a:schemeClr val="phClr">
<a:shade val="30000"/>
<a:satMod val="200000"/>
</a:schemeClr>
</a:gs>
</a:gsLst>
<a:path path="circle">
<a:fillToRect l="50000" t="50000" r="50000" b="50000"/>
</a:path>
</a:gradFill>
</a:bgFillStyleLst>
</a:fmtScheme>
</a:themeElements>
<a:objectDefaults>
<a:spDef>
<a:spPr/>
<a:bodyPr/>
<a:lstStyle/>
<a:style>
<a:lnRef idx="1">
<a:schemeClr val="accent1"/>
</a:lnRef>
<a:fillRef idx="3">
<a:schemeClr val="accent1"/>
</a:fillRef>
<a:effectRef idx="2">
<a:schemeClr val="accent1"/>
</a:effectRef>
<a:fontRef idx="minor">
<a:schemeClr val="lt1"/>
</a:fontRef>
</a:style>
</a:spDef>
<a:lnDef>
<a:spPr/>
<a:bodyPr/>
<a:lstStyle/>
<a:style>
<a:lnRef idx="2">
<a:schemeClr val="accent1"/>
</a:lnRef>
<a:fillRef idx="0">
<a:schemeClr val="accent1"/>
</a:fillRef>
<a:effectRef idx="1">
<a:schemeClr val="accent1"/>
</a:effectRef>
<a:fontRef idx="minor">
<a:schemeClr val="tx1"/>
</a:fontRef>
</a:style>
</a:lnDef>
</a:objectDefaults>
<a:extraClrSchemeLst/>
</a:theme>

View File

@ -0,0 +1,5 @@
<?xml version='1.0' encoding='UTF-8' standalone='yes'?>
<w:webSettings xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:w14="http://schemas.microsoft.com/office/word/2010/wordml" mc:Ignorable="w14">
<w:allowPNG/>
<w:doNotSaveAsSingleFile/>
</w:webSettings>

Binary file not shown.

View File

@ -0,0 +1,157 @@
Отчет по анализу архива address_query
Инвентаризация разметки 1С, покрытие доменов и статус runtime-сценариев
Источник: архив address_query.zip | Дата документов внутри архива: 2026-03-29
Главный вывод
Архив посвящен не общему налоговому/НДС-контуру, а отдельному runtime-режиму address_query для factual lookup по взаиморасчетам, договорам, документам, банковским операциям и остаткам по счетам.
Масштаб inventory
Обработано 7 039 648 строк из 12 monthly snapshot-файлов за 2020 год, parse errors = 0, найдено 286 уникальных сущностей в 10 family-группах.
Что реально покрыто сейчас
На уровне V1/M2.3c подтверждены прежде всего сценарии по контрагентам и документам; account-сценарии видны, но еще ограничены materialization/account-scope проблемами; contract-сценарии требуют специализированных recipe.
Что с НДС
НДС-сущности и related entities в inventory присутствуют, но в текущий whitelist intents/runtime V1 они не входят как отдельный поддержанный домен.
1. Что находится в архиве
• Главный README описывает пакет как набор документов для перехода к отдельному runtime-режиму `question_mode=address_query`.
• Есть слой системной инвентаризации сущностей 1С по snapshot-корпусу 2020: entity inventory JSON-артефакты, relation/intention support sets, readable summary, run summary.
• Есть продуктовый слой: scenario matrix, query recipes, runtime contracts, integration plan, readiness matrix, question bank, acceptance suites и UI dry-run наборы.
То есть архив сочетает два уровня: исследование структуры 1С-базы и проектирование прикладного factual-query runtime поверх этой структуры.
2. Структура пакета по смысловым блокам
Блок
Ключевые файлы
Зачем нужен
Инвентаризация 1С
entity_map_1c_2020.md, entity_inventory_*.json, run_summary.json
Показывает, какие сущности есть в snapshot-корпусе, насколько они query-suitable и какие relation patterns обнаружены.
Сценарии и intents
address_scenario_matrix.md, question_bank_v1.md
Фиксирует пользовательские вопросы, intent mapping, приоритеты и expected response types.
Recipe-слой
query_recipes_v1.md, address_runtime_contracts.md
Задает безопасный runtime-контур: intent -> filters -> recipe -> MCP -> factual result.
Интеграция и готовность
runtime_integration_plan.md, runtime_readiness_matrix_v1.md, execution_lineup_v1_2026-03-29.md
Показывает, как это встраивается и какие сценарии уже живы, а какие еще нет.
Acceptance и dry-run
curated_positive_live_suite_v1.md, data_aware_positive_acceptance_suite_v1.md, ui_dry_run_*
Нужны для live-проверки, контроля false factual и ручного прогона поддержанных вопросов.
3. Масштаб и состав inventory по базе 1С
• Источник данных: 12 monthly NDJSON snapshots за 2020 год.
• Обработано 7 039 648 строк без parse errors.
• Найдено 286 сущностей в 10 family-группах.
• Наиболее крупный пласт по строкам - INFORMATION_REGISTER (6,3 млн строк), но адресный runtime строится прежде всего на регистрах, документах, журналах и справочниках.
Family
Сущностей / строк
Приоритет
Комментарий
DOCUMENT
77 / 202 636
все P0
Главный источник document-level factual lookup.
ACCOUNTING_REGISTER
2 / 190 244
оба P0
Ключ к остаткам, хвостам и drilldown по проводкам.
NSI_CATALOG
50 / 140 050
3 P0 / 47 P1
Нужен для договоров, контрагентов и фильтровых резолверов.
DOCUMENT_JOURNAL
12 / 126 868
все P1
Быстрый индекс документов и банковских выписок.
ACCUMULATION_REGISTER
36 / 73 022
все P0
В inventory высоко видимы, но в runtime V1 почти не заведены как отдельные intents.
4. Какие домены реально прорабатываются
• Ключевой продуктовый домен - адресные factual-вопросы по взаиморасчетам и документам, а не произвольная аналитика по всей базе.
• Судя по scenario matrix, question bank и runtime contracts, текущий V1 разбит не по классическим бухгалтерским разделам, а по operational query-доменам.
Домен
Что входит
Статус
Комментарий
Контрагенты и задолженность
payables, receivables, open items by counterparty
ядро V1
Самый зрелый блок; есть curated positive cases и live-with-limits.
Счета и остатки
account balance, balance docs, turnover
частично
Структурно готово, но account-scope/materialization ограничивает стабильные non-empty ответы.
Договоры
open contracts, docs/open items by contract
не дожато
Есть в дизайне, но нужны specialized recipe и resolver path.
Документы и банковские операции
docs by counterparty, bank ops, list by type
ближе к рабочему
Именно здесь подтверждены live non-empty кейсы по counterparty family.
НДС и налоговые сущности
НДС-регистры, счета-фактуры, НДС-документы в inventory
не runtime V1
Есть в инвентаризации, но не заведены как whitelist intents.
5. Какие intents поддержаны по документам
• P0-ядро, зафиксированное в bootstrap report: `list_open_contracts`, `list_payables_counterparties`, `list_receivables_counterparties`, `account_balance_snapshot`, `open_items_by_counterparty_or_contract`.
• Как ближайшее расширение v1.1 перечислены: `list_documents_by_counterparty`, `list_documents_by_contract`, `documents_forming_balance`.
• Фактически в live runtime на момент пакета явно реализованы: `list_documents_by_counterparty`, `bank_operations_by_counterparty`, `documents_forming_balance`.
ID
Сценарий
Статус
Что мешает
AQ-P0-02
payables by counterparty
live, но с ограничениями
Широкие промпты пока дают sparse matches.
AQ-P0-03
receivables by counterparty
live, но с ограничениями
Нужны более точные period hints и anchor refinement.
AQ-P0-04
account balance snapshot
live, но с ограничениями
Строки приходят, но выпадают до materialization.
AQ-P0-05
open items by counterparty
live, но с ограничениями
Нужен явный counterparty anchor для стабильного non-empty.
AQ-P0-07
documents by counterparty
live, но с ограничениями
Есть positive cases, но якоря еще хрупкие.
AQ-P0-07B
bank ops by counterparty
live, но с ограничениями
Позитив подтвержден, но узкие/широкие варианты нестабильны.
AQ-P0-01/06/08
contract-related scenarios
требует спец. recipe
Не хватает contract-aware recipe и contract resolver.
AQ-P0-09
documents forming balance
live, но с ограничениями
Account family still blocked before materialization.
6. Какие сущности являются опорными
• Главная структурная опора - `AccountingRegister_Хозрасчетный_RecordType`: через него идут account, document, organization и часть business drilldown связей.
• Из документов центральны `СписаниеСРасчетногоСчета`, `ПоступлениеНаРасчетныйСчет`, их строки расшифровки платежа, а также `АктСверкиВзаиморасчетов`.
• Из справочников критичен `Catalog_ДоговорыКонтрагентов`; при этом `Catalog_Контрагенты`, `Catalog_Организации`, `Catalog_БанковскиеСчета` формально в triage отмечены как P1, но фактически обязательны для фильтров и resolver-логики.
• Document journals (`ДокументыПоставщиков`, `ДокументыПокупателей`, `БанковскиеВыписки`) играют роль быстрого индексного слоя для list/drilldown сценариев.
7. Что можно сказать про НДС
В inventory присутствуют НДС-регистры и связанные сущности: `AccumulationRegister_НДСПредъявленный`, `AccumulationRegister_НДСЗаписиКнигиПокупок`, `AccumulationRegister_НДСЗаписиКнигиПродаж`, документы `СчетФактураПолученный`, `СчетФактураВыданный`, `СписаниеНДС`, журнал `РегламентныеДокументыНДС`.
• Однако в scenario matrix, recipe catalog и readiness matrix они не оформлены как отдельный runtime-домен текущего `address_query`.
• Поэтому корректный вывод такой: НДС уже есть в структурной инвентаризации базы, но в текущем пакете это не целевое продуктовое покрытие V1.
8. Ограничения и риски
• Часть labels и entity names в исходном экспорте испорчена cp1251/utf8 mojibake; потребуется финальный decoding/cleanup перед production binding.
• Без business resolvers нельзя надежно переходить от имени контрагента, договора или счета к ID.
• Free-form query builder специально запрещен; доступ предполагается только через whitelist recipe.
• Compound factual вопросы пока только детектируются, но не исполняются как multi-intent decomposition.
• Account-семейство пока не дает стабильный поток до materialization, хотя сырье на входе уже есть.
9. Выводы по текущему состоянию
• Архив уже дает хорошую картину по 1С-сущностям и будущему runtime.
• Сейчас реально прорабатывается не вся база и не весь бухгалтерский домен, а конкретный address-query слой: контрагенты, задолженность, договоры, документы, банковские операции, остатки/расшифровка по счетам.
• Самый зрелый operational блок - counterparty/document lookup.
• Contract-specific и account-specific сценарии структурно готовы, но требуют еще одного цикла реализации и настройки.
• НДС виден в inventory и может стать следующим отдельным доменом, но в данном архиве это пока не активное runtime-покрытие.
10. Практический next step
• Собрать отдельную матрицу: “что есть в inventory” vs “что заведено в intents/recipes” vs “что уже реально работает live”.
• Развести домены на 3 слоя: текущий рабочий V1, design-only backlog и visible-inventory but not productized.
• Отдельно сделать shortlist по НДС/налоговым сущностям, если следующая цель - расширить покрытие за пределы address_query.

Binary file not shown.

View File

@ -0,0 +1,42 @@
# ADDRESS Query Production Pack V2
Дата: 2026-03-30
## Назначение
Этот пакет расширяет исходный `address_query` архив до production-oriented слоя:
- фиксирует полный доменный контур;
- раскладывает покрытие по intent/recipe/filter;
- добавляет массовый банк пользовательских формулировок в разных диалектах;
- задает acceptance-пакет для live проверки и дальнейшей доработки runtime.
## Что внутри
- `prod_domain_expansion_report.md` — итоговый аналитический отчет и стратегия доведения до prod.
- `address_domain_matrix_v2.md` — целевая матрица доменов/интентов/рецептов.
- `query_paraphrase_bank_v2.md` — расширенный банк формулировок по всем доменам.
- `question_bank_massive_v2.json` — машиночитаемый банк вопросов для NCP/MCP/LLM-routing.
- `resolver_normalization_spec_v2.md` — спецификация нормализации фильтров и синонимов.
- `production_acceptance_pack_v2.md` — критерии приемки для live-runtime.
- `execution_backlog_v2.md` — последовательность внедрения до prod-ready.
- `domain_gap_register_v2.md` — регистр пробелов между текущим runtime и целевым продом.
## Ключевой принцип
Проблема должна решаться не точечной ручной настройкой под отдельные вопросы, а слоем:
`dialect variants -> canonical intent -> required filters -> recipe -> MCP -> factual output`.
## Объем
- intent'ов: 13
- вопросов/парафраз: 234
- доменов: 8
## Совместимость со стилем исходного архива
Пакет сохранен в том же формате, что и исходный архив:
- markdown-документы;
- таблицы intent/recipe/filter;
- отдельный question bank;
- acceptance/readiness артефакты.

View File

@ -0,0 +1,28 @@
# Address Domain Matrix V2 (Prod Target)
Дата: 2026-03-30 Режим: `question_mode=address_query` (production extension)
## Scope
Этот слой охватывает direct factual lookup, агрегаты, drilldown и point-lookup по документам.
## Matrix
| scenario_id | domain | intent | recipe_id | required_filters | optional_filters | current_status | target_status | expected_response_type | priority |
|---|---|---|---|---|---|---|---|---|---|
| AQ-P0-01 | `contracts` | `list_open_contracts` | `address.open_contracts.by_asof` | `as_of_date` | `organization, counterparty, limit, sort` | STRUCTURALLY_VISIBLE / REQUIRES_SPECIALIZED_RECIPE | PROD_READY | `FACTUAL_LIST` | P0 |
| AQ-P0-02 | `debt` | `list_payables_counterparties` | `address.payables.counterparty_totals` | `as_of_date` | `organization, account, limit, sort` | STRUCTURALLY_VISIBLE / PLANNED | PROD_READY | `FACTUAL_SUMMARY` | P0 |
| AQ-P0-03 | `debt` | `list_receivables_counterparties` | `address.receivables.counterparty_totals` | `as_of_date` | `organization, account, limit, sort` | STRUCTURALLY_VISIBLE / PLANNED | PROD_READY | `FACTUAL_SUMMARY` | P0 |
| AQ-P0-04 | `accounts` | `account_balance_snapshot` | `address.account.balance_snapshot` | `account, as_of_date` | `organization, limit, sort` | STRUCTURALLY_VISIBLE / LIVE_QUERYABLE_WITH_LIMITS | PROD_READY | `FACTUAL_SUMMARY` | P0 |
| AQ-P0-05 | `open_items` | `open_items_by_counterparty` | `address.open_items.by_counterparty` | `counterparty, as_of_date` | `organization, contract, account, limit` | STRUCTURALLY_VISIBLE / LIVE_QUERYABLE_WITH_LIMITS | PROD_READY | `FACTUAL_LIST` | P0 |
| AQ-P0-06 | `open_items` | `open_items_by_contract` | `address.open_items.by_contract` | `contract, as_of_date` | `organization, counterparty, account, limit` | STRUCTURALLY_VISIBLE / REQUIRES_SPECIALIZED_RECIPE | PROD_READY | `FACTUAL_LIST` | P0 |
| AQ-P0-07 | `documents` | `list_documents_by_counterparty` | `address.documents.by_counterparty` | `counterparty, period_from, period_to` | `document_type, organization, limit, sort` | STRUCTURALLY_VISIBLE / LIVE_QUERYABLE_WITH_LIMITS | PROD_READY | `FACTUAL_LIST` | P0 |
| AQ-P0-07B | `bank_ops` | `bank_operations_by_counterparty` | `address.bank_ops.by_counterparty` | `counterparty` | `period_from, period_to, organization, limit, sort` | STRUCTURALLY_VISIBLE / LIVE_QUERYABLE_WITH_LIMITS | PROD_READY | `FACTUAL_LIST` | P0 |
| AQ-P0-08 | `documents` | `list_documents_by_contract` | `address.documents.by_contract` | `contract, period_from, period_to` | `document_type, organization, limit, sort` | STRUCTURALLY_VISIBLE / REQUIRES_SPECIALIZED_RECIPE | PROD_READY | `FACTUAL_LIST` | P0 |
| AQ-P0-09 | `drilldown` | `documents_forming_balance` | `address.balance.drilldown_documents` | `account, as_of_date` | `organization, counterparty, contract, limit` | STRUCTURALLY_VISIBLE / LIVE_QUERYABLE_WITH_LIMITS | PROD_READY | `FACTUAL_LIST` | P0 |
| AQ-P1-10 | `accounts` | `account_turnover_snapshot` | `address.account.turnover_snapshot` | `account, period_from, period_to` | `organization, counterparty, limit` | STRUCTURALLY_VISIBLE / PLANNED | PROD_READY | `FACTUAL_SUMMARY` | P1 |
| AQ-P1-11 | `documents` | `list_documents_by_type` | `address.documents.by_type` | `document_type, period_from, period_to` | `organization, counterparty, contract, limit` | STRUCTURALLY_VISIBLE / PLANNED | PROD_READY | `FACTUAL_LIST` | P1 |
| AQ-P1-12 | `document_lookup` | `find_document_by_number_and_date` | `address.document.lookup_by_number_date` | `document_number, document_date` | `document_type, organization, counterparty` | STRUCTURALLY_VISIBLE / NEW | PROD_READY | `FACTUAL_LIST` | P1 |
## Production Design Rules
- Для каждого intent должен существовать минимум один canonical recipe.
- Для каждого intent должны поддерживаться разговорные, бухгалтерские и сокращенные формулировки.
- Missing-filter fallback всегда явный: `LIMITED_WITH_REASON`, без ложного factual.
- Resolver работает отдельно от recipe и не зашивается в тексты примеров.
- Составные factual вопросы должны распадаться на подзапросы только после стабилизации single-intent покрытия.

View File

@ -0,0 +1,21 @@
# Domain Gap Register V2
Дата: 2026-03-30
## Регистр пробелов между текущим runtime и target prod
| domain | intent | current_gap | prod action |
|---|---|---|---|
| `contracts` | `list_open_contracts` | нет стабильного runtime recipe / resolver path | Нужен стабильный resolver по договору и единая логика open/closed. |
| `debt` | `list_payables_counterparties` | intent структурно описан, но не доведен до live-runtime | Ключевое покрытие бухгалтерского жаргона: кредиторка, задолженность перед поставщиками, долг поставщикам. |
| `debt` | `list_receivables_counterparties` | intent структурно описан, но не доведен до live-runtime | Нужны варианты: дебиторка, нам должны, покупатели должны, долг нам. |
| `accounts` | `account_balance_snapshot` | есть частичная live-проходимость, но недостаточная устойчивость по диалектам/anchor variants | Главный блокер — account scope/materialization. |
| `open_items` | `open_items_by_counterparty` | есть частичная live-проходимость, но недостаточная устойчивость по диалектам/anchor variants | Нужно покрыть жаргон: хвосты, незакрытое, висяк, остатки по взаиморасчетам. |
| `open_items` | `open_items_by_contract` | нет стабильного runtime recipe / resolver path | Требуется двухшаговый resolver contract->focused recipe. |
| `documents` | `list_documents_by_counterparty` | есть частичная live-проходимость, но недостаточная устойчивость по диалектам/anchor variants | Нужно держать разные диалекты: по поставщику, по клиенту, по контре, движения по контрагенту. |
| `bank_ops` | `bank_operations_by_counterparty` | есть частичная live-проходимость, но недостаточная устойчивость по диалектам/anchor variants | Варианты: оплаты, платежи, списания, поступления, банк по контрагенту. |
| `documents` | `list_documents_by_contract` | нет стабильного runtime recipe / resolver path | Нужна явная связка document <-> contract и фильтр по договорному идентификатору. |
| `drilldown` | `documents_forming_balance` | есть частичная live-проходимость, но недостаточная устойчивость по диалектам/anchor variants | Здесь особенно важны формулировки ‘расшифруй остаток’, ‘из чего сложился остаток’. |
| `accounts` | `account_turnover_snapshot` | intent структурно описан, но не доведен до live-runtime | Нужны варианты с оборотами, движением, дебет/кредит оборотами. |
| `documents` | `list_documents_by_type` | intent структурно описан, но не доведен до live-runtime | Важно распознавать русские и 1С-имена типа документа. |
| `document_lookup` | `find_document_by_number_and_date` | intent структурно описан, но не доведен до live-runtime | Берется прямо из question_bank_v1, полезно как частый пользовательский паттерн. |

View File

@ -0,0 +1,60 @@
# Execution Backlog V2
Дата: 2026-03-30
## Принцип приоритезации
Сначала доводим до устойчивого prod те интенты, где:
- уже есть structural support;
- уже видны живые строки или близкий доступ к ним;
- высокий бизнес-спрос;
- много пользовательских вариантов формулировок.
## Wave 1 — Stabilize current liveable intents
1. `list_documents_by_counterparty`
2. `bank_operations_by_counterparty`
3. `documents_forming_balance`
4. `account_balance_snapshot`
Что делаем:
- расширяем paraphrase bank;
- включаем account token normalization;
- добавляем resolver confidence audit;
- собираем positive + negative curated cases.
## Wave 2 — Close core accounting gap
5. `list_payables_counterparties`
6. `list_receivables_counterparties`
7. `list_open_contracts`
8. `open_items_by_counterparty`
9. `open_items_by_contract`
Что делаем:
- фиксируем recipe white-list;
- готовим отдельный resolver слой по договору/контрагенту;
- вводим явный open-items taxonomy;
- проверяем account/account-family joins.
## Wave 3 — Expand discoverability
10. `list_documents_by_contract`
11. `account_turnover_snapshot`
12. `list_documents_by_type`
13. `find_document_by_number_and_date`
Что делаем:
- point lookup path;
- type resolver;
- унификация периодных фильтров;
- корпус live acceptance.
## Definition of Done для каждого intent
- 10+ устойчивых формулировок;
- canonical filters извлекаются стабильно;
- selected recipe детерминирован;
- есть curated positive evidence;
- negative twins безопасны;
- documented limitation taxonomy закрыта.

View File

@ -0,0 +1,10 @@
README.md
address_domain_matrix_v2.md
domain_gap_register_v2.md
execution_backlog_v2.md
manifest.txt
prod_domain_expansion_report.md
production_acceptance_pack_v2.md
query_paraphrase_bank_v2.md
question_bank_massive_v2.json
resolver_normalization_spec_v2.md

View File

@ -0,0 +1,110 @@
# Address Query Production Expansion Report
Дата: 2026-03-30
## 1) Что именно решает этот пакет
Исходный архив хорошо описывает V1 runtime и фиксирует стартовый набор сценариев. Главная проблема, обозначенная пользователем, лежит не только в recipe-слое, а в связке:
`вариант формулировки -> intent -> filters -> resolver -> recipe`
Поэтому для доведения до prod недостаточно добавлять отдельные кейсы вручную. Нужно массовое покрытие домена через корпус формулировок и явные контракты нормализации.
## 2) Какой production target предлагается
### Production target = не “понимает один вопрос”, а:
- распознает 10+ естественных формулировок на intent;
- одинаково держит нейтральный, бухгалтерский и разговорный диалект;
- не разваливается от короткой/грязной формулировки;
- извлекает одинаковые canonical filters из разных phrasing;
- либо отдает factual, либо честно возвращает `LIMITED_WITH_REASON`.
## 3) Домены, которые надо считать целевыми
На базе исходного архива и question bank целевой контур такой:
1. задолженность / дебиторка / кредиторка;
2. открытые позиции / хвосты;
3. остатки по счетам;
4. обороты по счетам;
5. договоры;
6. документы по контрагенту;
7. документы по договору;
8. документы по типу;
9. банковские операции;
10. drilldown “какими документами сформирован остаток”;
11. point lookup документа по номеру/дате.
## 4) Что было слабым местом в исходном V1
- Сильнее всего был проработан counterparty/documents контур.
- Account family была частично видна, но упиралась в materialization/account-scope.
- Contract-family требовала специализированный resolver/recipe path.
- Массового корпуса диалектных формулировок не было: question bank был правильный по смыслу, но слишком компактный для продового routing.
## 5) Что добавлено в этот пакет
### A. Domain matrix V2
Фиксирует полный target-контур с required/optional filters и target recipe.
### B. Massive paraphrase bank
Дает корпус из 200+ формулировок, которые можно:
- использовать для декомпозиции и роутинга;
- пускать в automated batch acceptance;
- превращать в curated live suites.
### C. Normalization spec
Фиксирует, как переводить “контра/поставщик/клиент”, “хвост/остаток/сальдо”, “кредиторка/дебиторка” в stable intent/filter space.
### D. Production acceptance
Переводит качество из “кажется, местами работает” в измеримый acceptance по intent, диалекту и resolver quality.
## 6) Практическая стратегия внедрения
### Шаг 1. Freeze canonical intents
Не расширять бесконечно список intent'ов. Сначала закрепить канонический набор.
### Шаг 2. Attach paraphrase bank to normalizer tests
Каждая формулировка должна маппиться в один intent и один filter-contract.
### Шаг 3. Separate resolver failures from recipe failures
Сейчас часть “падений” пользователь видит как общую ошибку. В проде нужно видеть:
- не найден якорь;
- найден неоднозначно;
- recipe не поддерживает shape;
- raw rows есть, но materialization drops them.
### Шаг 4. Curated positive evidence
По каждому intent нужны реальные non-empty live кейсы. Без этого можно проверить только безопасность, но не полезность.
## 7) Рекомендуемая производственная последовательность
### Первая волна
- `list_documents_by_counterparty`
- `bank_operations_by_counterparty`
- `documents_forming_balance`
- `account_balance_snapshot`
### Вторая волна
- `list_payables_counterparties`
- `list_receivables_counterparties`
- `list_open_contracts`
- `open_items_by_counterparty`
- `open_items_by_contract`
### Третья волна
- `list_documents_by_contract`
- `account_turnover_snapshot`
- `list_documents_by_type`
- `find_document_by_number_and_date`
## 8) Ключевой управленческий вывод
Для этой задачи нужен не разовый набор “правильных вопросов”, а production corpus:
- intent matrix;
- resolver spec;
- massive paraphrase bank;
- acceptance pack.
Именно это позволяет перестать лечить каждый диалект вручную и перейти к системной доводке покрытия по доменам.

View File

@ -0,0 +1,92 @@
# Production Acceptance Pack V2
Дата: 2026-03-30
## Цель
Довести `address_query` до production-ready состояния на уровне:
- routing;
- intent detection;
- filter extraction;
- resolver robustness;
- factual stability;
- zero false factual.
## Обязательные suite'ы
### A. Routing & Intent Suite
Проверяет:
- mode routing;
- canonical intent resolution;
- detection of unsupported/causal queries;
- no deep-analysis regression.
### B. Dialect Robustness Suite
Проверяет:
- один и тот же intent в 10+ формулировках;
- бухгалтерский жаргон;
- разговорный диалект;
- сокращенные формулировки;
- уточняющие хвосты (`на сегодня`, `за период`, `по поставщику`).
### C. Resolver Robustness Suite
Проверяет:
- контрагент по полному имени;
- контрагент по короткому имени;
- договор по номеру;
- счет в разных форматах;
- тип документа по русскому и системному имени.
### D. Positive Evidence Suite
Проверяет:
- non-empty factual на curated live cases;
- стабильность recipe-level non-empty yield;
- отсутствие false factual.
### E. Negative Safety Suite
Проверяет:
- пустые или неоднозначные случаи возвращают `LIMITED_WITH_REASON`;
- нет молчаливой подмены anchor'а;
- нет ложных документов/сумм.
## Обязательные метрики
- `address_mode_routing_accuracy`
- `address_intent_resolution_rate`
- `dialect_variant_pass_rate`
- `required_filter_resolution_rate`
- `resolver_exact_or_high_confidence_rate`
- `live_call_success_rate`
- `factual_non_empty_rate`
- `false_factual_rate`
- `limited_reason_taxonomy_coverage`
## Целевые пороги
- `address_intent_resolution_rate >= 0.95`
- `dialect_variant_pass_rate >= 0.90`
- `required_filter_resolution_rate >= 0.95`
- `resolver_exact_or_high_confidence_rate >= 0.90`
- `live_call_success_rate >= 0.95`
- `false_factual_rate = 0`
## Минимальный debug contract
- `detected_mode`
- `detected_intent`
- `normalized_question`
- `resolved_filters`
- `missing_filters`
- `selected_recipe`
- resolver block
- MCP flow block
- `response_type`
- `limited_reason_category`
## Правило прод-приемки
Ни один домен не считается production-ready, пока он не прошел:
1. 10+ dialect variants на intent;
2. positive evidence;
3. negative twins;
4. ambiguity handling.

View File

@ -0,0 +1,359 @@
# Query Paraphrase Bank V2 — Address Query
Дата: 2026-03-30
## Назначение
Этот файл нужен для массовой декомпозиции пользовательских запросов в canonical intent'ы. Для каждого intent даны вариации в разных стилях: нейтральный, бухгалтерский, разговорный, сокращенный, операционный.
## AQ-P0-01 — `list_open_contracts`
- domain: `contracts`
- recipe: `address.open_contracts.by_asof`
- required filters: `as_of_date`
- optional filters: `organization, counterparty, limit, sort`
- goal: Показать незакрытые договоры на дату с суммами и контрагентом.
- какие договоры не закрыты на 2020-07-31
- покажи незакрытые договоры на 2020-07-31
- что из договоров висит открытым на 2020-07-31
- есть ли открытые договоры на 2020-07-31
- выгрузи договоры с остатком на 2020-07-31
- по каким договорам на 2020-07-31 еще нет закрытия
- дай список незакрытых договоров на 2020-07-31
- какие договоры остаются открытыми на 2020-07-31
- какие договоры болтаются незакрытыми на 2020-07-31
- покажи договоры, по которым есть хвост на 2020-07-31
- какие договоры в базе не закрыты на 2020-07-31
- что по открытым договорам на 2020-07-31
- сформируй перечень открытых договоров на 2020-07-31
- есть незакрытые договоры по состоянию на 2020-07-31
- какие договоры не схлопнулись на 2020-07-31
- по каким договорам висят остатки на 2020-07-31
- какие договоры не сведены на 2020-07-31
- открой договоры со сальдо на 2020-07-31
## AQ-P0-02 — `list_payables_counterparties`
- domain: `debt`
- recipe: `address.payables.counterparty_totals`
- required filters: `as_of_date`
- optional filters: `organization, account, limit, sort`
- goal: Определить, кому компания должна на дату.
- кому мы должны на 2020-07-31
- по кому у нас кредиторка на 2020-07-31
- покажи задолженность перед контрагентами на 2020-07-31
- какие поставщики ждут оплату на 2020-07-31
- кому должны денег по состоянию на 2020-07-31
- у кого перед нами нет оплаты? нет, наоборот: кому должны мы на 2020-07-31
- выведи кредиторскую задолженность на 2020-07-31
- кому нужно платить на 2020-07-31
- какие долги перед поставщиками висят на 2020-07-31
- контрагенты, которым мы должны на 2020-07-31
- по кому висит наш долг на 2020-07-31
- дай список кредиторов на 2020-07-31
- какая задолженность у нас перед поставщиками на 2020-07-31
- кому организация должна по взаиморасчетам на 2020-07-31
- раскрой кредиторку на 2020-07-31
- покажи кому мы должны денег по базе на 2020-07-31
- у каких контрагентов у нас минус на 2020-07-31
- по каким контрагентам надо закрывать долг на 2020-07-31
## AQ-P0-03 — `list_receivables_counterparties`
- domain: `debt`
- recipe: `address.receivables.counterparty_totals`
- required filters: `as_of_date`
- optional filters: `organization, account, limit, sort`
- goal: Определить, кто должен компании на дату.
- кто должен нам на 2020-07-31
- по кому у нас дебиторка на 2020-07-31
- покажи задолженность контрагентов перед нами на 2020-07-31
- какие покупатели нам должны на 2020-07-31
- кто не оплатил нам на 2020-07-31
- выведи дебиторскую задолженность на 2020-07-31
- кто висит в должниках на 2020-07-31
- какие долги нам торчат на 2020-07-31
- контрагенты, которые должны нам на 2020-07-31
- по кому висит долг нам на 2020-07-31
- дай список должников на 2020-07-31
- какая дебиторка у нас на 2020-07-31
- покажи, кто не закрылся перед нами на 2020-07-31
- кто должен организации по взаиморасчетам на 2020-07-31
- раскрой дебиторку на 2020-07-31
- у каких контрагентов есть долг перед нами на 2020-07-31
- по каким покупателям есть хвост на 2020-07-31
- кого нужно дожимать по оплате на 2020-07-31
## AQ-P0-04 — `account_balance_snapshot`
- domain: `accounts`
- recipe: `address.account.balance_snapshot`
- required filters: `account, as_of_date`
- optional filters: `organization, limit, sort`
- goal: Показать остаток по счету на дату.
- какой остаток по счету 62 на 2020-07-31
- покажи остаток по 62 счету на 2020-07-31
- что висит на счете 62 на 2020-07-31
- дай сальдо по счету 62 на 2020-07-31
- какой хвост на 62 на 2020-07-31
- какой остаток висит по 62 на 2020-07-31
- сколько на 62 счете на 2020-07-31
- сформируй остаток по счету 62 по состоянию на 2020-07-31
- раскрой остаток по счету 62 на 2020-07-31
- покажи сальдо по 62 на 2020-07-31
- что осталось на 62 счете на 2020-07-31
- по счету 62 какой баланс на 2020-07-31
- какой остаток по бухсчету 62 на 2020-07-31
- на 2020-07-31 сколько висит на счете 62
- остаток по плану счетов 62 на 2020-07-31
- какое сальдо по 62 по базе на 2020-07-31
- дай остаток по счету 62 с учетом проводок на 2020-07-31
- что по остатку счета 62 на 2020-07-31
## AQ-P0-05 — `open_items_by_counterparty`
- domain: `open_items`
- recipe: `address.open_items.by_counterparty`
- required filters: `counterparty, as_of_date`
- optional filters: `organization, contract, account, limit`
- goal: Показать хвосты / открытые позиции по контрагенту.
- какие хвосты висят по контрагенту ООО Альфа на 2020-07-31
- что не закрыто по ООО Альфа на 2020-07-31
- покажи открытые позиции по контрагенту ООО Альфа на 2020-07-31
- какие незакрытые взаиморасчеты по ООО Альфа на 2020-07-31
- какие долги/хвосты по ООО Альфа на 2020-07-31
- что висит по контрагенту ООО Альфа на 2020-07-31
- раскрой висяки по ООО Альфа на 2020-07-31
- покажи незакрытое по ООО Альфа на 2020-07-31
- какие остатки по взаиморасчетам с ООО Альфа на 2020-07-31
- есть ли хвосты по ООО Альфа на 2020-07-31
- по ООО Альфа что осталось открытым на 2020-07-31
- дай открытые документы/позиции по ООО Альфа на 2020-07-31
- покажи висящие суммы по ООО Альфа на 2020-07-31
- что не схлопнулось по контрагенту ООО Альфа на 2020-07-31
- по поставщику ООО Альфа какие хвосты на 2020-07-31
- по клиенту ООО Альфа что не закрыто на 2020-07-31
- взаиморасчеты с ООО Альфа: что висит на 2020-07-31
- покажи остатки по контрагенту ООО Альфа на 2020-07-31
## AQ-P0-06 — `open_items_by_contract`
- domain: `open_items`
- recipe: `address.open_items.by_contract`
- required filters: `contract, as_of_date`
- optional filters: `organization, counterparty, account, limit`
- goal: Показать хвосты / открытые позиции по договору.
- какие хвосты висят по договору 15/24 на 2020-07-31
- что не закрыто по договору 15/24 на 2020-07-31
- покажи открытые позиции по договору 15/24 на 2020-07-31
- какие незакрытые взаиморасчеты по договору 15/24 на 2020-07-31
- что висит по договору 15/24 на 2020-07-31
- раскрой висяки по договору 15/24 на 2020-07-31
- покажи незакрытое по договору 15/24 на 2020-07-31
- какие остатки по договору 15/24 на 2020-07-31
- есть ли хвосты по договору 15/24 на 2020-07-31
- по договору 15/24 что осталось открытым на 2020-07-31
- дай открытые документы/позиции по договору 15/24 на 2020-07-31
- покажи висящие суммы по договору 15/24 на 2020-07-31
- что не схлопнулось по договору 15/24 на 2020-07-31
- по договору 15/24 какие хвосты на 2020-07-31
- взаиморасчеты по договору 15/24: что висит на 2020-07-31
- покажи остатки по договору 15/24 на 2020-07-31
- какие позиции не закрыты по договору 15/24 на 2020-07-31
- дай хвосты по договору 15/24 по состоянию на 2020-07-31
## AQ-P0-07 — `list_documents_by_counterparty`
- domain: `documents`
- recipe: `address.documents.by_counterparty`
- required filters: `counterparty, period_from, period_to`
- optional filters: `document_type, organization, limit, sort`
- goal: Показать документы по контрагенту за период.
- покажи документы по контрагенту ООО Альфа за период 2020-07-01..2020-07-31
- дай документы по ООО Альфа за 2020-07-01..2020-07-31
- какие документы были по контрагенту ООО Альфа за 2020-07-01..2020-07-31
- выгрузи документы по ООО Альфа за 2020-07-01..2020-07-31
- покажи движения документов по ООО Альфа за 2020-07-01..2020-07-31
- что за документы проходили по ООО Альфа за 2020-07-01..2020-07-31
- дай первичку по ООО Альфа за 2020-07-01..2020-07-31
- какие документы по поставщику ООО Альфа за 2020-07-01..2020-07-31
- какие документы по клиенту ООО Альфа за 2020-07-01..2020-07-31
- найди документы по контре ООО Альфа за 2020-07-01..2020-07-31
- список документов по ООО Альфа за 2020-07-01..2020-07-31
- покажи все документы по ООО Альфа за 2020-07-01..2020-07-31
- по контрагенту ООО Альфа какие документы были в 2020-07-01..2020-07-31
- по ООО Альфа дай документы за 2020-07-01..2020-07-31
- какие проведенные документы по ООО Альфа за 2020-07-01..2020-07-31
- по поставщику ООО Альфа покажи документы за 2020-07-01..2020-07-31
- по контрагенту ООО Альфа нужна выгрузка документов за 2020-07-01..2020-07-31
- документальный след по ООО Альфа за 2020-07-01..2020-07-31
## AQ-P0-07B — `bank_operations_by_counterparty`
- domain: `bank_ops`
- recipe: `address.bank_ops.by_counterparty`
- required filters: `counterparty`
- optional filters: `period_from, period_to, organization, limit, sort`
- goal: Показать банковские операции по контрагенту.
- покажи банковские операции по контрагенту ООО Альфа за 2020-07-01..2020-07-31
- какие платежи были по ООО Альфа за 2020-07-01..2020-07-31
- были ли оплаты по ООО Альфа за 2020-07-01..2020-07-31
- дай банк по контрагенту ООО Альфа за 2020-07-01..2020-07-31
- покажи движения по банку с ООО Альфа за 2020-07-01..2020-07-31
- какие списания/поступления были по ООО Альфа за 2020-07-01..2020-07-31
- выгрузи банковские документы по ООО Альфа за 2020-07-01..2020-07-31
- покажи платежки по ООО Альфа за 2020-07-01..2020-07-31
- что проходило по расчетному счету с ООО Альфа за 2020-07-01..2020-07-31
- какие оплаты поставщику ООО Альфа были за 2020-07-01..2020-07-31
- какие поступления от ООО Альфа были за 2020-07-01..2020-07-31
- найди банковские операции по контре ООО Альфа за 2020-07-01..2020-07-31
- список оплат по ООО Альфа за 2020-07-01..2020-07-31
- покажи банк по ООО Альфа за 2020-07-01..2020-07-31
- по контрагенту ООО Альфа какие платежи шли в 2020-07-01..2020-07-31
- по ООО Альфа нужна банковская выборка за 2020-07-01..2020-07-31
- были ли платежные движения по ООО Альфа за 2020-07-01..2020-07-31
- все банковские проходы по ООО Альфа за 2020-07-01..2020-07-31
## AQ-P0-08 — `list_documents_by_contract`
- domain: `documents`
- recipe: `address.documents.by_contract`
- required filters: `contract, period_from, period_to`
- optional filters: `document_type, organization, limit, sort`
- goal: Показать документы по договору за период.
- покажи документы по договору 15/24 за период 2020-07-01..2020-07-31
- дай документы по договору 15/24 за 2020-07-01..2020-07-31
- какие документы были по договору 15/24 за 2020-07-01..2020-07-31
- выгрузи документы по договору 15/24 за 2020-07-01..2020-07-31
- что за документы проходили по договору 15/24 за 2020-07-01..2020-07-31
- дай первичку по договору 15/24 за 2020-07-01..2020-07-31
- список документов по договору 15/24 за 2020-07-01..2020-07-31
- покажи все документы по договору 15/24 за 2020-07-01..2020-07-31
- по договору 15/24 какие документы были в 2020-07-01..2020-07-31
- по 15/24 дай документы за 2020-07-01..2020-07-31
- какие проведенные документы по договору 15/24 за 2020-07-01..2020-07-31
- по договору 15/24 покажи документы за 2020-07-01..2020-07-31
- по договору 15/24 нужна выгрузка документов за 2020-07-01..2020-07-31
- документальный след по договору 15/24 за 2020-07-01..2020-07-31
- есть ли документы по договору 15/24 за 2020-07-01..2020-07-31
- покажи движения документов по договору 15/24 за 2020-07-01..2020-07-31
- что оформляли по договору 15/24 за 2020-07-01..2020-07-31
- все документы, связанные с договором 15/24, за 2020-07-01..2020-07-31
## AQ-P0-09 — `documents_forming_balance`
- domain: `drilldown`
- recipe: `address.balance.drilldown_documents`
- required filters: `account, as_of_date`
- optional filters: `organization, counterparty, contract, limit`
- goal: Раскрыть, какими документами сформирован остаток по счету.
- какие документы формируют остаток по счету 62 на 2020-07-31
- расшифруй остаток по счету 62 на 2020-07-31 по документам
- из каких документов сложился остаток по 62 на 2020-07-31
- покажи документы, которые дали остаток по счету 62 на 2020-07-31
- чем сформирован остаток по 62 на 2020-07-31
- какие документы висят в остатке по 62 на 2020-07-31
- раскрой остаток по 62 на 2020-07-31 до документов
- дай документную расшифровку остатка по счету 62 на 2020-07-31
- по счету 62 какие документы формируют сальдо на 2020-07-31
- покажи первичку, которая сидит в остатке 62 на 2020-07-31
- что конкретно образует остаток по 62 на 2020-07-31
- какие регистраторы формируют остаток счета 62 на 2020-07-31
- разложи остаток по счету 62 на 2020-07-31 на документы
- по 62 на 2020-07-31 нужен drilldown до документов
- остаток счета 62 на 2020-07-31: из чего он состоит
- какие документы попали в хвост по счету 62 на 2020-07-31
- раскрой сальдо 62 на 2020-07-31 по документам
- что формирует баланс по счету 62 на 2020-07-31
## AQ-P1-10 — `account_turnover_snapshot`
- domain: `accounts`
- recipe: `address.account.turnover_snapshot`
- required filters: `account, period_from, period_to`
- optional filters: `organization, counterparty, limit`
- goal: Показать обороты по счету за период.
- дай обороты по счету 62 за 2020-07-01..2020-07-31
- покажи движение по счету 62 за 2020-07-01..2020-07-31
- какие обороты по 62 были за 2020-07-01..2020-07-31
- выведи дебет/кредит обороты по счету 62 за 2020-07-01..2020-07-31
- сформируй оборотку по счету 62 за 2020-07-01..2020-07-31
- по счету 62 какие обороты в 2020-07-01..2020-07-31
- дай обороты по бухсчету 62 за 2020-07-01..2020-07-31
- что прошло по счету 62 за 2020-07-01..2020-07-31
- обороты по 62 счету за 2020-07-01..2020-07-31
- покажи обороты счета 62 за 2020-07-01..2020-07-31
- какое движение по 62 за 2020-07-01..2020-07-31
- дай сумму оборотов по 62 за 2020-07-01..2020-07-31
- по 62 раскрой дебетовые и кредитовые обороты за 2020-07-01..2020-07-31
- какие суммы крутились по счету 62 за 2020-07-01..2020-07-31
- покажи оборот по плану счетов 62 за 2020-07-01..2020-07-31
- счет 62: движение за 2020-07-01..2020-07-31
- что было по дебету/кредиту счета 62 за 2020-07-01..2020-07-31
- нужны обороты по 62 за 2020-07-01..2020-07-31
## AQ-P1-11 — `list_documents_by_type`
- domain: `documents`
- recipe: `address.documents.by_type`
- required filters: `document_type, period_from, period_to`
- optional filters: `organization, counterparty, contract, limit`
- goal: Показать документы определенного вида за период.
- дай документы типа СписаниеСРасчетногоСчета за 2020-07-01..2020-07-31
- покажи документы вида СписаниеСРасчетногоСчета за 2020-07-01..2020-07-31
- какие документы СписаниеСРасчетногоСчета есть за 2020-07-01..2020-07-31
- выгрузи документы типа СписаниеСРасчетногоСчета за 2020-07-01..2020-07-31
- список документов СписаниеСРасчетногоСчета за 2020-07-01..2020-07-31
- покажи все СписаниеСРасчетногоСчета за 2020-07-01..2020-07-31
- какие проведенные документы типа СписаниеСРасчетногоСчета были за 2020-07-01..2020-07-31
- документы вида СписаниеСРасчетногоСчета в 2020-07-01..2020-07-31
- по типу СписаниеСРасчетногоСчета покажи документы за 2020-07-01..2020-07-31
- что есть по документам типа СписаниеСРасчетногоСчета за 2020-07-01..2020-07-31
- дай выборку по СписаниеСРасчетногоСчета за 2020-07-01..2020-07-31
- покажи реестр СписаниеСРасчетногоСчета за 2020-07-01..2020-07-31
- по виду документа СписаниеСРасчетногоСчета нужна выгрузка за 2020-07-01..2020-07-31
- какие экземпляры СписаниеСРасчетногоСчета проходили за 2020-07-01..2020-07-31
- документы 1С типа СписаниеСРасчетногоСчета за 2020-07-01..2020-07-31
- найди документы СписаниеСРасчетногоСчета за 2020-07-01..2020-07-31
- выведи журнал по СписаниеСРасчетногоСчета за 2020-07-01..2020-07-31
- какие документы этого типа были оформлены за 2020-07-01..2020-07-31
## AQ-P1-12 — `find_document_by_number_and_date`
- domain: `document_lookup`
- recipe: `address.document.lookup_by_number_date`
- required filters: `document_number, document_date`
- optional filters: `document_type, organization, counterparty`
- goal: Найти конкретный документ по номеру и дате.
- найди документ № 000123 от 2020-07-15
- покажи документ номер 000123 от 2020-07-15
- есть ли документ 000123 от 2020-07-15
- отыщи документ № 000123 датой 2020-07-15
- нужен документ 000123 от 2020-07-15
- в базе есть документ 000123 от 2020-07-15
- подними документ номер 000123 от 2020-07-15
- покажи, что за документ 000123 от 2020-07-15
- дай карточку документа 000123 от 2020-07-15
- ищу документ 000123 от 2020-07-15
- найди по номеру 000123 и дате 2020-07-15
- проверь наличие документа 000123 от 2020-07-15
- покажи запись по документу 000123 от 2020-07-15
- где документ 000123 от 2020-07-15
- какой документ проходит под номером 000123 от 2020-07-15
- вытащи документ 000123 от 2020-07-15
- документ номер 000123, дата 2020-07-15
- дай документ по реквизитам: номер 000123, дата 2020-07-15

View File

@ -0,0 +1,487 @@
{
"generated_at": "2026-03-30",
"package": "address_query_prod_pack_v2",
"total_intents": 13,
"total_questions": 234,
"items": [
{
"scenario_id": "AQ-P0-01",
"domain": "contracts",
"intent": "list_open_contracts",
"recipe_id": "address.open_contracts.by_asof",
"required_filters": [
"as_of_date"
],
"optional_filters": [
"organization",
"counterparty",
"limit",
"sort"
],
"expected_response_type": "FACTUAL_LIST",
"questions": [
"какие договоры не закрыты на 2020-07-31",
"покажи незакрытые договоры на 2020-07-31",
"что из договоров висит открытым на 2020-07-31",
"есть ли открытые договоры на 2020-07-31",
"выгрузи договоры с остатком на 2020-07-31",
"по каким договорам на 2020-07-31 еще нет закрытия",
"дай список незакрытых договоров на 2020-07-31",
"какие договоры остаются открытыми на 2020-07-31",
"какие договоры болтаются незакрытыми на 2020-07-31",
"покажи договоры, по которым есть хвост на 2020-07-31",
"какие договоры в базе не закрыты на 2020-07-31",
"что по открытым договорам на 2020-07-31",
"сформируй перечень открытых договоров на 2020-07-31",
"есть незакрытые договоры по состоянию на 2020-07-31",
"какие договоры не схлопнулись на 2020-07-31",
"по каким договорам висят остатки на 2020-07-31",
"какие договоры не сведены на 2020-07-31",
"открой договоры со сальдо на 2020-07-31"
]
},
{
"scenario_id": "AQ-P0-02",
"domain": "debt",
"intent": "list_payables_counterparties",
"recipe_id": "address.payables.counterparty_totals",
"required_filters": [
"as_of_date"
],
"optional_filters": [
"organization",
"account",
"limit",
"sort"
],
"expected_response_type": "FACTUAL_SUMMARY",
"questions": [
"кому мы должны на 2020-07-31",
"по кому у нас кредиторка на 2020-07-31",
"покажи задолженность перед контрагентами на 2020-07-31",
"какие поставщики ждут оплату на 2020-07-31",
"кому должны денег по состоянию на 2020-07-31",
"у кого перед нами нет оплаты? нет, наоборот: кому должны мы на 2020-07-31",
"выведи кредиторскую задолженность на 2020-07-31",
"кому нужно платить на 2020-07-31",
"какие долги перед поставщиками висят на 2020-07-31",
"контрагенты, которым мы должны на 2020-07-31",
"по кому висит наш долг на 2020-07-31",
"дай список кредиторов на 2020-07-31",
"какая задолженность у нас перед поставщиками на 2020-07-31",
"кому организация должна по взаиморасчетам на 2020-07-31",
"раскрой кредиторку на 2020-07-31",
"покажи кому мы должны денег по базе на 2020-07-31",
"у каких контрагентов у нас минус на 2020-07-31",
"по каким контрагентам надо закрывать долг на 2020-07-31"
]
},
{
"scenario_id": "AQ-P0-03",
"domain": "debt",
"intent": "list_receivables_counterparties",
"recipe_id": "address.receivables.counterparty_totals",
"required_filters": [
"as_of_date"
],
"optional_filters": [
"organization",
"account",
"limit",
"sort"
],
"expected_response_type": "FACTUAL_SUMMARY",
"questions": [
"кто должен нам на 2020-07-31",
"по кому у нас дебиторка на 2020-07-31",
"покажи задолженность контрагентов перед нами на 2020-07-31",
"какие покупатели нам должны на 2020-07-31",
"кто не оплатил нам на 2020-07-31",
"выведи дебиторскую задолженность на 2020-07-31",
"кто висит в должниках на 2020-07-31",
"какие долги нам торчат на 2020-07-31",
"контрагенты, которые должны нам на 2020-07-31",
"по кому висит долг нам на 2020-07-31",
"дай список должников на 2020-07-31",
"какая дебиторка у нас на 2020-07-31",
"покажи, кто не закрылся перед нами на 2020-07-31",
"кто должен организации по взаиморасчетам на 2020-07-31",
"раскрой дебиторку на 2020-07-31",
"у каких контрагентов есть долг перед нами на 2020-07-31",
"по каким покупателям есть хвост на 2020-07-31",
"кого нужно дожимать по оплате на 2020-07-31"
]
},
{
"scenario_id": "AQ-P0-04",
"domain": "accounts",
"intent": "account_balance_snapshot",
"recipe_id": "address.account.balance_snapshot",
"required_filters": [
"account",
"as_of_date"
],
"optional_filters": [
"organization",
"limit",
"sort"
],
"expected_response_type": "FACTUAL_SUMMARY",
"questions": [
"какой остаток по счету 62 на 2020-07-31",
"покажи остаток по 62 счету на 2020-07-31",
"что висит на счете 62 на 2020-07-31",
"дай сальдо по счету 62 на 2020-07-31",
"какой хвост на 62 на 2020-07-31",
"какой остаток висит по 62 на 2020-07-31",
"сколько на 62 счете на 2020-07-31",
"сформируй остаток по счету 62 по состоянию на 2020-07-31",
"раскрой остаток по счету 62 на 2020-07-31",
"покажи сальдо по 62 на 2020-07-31",
"что осталось на 62 счете на 2020-07-31",
"по счету 62 какой баланс на 2020-07-31",
"какой остаток по бухсчету 62 на 2020-07-31",
"на 2020-07-31 сколько висит на счете 62",
"остаток по плану счетов 62 на 2020-07-31",
"какое сальдо по 62 по базе на 2020-07-31",
"дай остаток по счету 62 с учетом проводок на 2020-07-31",
"что по остатку счета 62 на 2020-07-31"
]
},
{
"scenario_id": "AQ-P0-05",
"domain": "open_items",
"intent": "open_items_by_counterparty",
"recipe_id": "address.open_items.by_counterparty",
"required_filters": [
"counterparty",
"as_of_date"
],
"optional_filters": [
"organization",
"contract",
"account",
"limit"
],
"expected_response_type": "FACTUAL_LIST",
"questions": [
"какие хвосты висят по контрагенту ООО Альфа на 2020-07-31",
"что не закрыто по ООО Альфа на 2020-07-31",
"покажи открытые позиции по контрагенту ООО Альфа на 2020-07-31",
"какие незакрытые взаиморасчеты по ООО Альфа на 2020-07-31",
"какие долги/хвосты по ООО Альфа на 2020-07-31",
"что висит по контрагенту ООО Альфа на 2020-07-31",
"раскрой висяки по ООО Альфа на 2020-07-31",
"покажи незакрытое по ООО Альфа на 2020-07-31",
"какие остатки по взаиморасчетам с ООО Альфа на 2020-07-31",
"есть ли хвосты по ООО Альфа на 2020-07-31",
"по ООО Альфа что осталось открытым на 2020-07-31",
"дай открытые документы/позиции по ООО Альфа на 2020-07-31",
"покажи висящие суммы по ООО Альфа на 2020-07-31",
"что не схлопнулось по контрагенту ООО Альфа на 2020-07-31",
"по поставщику ООО Альфа какие хвосты на 2020-07-31",
"по клиенту ООО Альфа что не закрыто на 2020-07-31",
"взаиморасчеты с ООО Альфа: что висит на 2020-07-31",
"покажи остатки по контрагенту ООО Альфа на 2020-07-31"
]
},
{
"scenario_id": "AQ-P0-06",
"domain": "open_items",
"intent": "open_items_by_contract",
"recipe_id": "address.open_items.by_contract",
"required_filters": [
"contract",
"as_of_date"
],
"optional_filters": [
"organization",
"counterparty",
"account",
"limit"
],
"expected_response_type": "FACTUAL_LIST",
"questions": [
"какие хвосты висят по договору 15/24 на 2020-07-31",
"что не закрыто по договору 15/24 на 2020-07-31",
"покажи открытые позиции по договору 15/24 на 2020-07-31",
"какие незакрытые взаиморасчеты по договору 15/24 на 2020-07-31",
"что висит по договору 15/24 на 2020-07-31",
"раскрой висяки по договору 15/24 на 2020-07-31",
"покажи незакрытое по договору 15/24 на 2020-07-31",
"какие остатки по договору 15/24 на 2020-07-31",
"есть ли хвосты по договору 15/24 на 2020-07-31",
"по договору 15/24 что осталось открытым на 2020-07-31",
"дай открытые документы/позиции по договору 15/24 на 2020-07-31",
"покажи висящие суммы по договору 15/24 на 2020-07-31",
"что не схлопнулось по договору 15/24 на 2020-07-31",
"по договору 15/24 какие хвосты на 2020-07-31",
"взаиморасчеты по договору 15/24: что висит на 2020-07-31",
"покажи остатки по договору 15/24 на 2020-07-31",
"какие позиции не закрыты по договору 15/24 на 2020-07-31",
"дай хвосты по договору 15/24 по состоянию на 2020-07-31"
]
},
{
"scenario_id": "AQ-P0-07",
"domain": "documents",
"intent": "list_documents_by_counterparty",
"recipe_id": "address.documents.by_counterparty",
"required_filters": [
"counterparty",
"period_from",
"period_to"
],
"optional_filters": [
"document_type",
"organization",
"limit",
"sort"
],
"expected_response_type": "FACTUAL_LIST",
"questions": [
"покажи документы по контрагенту ООО Альфа за период 2020-07-01..2020-07-31",
"дай документы по ООО Альфа за 2020-07-01..2020-07-31",
"какие документы были по контрагенту ООО Альфа за 2020-07-01..2020-07-31",
"выгрузи документы по ООО Альфа за 2020-07-01..2020-07-31",
"покажи движения документов по ООО Альфа за 2020-07-01..2020-07-31",
"что за документы проходили по ООО Альфа за 2020-07-01..2020-07-31",
"дай первичку по ООО Альфа за 2020-07-01..2020-07-31",
"какие документы по поставщику ООО Альфа за 2020-07-01..2020-07-31",
"какие документы по клиенту ООО Альфа за 2020-07-01..2020-07-31",
"найди документы по контре ООО Альфа за 2020-07-01..2020-07-31",
"список документов по ООО Альфа за 2020-07-01..2020-07-31",
"покажи все документы по ООО Альфа за 2020-07-01..2020-07-31",
"по контрагенту ООО Альфа какие документы были в 2020-07-01..2020-07-31",
"по ООО Альфа дай документы за 2020-07-01..2020-07-31",
"какие проведенные документы по ООО Альфа за 2020-07-01..2020-07-31",
"по поставщику ООО Альфа покажи документы за 2020-07-01..2020-07-31",
"по контрагенту ООО Альфа нужна выгрузка документов за 2020-07-01..2020-07-31",
"документальный след по ООО Альфа за 2020-07-01..2020-07-31"
]
},
{
"scenario_id": "AQ-P0-07B",
"domain": "bank_ops",
"intent": "bank_operations_by_counterparty",
"recipe_id": "address.bank_ops.by_counterparty",
"required_filters": [
"counterparty"
],
"optional_filters": [
"period_from",
"period_to",
"organization",
"limit",
"sort"
],
"expected_response_type": "FACTUAL_LIST",
"questions": [
"покажи банковские операции по контрагенту ООО Альфа за 2020-07-01..2020-07-31",
"какие платежи были по ООО Альфа за 2020-07-01..2020-07-31",
"были ли оплаты по ООО Альфа за 2020-07-01..2020-07-31",
"дай банк по контрагенту ООО Альфа за 2020-07-01..2020-07-31",
"покажи движения по банку с ООО Альфа за 2020-07-01..2020-07-31",
"какие списания/поступления были по ООО Альфа за 2020-07-01..2020-07-31",
"выгрузи банковские документы по ООО Альфа за 2020-07-01..2020-07-31",
"покажи платежки по ООО Альфа за 2020-07-01..2020-07-31",
"что проходило по расчетному счету с ООО Альфа за 2020-07-01..2020-07-31",
"какие оплаты поставщику ООО Альфа были за 2020-07-01..2020-07-31",
"какие поступления от ООО Альфа были за 2020-07-01..2020-07-31",
"найди банковские операции по контре ООО Альфа за 2020-07-01..2020-07-31",
"список оплат по ООО Альфа за 2020-07-01..2020-07-31",
"покажи банк по ООО Альфа за 2020-07-01..2020-07-31",
"по контрагенту ООО Альфа какие платежи шли в 2020-07-01..2020-07-31",
"по ООО Альфа нужна банковская выборка за 2020-07-01..2020-07-31",
"были ли платежные движения по ООО Альфа за 2020-07-01..2020-07-31",
"все банковские проходы по ООО Альфа за 2020-07-01..2020-07-31"
]
},
{
"scenario_id": "AQ-P0-08",
"domain": "documents",
"intent": "list_documents_by_contract",
"recipe_id": "address.documents.by_contract",
"required_filters": [
"contract",
"period_from",
"period_to"
],
"optional_filters": [
"document_type",
"organization",
"limit",
"sort"
],
"expected_response_type": "FACTUAL_LIST",
"questions": [
"покажи документы по договору 15/24 за период 2020-07-01..2020-07-31",
"дай документы по договору 15/24 за 2020-07-01..2020-07-31",
"какие документы были по договору 15/24 за 2020-07-01..2020-07-31",
"выгрузи документы по договору 15/24 за 2020-07-01..2020-07-31",
"что за документы проходили по договору 15/24 за 2020-07-01..2020-07-31",
"дай первичку по договору 15/24 за 2020-07-01..2020-07-31",
"список документов по договору 15/24 за 2020-07-01..2020-07-31",
"покажи все документы по договору 15/24 за 2020-07-01..2020-07-31",
"по договору 15/24 какие документы были в 2020-07-01..2020-07-31",
"по 15/24 дай документы за 2020-07-01..2020-07-31",
"какие проведенные документы по договору 15/24 за 2020-07-01..2020-07-31",
"по договору 15/24 покажи документы за 2020-07-01..2020-07-31",
"по договору 15/24 нужна выгрузка документов за 2020-07-01..2020-07-31",
"документальный след по договору 15/24 за 2020-07-01..2020-07-31",
"есть ли документы по договору 15/24 за 2020-07-01..2020-07-31",
"покажи движения документов по договору 15/24 за 2020-07-01..2020-07-31",
"что оформляли по договору 15/24 за 2020-07-01..2020-07-31",
"все документы, связанные с договором 15/24, за 2020-07-01..2020-07-31"
]
},
{
"scenario_id": "AQ-P0-09",
"domain": "drilldown",
"intent": "documents_forming_balance",
"recipe_id": "address.balance.drilldown_documents",
"required_filters": [
"account",
"as_of_date"
],
"optional_filters": [
"organization",
"counterparty",
"contract",
"limit"
],
"expected_response_type": "FACTUAL_LIST",
"questions": [
"какие документы формируют остаток по счету 62 на 2020-07-31",
"расшифруй остаток по счету 62 на 2020-07-31 по документам",
"из каких документов сложился остаток по 62 на 2020-07-31",
"покажи документы, которые дали остаток по счету 62 на 2020-07-31",
"чем сформирован остаток по 62 на 2020-07-31",
"какие документы висят в остатке по 62 на 2020-07-31",
"раскрой остаток по 62 на 2020-07-31 до документов",
"дай документную расшифровку остатка по счету 62 на 2020-07-31",
"по счету 62 какие документы формируют сальдо на 2020-07-31",
"покажи первичку, которая сидит в остатке 62 на 2020-07-31",
"что конкретно образует остаток по 62 на 2020-07-31",
"какие регистраторы формируют остаток счета 62 на 2020-07-31",
"разложи остаток по счету 62 на 2020-07-31 на документы",
"по 62 на 2020-07-31 нужен drilldown до документов",
"остаток счета 62 на 2020-07-31: из чего он состоит",
"какие документы попали в хвост по счету 62 на 2020-07-31",
"раскрой сальдо 62 на 2020-07-31 по документам",
"что формирует баланс по счету 62 на 2020-07-31"
]
},
{
"scenario_id": "AQ-P1-10",
"domain": "accounts",
"intent": "account_turnover_snapshot",
"recipe_id": "address.account.turnover_snapshot",
"required_filters": [
"account",
"period_from",
"period_to"
],
"optional_filters": [
"organization",
"counterparty",
"limit"
],
"expected_response_type": "FACTUAL_SUMMARY",
"questions": [
"дай обороты по счету 62 за 2020-07-01..2020-07-31",
"покажи движение по счету 62 за 2020-07-01..2020-07-31",
"какие обороты по 62 были за 2020-07-01..2020-07-31",
"выведи дебет/кредит обороты по счету 62 за 2020-07-01..2020-07-31",
"сформируй оборотку по счету 62 за 2020-07-01..2020-07-31",
"по счету 62 какие обороты в 2020-07-01..2020-07-31",
"дай обороты по бухсчету 62 за 2020-07-01..2020-07-31",
"что прошло по счету 62 за 2020-07-01..2020-07-31",
"обороты по 62 счету за 2020-07-01..2020-07-31",
"покажи обороты счета 62 за 2020-07-01..2020-07-31",
"какое движение по 62 за 2020-07-01..2020-07-31",
"дай сумму оборотов по 62 за 2020-07-01..2020-07-31",
"по 62 раскрой дебетовые и кредитовые обороты за 2020-07-01..2020-07-31",
"какие суммы крутились по счету 62 за 2020-07-01..2020-07-31",
"покажи оборот по плану счетов 62 за 2020-07-01..2020-07-31",
"счет 62: движение за 2020-07-01..2020-07-31",
"что было по дебету/кредиту счета 62 за 2020-07-01..2020-07-31",
"нужны обороты по 62 за 2020-07-01..2020-07-31"
]
},
{
"scenario_id": "AQ-P1-11",
"domain": "documents",
"intent": "list_documents_by_type",
"recipe_id": "address.documents.by_type",
"required_filters": [
"document_type",
"period_from",
"period_to"
],
"optional_filters": [
"organization",
"counterparty",
"contract",
"limit"
],
"expected_response_type": "FACTUAL_LIST",
"questions": [
"дай документы типа СписаниеСРасчетногоСчета за 2020-07-01..2020-07-31",
"покажи документы вида СписаниеСРасчетногоСчета за 2020-07-01..2020-07-31",
"какие документы СписаниеСРасчетногоСчета есть за 2020-07-01..2020-07-31",
"выгрузи документы типа СписаниеСРасчетногоСчета за 2020-07-01..2020-07-31",
"список документов СписаниеСРасчетногоСчета за 2020-07-01..2020-07-31",
"покажи все СписаниеСРасчетногоСчета за 2020-07-01..2020-07-31",
"какие проведенные документы типа СписаниеСРасчетногоСчета были за 2020-07-01..2020-07-31",
"документы вида СписаниеСРасчетногоСчета в 2020-07-01..2020-07-31",
"по типу СписаниеСРасчетногоСчета покажи документы за 2020-07-01..2020-07-31",
"что есть по документам типа СписаниеСРасчетногоСчета за 2020-07-01..2020-07-31",
"дай выборку по СписаниеСРасчетногоСчета за 2020-07-01..2020-07-31",
"покажи реестр СписаниеСРасчетногоСчета за 2020-07-01..2020-07-31",
"по виду документа СписаниеСРасчетногоСчета нужна выгрузка за 2020-07-01..2020-07-31",
"какие экземпляры СписаниеСРасчетногоСчета проходили за 2020-07-01..2020-07-31",
"документы 1С типа СписаниеСРасчетногоСчета за 2020-07-01..2020-07-31",
"найди документы СписаниеСРасчетногоСчета за 2020-07-01..2020-07-31",
"выведи журнал по СписаниеСРасчетногоСчета за 2020-07-01..2020-07-31",
"какие документы этого типа были оформлены за 2020-07-01..2020-07-31"
]
},
{
"scenario_id": "AQ-P1-12",
"domain": "document_lookup",
"intent": "find_document_by_number_and_date",
"recipe_id": "address.document.lookup_by_number_date",
"required_filters": [
"document_number",
"document_date"
],
"optional_filters": [
"document_type",
"organization",
"counterparty"
],
"expected_response_type": "FACTUAL_LIST",
"questions": [
"найди документ № 000123 от 2020-07-15",
"покажи документ номер 000123 от 2020-07-15",
"есть ли документ 000123 от 2020-07-15",
"отыщи документ № 000123 датой 2020-07-15",
"нужен документ 000123 от 2020-07-15",
"в базе есть документ 000123 от 2020-07-15",
"подними документ номер 000123 от 2020-07-15",
"покажи, что за документ 000123 от 2020-07-15",
"дай карточку документа 000123 от 2020-07-15",
"ищу документ 000123 от 2020-07-15",
"найди по номеру 000123 и дате 2020-07-15",
"проверь наличие документа 000123 от 2020-07-15",
"покажи запись по документу 000123 от 2020-07-15",
"где документ 000123 от 2020-07-15",
"какой документ проходит под номером 000123 от 2020-07-15",
"вытащи документ 000123 от 2020-07-15",
"документ номер 000123, дата 2020-07-15",
"дай документ по реквизитам: номер 000123, дата 2020-07-15"
]
}
]
}

View File

@ -0,0 +1,118 @@
# Resolver & Normalization Spec V2
Дата: 2026-03-30
## 1) Общий принцип
LLM/normalizer не пытается сразу вызвать recipe.
Сначала он переводит пользовательскую фразу в canonical intent и нормализованные фильтры.
Цепочка:
`raw_question -> dialect normalization -> intent detection -> filter extraction -> resolver -> recipe selection`
## 2) Синонимы доменных сигналов
### Контрагент
Сигналы:
- контрагент
- поставщик
- клиент
- покупатель
- контра
- организация-партнер
### Договор
Сигналы:
- договор
- контракт
- соглашение
- договорник
- договор с номером
### Остаток / сальдо
Сигналы:
- остаток
- сальдо
- что висит
- хвост
- остаток висит
- баланс по счету
### Дебиторка / кредиторка
Дебиторка:
- кто должен нам
- дебиторка
- долг нам
- задолженность перед нами
Кредиторка:
- кому должны мы
- кредиторка
- наш долг поставщику
- задолженность перед контрагентом
### Банковый контур
Сигналы:
- банк
- платежи
- платежки
- списания
- поступления
- движение по расчетному счету
## 3) Нормализация счетов
Допустимые формы:
- `60`
- `62`
- `60.01`
- `62.01`
Правила:
- удалить лишние слова (`счет`, `счёт`, `бухсчет`, `по счету`);
- сохранить только валидный account token;
- хранить и raw, и normalized форму в debug.
## 4) Нормализация дат и периодов
Поддержать:
- `на сегодня`
- `на текущую дату`
- `на конец месяца`
- `за июль 2020`
- `за период с 01.07.2020 по 31.07.2020`
- `за месяц`
- `за квартал`
Выход:
- `as_of_date`
- либо `period_from + period_to`
## 5) Resolver policy
### Counterparty resolver
- `name -> candidate set`
- exact match > normalized exact > startswith > fuzzy
- если ambiguity_count > 1 и нет явного приоритета, вернуть `LIMITED_WITH_REASON`
### Contract resolver
- поддерживать номер договора, имя договора, пару `контрагент + договор`
- при наличии обоих якорей резолвить договор в контексте контрагента
### Document type resolver
- белый список русских имен и системных 1С-имен:
- `СписаниеСРасчетногоСчета`
- `ПоступлениеНаРасчетныйСчет`
- и др. по фактическому whitelist
## 6) Guardrails
- не допускать свободную генерацию SQL/1С-запросов;
- не подменять отсутствующий фильтр догадкой;
- не заменять by-contract на by-counterparty молча;
- не отдавать пустой factual как будто это найденный результат.

View File

@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.ARCH_EXPORT_2020_DIR = exports.SCHEMAS_DIR = exports.EVAL_DATASETS_DIR = exports.REPORTS_DIR = exports.PROMPTS_DIR = exports.ASSISTANT_SESSIONS_DIR = exports.EVAL_CASES_DIR = exports.PRESETS_DIR = exports.TRACES_DIR = exports.DATA_DIR = exports.ASSISTANT_MCP_LIVE_LIMIT = exports.ASSISTANT_MCP_TIMEOUT_MS = exports.ASSISTANT_MCP_CHANNEL = exports.ASSISTANT_MCP_PROXY_URL = exports.FEATURE_ASSISTANT_ADDRESS_QUERY_LIVE_V1 = exports.FEATURE_ASSISTANT_ADDRESS_QUERY_V1 = exports.FEATURE_ASSISTANT_MCP_RUNTIME_V1 = exports.FEATURE_ASSISTANT_GRAPH_RUNTIME_V1 = exports.FEATURE_ASSISTANT_LIFECYCLE_ANSWER_V1 = exports.FEATURE_ASSISTANT_LIFECYCLE_RUNTIME_V1 = exports.FEATURE_ASSISTANT_STAGE2_EVAL_V1 = exports.FEATURE_ASSISTANT_PROBLEM_UNIT_CONTINUITY_V1 = exports.FEATURE_ASSISTANT_PROBLEM_CENTRIC_ANSWER_V1 = exports.FEATURE_ASSISTANT_PROBLEM_UNITS_V1 = exports.FEATURE_ASSISTANT_ACCOUNTANT_EVAL_V1 = exports.FEATURE_ASSISTANT_ANSWER_POLICY_V11 = exports.FEATURE_ASSISTANT_ANTI_GENERIC_RANKING_GUARD_V1 = exports.FEATURE_ASSISTANT_MIN_EVIDENCE_GATE_V1 = exports.FEATURE_ASSISTANT_BROAD_GUARD_V1 = exports.FEATURE_ASSISTANT_EVIDENCE_ENRICHMENT_V1 = exports.FEATURE_ASSISTANT_STATE_FOLLOWUP_BINDING_V1 = exports.FEATURE_ASSISTANT_CONTRACTS_V11 = exports.FEATURE_ASSISTANT_INVESTIGATION_STATE_V1 = exports.DEFAULT_PROMPT_VERSION = exports.DEFAULT_MAX_OUTPUT_TOKENS = exports.DEFAULT_TEMPERATURE = exports.DEFAULT_MODEL = exports.DEFAULT_OPENAI_BASE_URL = exports.TIMEZONE = exports.PORT = exports.MODULE_ROOT = exports.BACKEND_ROOT = void 0;
exports.ARCH_EXPORT_2020_DIR = exports.SCHEMAS_DIR = exports.EVAL_DATASETS_DIR = exports.REPORTS_DIR = exports.PROMPTS_DIR = exports.ASSISTANT_SESSIONS_DIR = exports.EVAL_CASES_DIR = exports.PRESETS_DIR = exports.TRACES_DIR = exports.DATA_DIR = exports.ASSISTANT_MCP_LIVE_LIMIT = exports.ASSISTANT_MCP_TIMEOUT_MS = exports.ASSISTANT_MCP_CHANNEL = exports.ASSISTANT_MCP_PROXY_URL = exports.FEATURE_ASSISTANT_ADDRESS_QUERY_LIVE_V1 = exports.FEATURE_ASSISTANT_ADDRESS_QUERY_LLM_PREDECOMPOSE_V1 = exports.FEATURE_ASSISTANT_ADDRESS_QUERY_V1 = exports.FEATURE_ASSISTANT_MCP_RUNTIME_V1 = exports.FEATURE_ASSISTANT_GRAPH_RUNTIME_V1 = exports.FEATURE_ASSISTANT_LIFECYCLE_ANSWER_V1 = exports.FEATURE_ASSISTANT_LIFECYCLE_RUNTIME_V1 = exports.FEATURE_ASSISTANT_STAGE2_EVAL_V1 = exports.FEATURE_ASSISTANT_PROBLEM_UNIT_CONTINUITY_V1 = exports.FEATURE_ASSISTANT_PROBLEM_CENTRIC_ANSWER_V1 = exports.FEATURE_ASSISTANT_PROBLEM_UNITS_V1 = exports.FEATURE_ASSISTANT_ACCOUNTANT_EVAL_V1 = exports.FEATURE_ASSISTANT_ANSWER_POLICY_V11 = exports.FEATURE_ASSISTANT_ANTI_GENERIC_RANKING_GUARD_V1 = exports.FEATURE_ASSISTANT_MIN_EVIDENCE_GATE_V1 = exports.FEATURE_ASSISTANT_BROAD_GUARD_V1 = exports.FEATURE_ASSISTANT_EVIDENCE_ENRICHMENT_V1 = exports.FEATURE_ASSISTANT_STATE_FOLLOWUP_BINDING_V1 = exports.FEATURE_ASSISTANT_CONTRACTS_V11 = exports.FEATURE_ASSISTANT_INVESTIGATION_STATE_V1 = exports.DEFAULT_PROMPT_VERSION = exports.DEFAULT_MAX_OUTPUT_TOKENS = exports.DEFAULT_TEMPERATURE = exports.DEFAULT_MODEL = exports.DEFAULT_OPENAI_BASE_URL = exports.TIMEZONE = exports.PORT = exports.MODULE_ROOT = exports.BACKEND_ROOT = void 0;
const path_1 = __importDefault(require("path"));
exports.BACKEND_ROOT = path_1.default.resolve(__dirname, "..");
exports.MODULE_ROOT = path_1.default.resolve(exports.BACKEND_ROOT, "..");
@ -46,6 +46,7 @@ exports.FEATURE_ASSISTANT_LIFECYCLE_ANSWER_V1 = toBooleanFlag(process.env.FEATUR
exports.FEATURE_ASSISTANT_GRAPH_RUNTIME_V1 = toBooleanFlag(process.env.FEATURE_ASSISTANT_GRAPH_RUNTIME_V1, true);
exports.FEATURE_ASSISTANT_MCP_RUNTIME_V1 = toBooleanFlag(process.env.FEATURE_ASSISTANT_MCP_RUNTIME_V1, false);
exports.FEATURE_ASSISTANT_ADDRESS_QUERY_V1 = toBooleanFlag(process.env.FEATURE_ASSISTANT_ADDRESS_QUERY_V1, true);
exports.FEATURE_ASSISTANT_ADDRESS_QUERY_LLM_PREDECOMPOSE_V1 = toBooleanFlag(process.env.FEATURE_ASSISTANT_ADDRESS_QUERY_LLM_PREDECOMPOSE_V1, true);
exports.FEATURE_ASSISTANT_ADDRESS_QUERY_LIVE_V1 = toBooleanFlag(process.env.FEATURE_ASSISTANT_ADDRESS_QUERY_LIVE_V1, true);
exports.ASSISTANT_MCP_PROXY_URL = (process.env.ASSISTANT_MCP_PROXY_URL ?? "http://127.0.0.1:6003").replace(/\/+$/, "");
exports.ASSISTANT_MCP_CHANNEL = process.env.ASSISTANT_MCP_CHANNEL ?? "default";

View File

@ -6,23 +6,74 @@ const config_1 = require("../config");
const http_1 = require("../utils/http");
function buildTestConnectionRouter(client) {
const router = (0, express_1.Router)();
router.post("/api/openai/test-connection", async (req, res, next) => {
const handler = async (req, res, next) => {
try {
const body = (req.body ?? {});
const llmProvider = body.llmProvider === "local" ? "local" : "openai";
const model = String(body.model ?? config_1.DEFAULT_MODEL);
const baseUrl = String(body.baseUrl ?? config_1.DEFAULT_OPENAI_BASE_URL);
const apiKey = String(body.apiKey ?? process.env.OPENAI_API_KEY ?? "");
const result = await client.testConnection({
apiKey: String(body.apiKey ?? process.env.OPENAI_API_KEY ?? ""),
model: String(body.model ?? config_1.DEFAULT_MODEL),
baseUrl: String(body.baseUrl ?? config_1.DEFAULT_OPENAI_BASE_URL)
llmProvider,
apiKey,
model,
baseUrl
});
let modelFound = null;
let modelsCount = null;
if (llmProvider === "local") {
try {
const models = await client.listModels({
llmProvider,
apiKey,
model,
baseUrl
});
modelsCount = models.length;
modelFound = models.includes(model);
}
catch {
modelFound = null;
modelsCount = null;
}
}
(0, http_1.ok)(res, {
ok: true,
provider: llmProvider,
model: result.model,
model_found: modelFound,
models_count: modelsCount,
timestamp: new Date().toISOString()
});
}
catch (error) {
next(error);
}
});
};
const listModelsHandler = async (req, res, next) => {
try {
const body = (req.body ?? {});
const models = await client.listModels({
llmProvider: body.llmProvider === "local" ? "local" : "openai",
apiKey: String(body.apiKey ?? process.env.OPENAI_API_KEY ?? ""),
model: String(body.model ?? config_1.DEFAULT_MODEL),
baseUrl: String(body.baseUrl ?? config_1.DEFAULT_OPENAI_BASE_URL)
});
(0, http_1.ok)(res, {
ok: true,
models,
count: models.length,
timestamp: new Date().toISOString()
});
}
catch (error) {
next(error);
}
};
router.post("/api/llm/test-connection", handler);
router.post("/api/llm/models", listModelsHandler);
// Backward-compatible route for old frontend builds.
router.post("/api/openai/test-connection", handler);
router.post("/api/openai/models", listModelsHandler);
return router;
}

View File

@ -3,12 +3,19 @@ Object.defineProperty(exports, "__esModule", { value: true });
exports.extractAddressFilters = extractAddressFilters;
const ACCOUNT_PATTERN = /(?:сч[её]т|счет|account)[^0-9]{0,12}(\d{2}(?:[.,]\d{1,2})?)/i;
const LIMIT_PATTERN = /(?:\btop\b|\blimit\b|\bпервые\b|\bтоп\b)\s*(\d{1,3})/i;
const COUNTERPARTY_PATTERN = /(?:по\s+контрагенту|контрагент(?:у|а)?|by\s+counterparty|counterparty)\s+([^\r\n,.;:]+)/i;
const COUNTERPARTY_PATTERN = /(?:по\s+контрагенту|контрагент(?:у|а)?|по\s+контре|контра|по\s+компан(?:ии|ию|ия)|компан(?:ия|ии|ию)|по\s+организац(?:ии|ию|ия)|организац(?:ия|ии|ию)|по\s+поставщик(?:у|а)?|поставщик(?:у|а)?|по\s+клиент(?:у|а)?|клиент(?:у|а)?|по\s+покупател(?:ю|я)|покупател(?:ю|я)|по\s+партнер(?:у|а)?|партнер(?:у|а)?|by\s+counterparty|counterparty|by\s+company|company|by\s+supplier|supplier|by\s+vendor|vendor|by\s+customer|customer|by\s+client|client|by\s+partner|partner)\s+([^\r\n,.;:]+)/iu;
const CONTRACT_PATTERN = /(?:по\s+договору|договор(?:у|а)?\s*(?:№|#|n)?|by\s+contract|contract(?:\s*(?:no|number|#|n))?)\s+([^\r\n,.;:]+)/i;
const DATE_DMY_PATTERN = /\b(\d{1,2})[.\/-](\d{1,2})[.\/-](\d{2,4})\b/;
const DATE_YMD_PATTERN = /\b(20\d{2})[.\/-](\d{1,2})[.\/-](\d{1,2})\b/;
const PERIOD_RANGE_PATTERN_1 = /(?:from|с)\s+(\d{1,4}[.\/-]\d{1,2}[.\/-]\d{1,4})\s+(?:to|по)\s+(\d{1,4}[.\/-]\d{1,2}[.\/-]\d{1,4})/i;
const PERIOD_RANGE_PATTERN_2 = /(?:between|за\s+период\s+с)\s+(\d{1,4}[.\/-]\d{1,2}[.\/-]\d{1,4})\s+(?:and|по)\s+(\d{1,4}[.\/-]\d{1,2}[.\/-]\d{1,4})/i;
const YEAR_RANGE_PATTERN = /(?:за|for|с|from)?\s*(20\d{2})\s*(?:[-‐‑‒–—―−]|до|to|по)\s*(20\d{2})(?:\s*(?:г(?:од|ода)?\.?|year))?(?=[^\d]|$)/iu;
const YEAR_RANGE_LOOSE_PATTERN = /\b(20\d{2})\b\s*(?:[-‐‑‒–—―−]|до|to|по)\s*\b(20\d{2})\b/iu;
const YEAR_PERIOD_PATTERN = /(?:за|for)\s*(20\d{2})(?!\s*(?:[-‐‑‒–—―−]|до|to|по)\s*20\d{2})\s*(?:г(?:од|ода)?\.?|year)?/iu;
const YEAR_PERIOD_SHORT_PATTERN = /(?:^|[\s,.;:!?()\-])(\d{2})\s*(?:г(?:од|ода)?\.?|year)(?=$|[\s,.;:!?()\-])/iu;
const YEAR_PERIOD_ANY_PATTERN = /(?:^|[\s,.;:!?()\-])((?:19|20)\d{2})(?!\s*(?:[-‐‑‒–—―−]|до|to|по)\s*(?:19|20)\d{2})(?![.\/-]\d)(?:\s*(?:г(?:од|ода)?\.?|year))?(?=$|[\s,.;:!?()\-])/iu;
const MONTH_PERIOD_NUMERIC_PATTERN = /(?:за|for)\s*(0?[1-9]|1[0-2])[.\/-](20\d{2})/i;
const MONTH_PERIOD_NAME_PATTERN = /(?:за|for)\s+([a-zа-яё]+)\s+(20\d{2})(?:\s*г(?:од|ода|\\.)?)?/iu;
function toIsoDate(year, month, day) {
if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) {
return null;
@ -61,6 +68,64 @@ function parseDateToken(token) {
}
return undefined;
}
function resolveMonthByName(rawMonthName) {
const token = String(rawMonthName ?? "").trim().toLowerCase();
if (!token) {
return undefined;
}
if (/^янв|^january|^jan/.test(token))
return 1;
if (/^фев|^february|^feb/.test(token))
return 2;
if (/^мар|^march|^mar/.test(token))
return 3;
if (/^апр|^april|^apr/.test(token))
return 4;
if (/^ма[йя]|^may/.test(token))
return 5;
if (/^июн|^june|^jun/.test(token))
return 6;
if (/^июл|^july|^jul/.test(token))
return 7;
if (/^авг|^august|^aug/.test(token))
return 8;
if (/^сен|^сент|^september|^sep/.test(token))
return 9;
if (/^окт|^october|^oct/.test(token))
return 10;
if (/^ноя|^november|^nov/.test(token))
return 11;
if (/^дек|^december|^dec/.test(token))
return 12;
return undefined;
}
function extractMonthPeriod(text) {
const numericMatch = text.match(MONTH_PERIOD_NUMERIC_PATTERN);
if (numericMatch) {
const month = Number(numericMatch[1]);
const year = Number(numericMatch[2]);
if (month >= 1 && month <= 12 && year >= 2000 && year <= 2099) {
const lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate();
return {
period_from: `${year}-${String(month).padStart(2, "0")}-01`,
period_to: `${year}-${String(month).padStart(2, "0")}-${String(lastDay).padStart(2, "0")}`
};
}
}
const byNameMatch = text.match(MONTH_PERIOD_NAME_PATTERN);
if (byNameMatch) {
const month = resolveMonthByName(String(byNameMatch[1]));
const year = Number(byNameMatch[2]);
if (month && year >= 2000 && year <= 2099) {
const lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate();
return {
period_from: `${year}-${String(month).padStart(2, "0")}-01`,
period_to: `${year}-${String(month).padStart(2, "0")}-${String(lastDay).padStart(2, "0")}`
};
}
}
return {};
}
function extractPeriodRange(text) {
const directMatch = text.match(PERIOD_RANGE_PATTERN_1) ?? text.match(PERIOD_RANGE_PATTERN_2);
if (!directMatch) {
@ -73,6 +138,64 @@ function extractPeriodRange(text) {
...(periodTo ? { period_to: periodTo } : {})
};
}
function extractYearPeriod(text) {
const match = text.match(YEAR_PERIOD_PATTERN);
if (match) {
const year = Number(match[1]);
if (!Number.isFinite(year) || year < 2000 || year > 2099) {
return {};
}
return {
period_from: `${year}-01-01`,
period_to: `${year}-12-31`
};
}
const relaxedYearMatch = text.match(YEAR_PERIOD_ANY_PATTERN);
if (relaxedYearMatch) {
const year = Number(relaxedYearMatch[1]);
if (Number.isFinite(year) && year >= 2000 && year <= 2099) {
return {
period_from: `${year}-01-01`,
period_to: `${year}-12-31`
};
}
}
const shortYearMatch = text.match(YEAR_PERIOD_SHORT_PATTERN);
if (!shortYearMatch) {
return {};
}
const shortYear = Number(shortYearMatch[1]);
if (!Number.isFinite(shortYear) || shortYear < 0 || shortYear > 99) {
return {};
}
const year = 2000 + shortYear;
return {
period_from: `${year}-01-01`,
period_to: `${year}-12-31`
};
}
function extractYearRangePeriod(text) {
const match = text.match(YEAR_RANGE_PATTERN) ?? text.match(YEAR_RANGE_LOOSE_PATTERN);
if (!match) {
return {};
}
const leftYear = Number(match[1]);
const rightYear = Number(match[2]);
if (!Number.isFinite(leftYear) ||
!Number.isFinite(rightYear) ||
leftYear < 2000 ||
leftYear > 2099 ||
rightYear < 2000 ||
rightYear > 2099) {
return {};
}
const fromYear = Math.min(leftYear, rightYear);
const toYear = Math.max(leftYear, rightYear);
return {
period_from: `${fromYear}-01-01`,
period_to: `${toYear}-12-31`
};
}
function cleanupAnchorValue(value) {
const normalized = String(value ?? "").trim();
if (!normalized) {
@ -84,11 +207,11 @@ function cleanupAnchorValue(value) {
if (periodTailPattern.test(normalized)) {
return normalized.replace(periodTailPattern, "").trim();
}
const allTimeTailPattern = /\s+за\s+вс[её]\s+время(?:\s+|$)[\s\S]*$/iu;
const allTimeTailPattern = /\s+за\s+(?:вс[её]\s+время|весь\s+период|весь\s+срок|всю\s+истори(?:ю|и)|любой\s+период|любой\s+срок)(?:\s+|$)[\s\S]*$/iu;
if (allTimeTailPattern.test(normalized)) {
return normalized.replace(allTimeTailPattern, "").trim();
}
const allTimeTailPatternEn = /\s+(?:for\s+all\s+time|all\s+time)(?:\s+|$)[\s\S]*$/iu;
const allTimeTailPatternEn = /\s+(?:for\s+all\s+time|all\s+time|for\s+entire\s+period|entire\s+period|for\s+any\s+period|any\s+period|for\s+full\s+history|full\s+history)(?:\s+|$)[\s\S]*$/iu;
if (allTimeTailPatternEn.test(normalized)) {
return normalized.replace(allTimeTailPatternEn, "").trim();
}
@ -99,7 +222,186 @@ function cleanupAnchorValue(value) {
}
function hasAllTimeHint(text) {
const value = String(text ?? "");
return /(?:за\s+вс[её]\s+время|for\s+all\s+time|all\s+time)/iu.test(value);
return /(?:за\s+вс[её]\s+время|за\s+весь\s+период|за\s+весь\s+срок|за\s+всю\s+истори(?:ю|и)|за\s+любой\s+период|за\s+любой\s+срок|for\s+all\s+time|all\s+time|for\s+entire\s+period|entire\s+period|for\s+any\s+period|any\s+period|for\s+full\s+history|full\s+history)/iu.test(value);
}
function extractLooseByAnchorValue(text) {
const match = String(text ?? "").match(/(?:^|\s)по\s+([a-zа-яё][a-zа-яё0-9._-]{1,})(?=[\s,.;:!?)]|$)/iu);
if (!match) {
return undefined;
}
const token = String(match[1] ?? "").trim();
if (!token) {
return undefined;
}
const lowered = token.toLowerCase();
const stopWords = new Set([
"контрагенту",
"контрагента",
"контре",
"компании",
"компанию",
"организации",
"организацию",
"поставщику",
"поставщика",
"клиенту",
"клиента",
"покупателю",
"покупателя",
"партнеру",
"партнера",
"договору",
"договора",
"счету",
"счёту",
"дате",
"периоду",
"период",
"документам",
"докам",
"взаиморасчетам",
"взаиморасчётам"
]);
if (stopWords.has(lowered)) {
return undefined;
}
return token;
}
function isLikelyCounterpartyToken(rawToken) {
const token = String(rawToken ?? "").trim();
const lowered = token.toLowerCase();
if (!token || token.length < 2) {
return false;
}
if (/^\d+$/.test(lowered)) {
return false;
}
if (/^(?:19|20)\d{2}$/.test(lowered)) {
return false;
}
const stopWords = new Set([
"за",
"с",
"по",
"на",
"и",
"или",
"док",
"доки",
"документ",
"документы",
"документов",
"банк",
"банковские",
"операции",
"платежи",
"платеж",
"платёж",
"контрагент",
"контрагенту",
"контрагента",
"компания",
"компании",
"организация",
"организации",
"год",
"года",
"г",
"плс",
"pls",
"пж",
"пжлст",
"пожалуйста",
"бля",
"блять",
"епт",
"ёпт",
"епта",
"нах",
"нахуй",
"покеж",
"покажи",
"выведи"
]);
return !stopWords.has(lowered);
}
function hasDocsOrBankSignal(text) {
const lowered = String(text ?? "").toLowerCase();
return /(?:док(?:и|умент|ументы|ументов)|docs?|documents?|банк|выписк|платеж|платёж|оплат|transactions?|bank\s+ops|bank\s+operations?)/iu.test(lowered);
}
function extractCounterpartyFromFreeTextHeuristic(text) {
if (!hasDocsOrBankSignal(text)) {
return undefined;
}
const tokens = String(text ?? "")
.split(/[^a-zа-яё0-9._-]+/iu)
.map((item) => item.trim())
.filter((item) => item.length > 0);
if (tokens.length === 0) {
return undefined;
}
const monthTokens = [
"янв",
"фев",
"мар",
"апр",
"май",
"июн",
"июл",
"авг",
"сен",
"сент",
"окт",
"ноя",
"дек",
"january",
"february",
"march",
"april",
"may",
"june",
"july",
"august",
"september",
"october",
"november",
"december"
];
for (const token of tokens) {
const lowered = token.toLowerCase();
if (!isLikelyCounterpartyToken(lowered)) {
continue;
}
if (/^\d{2}$/.test(lowered) || /^\d{4}$/.test(lowered)) {
continue;
}
if (monthTokens.some((prefix) => lowered.startsWith(prefix))) {
continue;
}
if (/(?:^за$|^for$|^from$|^to$|^по$|^с$|^год$|^года$|^г$|^year$)/iu.test(lowered)) {
continue;
}
return token;
}
return undefined;
}
function extractImplicitCounterpartyValue(text) {
const input = String(text ?? "");
const beforeDocsMatch = input.match(/(?:^|\s)([a-zа-яё][a-zа-яё0-9._-]{1,})\s+(?:док(?:и|ум(?:ент(?:ы|ов|ам|а)?)?)|docs?|documents?)(?=[\s,.;:!?)]|$)/iu);
if (beforeDocsMatch) {
const candidate = String(beforeDocsMatch[1] ?? "").trim();
if (isLikelyCounterpartyToken(candidate)) {
return candidate;
}
}
const afterDocsMatch = input.match(/(?:док(?:и|ум(?:ент(?:ы|ов|ам|а)?)?)|docs?|documents?)\s+(?:по\s+)?([a-zа-яё][a-zа-яё0-9._-]{1,})(?=[\s,.;:!?)]|$)/iu);
if (afterDocsMatch) {
const candidate = String(afterDocsMatch[1] ?? "").trim();
if (isLikelyCounterpartyToken(candidate)) {
return candidate;
}
}
return undefined;
}
function shiftDaysIso(baseIso, deltaDays) {
const date = new Date(`${baseIso}T00:00:00.000Z`);
@ -137,6 +439,27 @@ function extractAddressFilters(userMessage, intent) {
if (counterpartyMatch) {
filters.counterparty = cleanupAnchorValue(String(counterpartyMatch[1]));
}
if (!filters.counterparty && (intent === "list_documents_by_counterparty" || intent === "bank_operations_by_counterparty")) {
const fallbackCounterparty = extractLooseByAnchorValue(text);
if (fallbackCounterparty) {
filters.counterparty = cleanupAnchorValue(fallbackCounterparty);
warnings.push("counterparty_anchor_derived_from_loose_by_phrase");
}
}
if (!filters.counterparty && (intent === "list_documents_by_counterparty" || intent === "bank_operations_by_counterparty")) {
const implicitCounterparty = extractImplicitCounterpartyValue(text);
if (implicitCounterparty) {
filters.counterparty = cleanupAnchorValue(implicitCounterparty);
warnings.push("counterparty_anchor_derived_from_implicit_phrase");
}
}
if (!filters.counterparty && (intent === "list_documents_by_counterparty" || intent === "bank_operations_by_counterparty")) {
const heuristicCounterparty = extractCounterpartyFromFreeTextHeuristic(text);
if (heuristicCounterparty) {
filters.counterparty = cleanupAnchorValue(heuristicCounterparty);
warnings.push("counterparty_anchor_derived_from_free_text_heuristic");
}
}
const contractMatch = text.match(CONTRACT_PATTERN);
if (contractMatch) {
filters.contract = cleanupAnchorValue(String(contractMatch[1]));
@ -148,6 +471,30 @@ function extractAddressFilters(userMessage, intent) {
if (periodRange.period_to) {
filters.period_to = periodRange.period_to;
}
if (!filters.period_from && !filters.period_to) {
const monthPeriod = extractMonthPeriod(text);
if (monthPeriod.period_from && monthPeriod.period_to) {
filters.period_from = monthPeriod.period_from;
filters.period_to = monthPeriod.period_to;
warnings.push("period_derived_from_month_phrase");
}
}
if (!filters.period_from && !filters.period_to) {
const yearRangePeriod = extractYearRangePeriod(text);
if (yearRangePeriod.period_from && yearRangePeriod.period_to) {
filters.period_from = yearRangePeriod.period_from;
filters.period_to = yearRangePeriod.period_to;
warnings.push("period_derived_from_year_range_phrase");
}
}
if (!filters.period_from && !filters.period_to) {
const yearPeriod = extractYearPeriod(text);
if (yearPeriod.period_from && yearPeriod.period_to) {
filters.period_from = yearPeriod.period_from;
filters.period_to = yearPeriod.period_to;
warnings.push("period_derived_from_year_phrase");
}
}
// If explicit period window exists, do not infer as_of_date from one of its boundary dates.
if (!filters.period_from && !filters.period_to) {
const asOfDate = extractAsOfDate(text);

View File

@ -62,23 +62,201 @@ const OPEN_ITEMS_HINTS = [
const DOCUMENTS_BY_COUNTERPARTY_HINTS = [
"documents by counterparty",
"docs by counterparty",
"documents by company",
"documents by supplier",
"documents by customer",
"documents by client",
"documents by partner",
"show documents by counterparty",
"list documents by counterparty",
"документы по",
"доступные документы",
"список документов",
"документ",
"доки",
"доки по",
"док по",
"по контрагент"
];
const BANK_OPERATIONS_BY_COUNTERPARTY_HINTS = [
"bank operations by counterparty",
"bank payments by counterparty",
"payment orders by counterparty",
"bank operations by company",
"bank operations by supplier",
"bank operations by customer",
"show bank operations by counterparty",
"bank ops",
"transactions by counterparty",
"банков",
"выписк",
"платеж"
"платеж",
"платёж",
"оплат",
"списан",
"поступлен",
"движени"
];
function hasAny(text, patterns) {
return patterns.some((item) => text.includes(item));
}
function isLikelyCounterpartyToken(rawToken) {
const token = String(rawToken ?? "").trim().toLowerCase();
if (!token || token.length < 2) {
return false;
}
if (/^\d+$/.test(token)) {
return false;
}
if (/^(?:19|20)\d{2}$/.test(token)) {
return false;
}
const stopWords = new Set([
"за",
"с",
"по",
"на",
"и",
"или",
"док",
"доки",
"доки?",
"документ",
"документы",
"документов",
"банк",
"банковские",
"операции",
"платежи",
"платеж",
"платёж",
"контрагент",
"контрагенту",
"контрагента",
"компания",
"компании",
"организация",
"организации",
"год",
"года",
"г",
"плс",
"pls",
"пж",
"пжлст",
"пожалуйста",
"бля",
"блять",
"епт",
"ёпт",
"епта",
"нах",
"нахуй"
]);
return !stopWords.has(token);
}
function hasPartyAnchorMention(text) {
return (text.includes("контраг") ||
text.includes("контра") ||
text.includes("counterparty") ||
text.includes("компан") ||
text.includes("company") ||
text.includes("организац") ||
text.includes("supplier") ||
text.includes("vendor") ||
text.includes("customer") ||
text.includes("client") ||
text.includes("partner") ||
text.includes("поставщик") ||
text.includes("клиент") ||
text.includes("покупател") ||
text.includes("партнер"));
}
function hasLooseByAnchorMention(text) {
const match = text.match(/(?:^|\s)по\s+([a-zа-яё][a-zа-яё0-9._-]{1,})(?=[\s,.;:!?)]|$)/iu);
if (!match) {
return false;
}
const token = String(match[1] ?? "").toLowerCase();
if (!token) {
return false;
}
const stopWords = new Set([
"контрагенту",
"контрагента",
"контре",
"компании",
"компанию",
"организации",
"организацию",
"поставщику",
"поставщика",
"клиенту",
"клиента",
"покупателю",
"покупателя",
"партнеру",
"партнера",
"договору",
"договора",
"счету",
"счёту",
"дате",
"периоду",
"период",
"документам",
"докам"
]);
return !stopWords.has(token);
}
function hasImplicitCounterpartyAnchorAroundDocs(text) {
const beforeDocsMatch = text.match(/(?:^|\s)([a-zа-яё][a-zа-яё0-9._-]{1,})\s+(?:док(?:и|ум(?:ент(?:ы|ов|ам|а)?)?)|docs?|documents?)(?=[\s,.;:!?)]|$)/iu);
if (beforeDocsMatch && isLikelyCounterpartyToken(String(beforeDocsMatch[1] ?? ""))) {
return true;
}
const afterDocsMatch = text.match(/(?:док(?:и|ум(?:ент(?:ы|ов|ам|а)?)?)|docs?|documents?)\s+(?:по\s+)?([a-zа-яё][a-zа-яё0-9._-]{1,})(?=[\s,.;:!?)]|$)/iu);
if (afterDocsMatch && isLikelyCounterpartyToken(String(afterDocsMatch[1] ?? ""))) {
return true;
}
return false;
}
function hasDocsOrBankSignal(text) {
return /(?:док(?:и|умент|ументы|ументов)|docs?|documents?|банк|выписк|платеж|платёж|оплат|transactions?|bank\s+ops|bank\s+operations?)/iu.test(text);
}
function hasHeuristicCounterpartyAnchor(text) {
if (!hasDocsOrBankSignal(text)) {
return false;
}
const tokens = String(text ?? "")
.split(/[^a-zа-яё0-9._-]+/iu)
.map((item) => item.trim())
.filter((item) => item.length > 0);
for (const token of tokens) {
const lowered = token.toLowerCase();
if (!isLikelyCounterpartyToken(lowered)) {
continue;
}
if (/^\d{2}$/.test(lowered) || /^\d{4}$/.test(lowered)) {
continue;
}
if (/(?:^за$|^for$|^from$|^to$|^по$|^с$|^год$|^года$|^г$|^year$)/iu.test(lowered)) {
continue;
}
return true;
}
return false;
}
function hasGenericAddressLookupSignal(text) {
return (/\bесть\b/iu.test(text) ||
/\bпокажи\b/iu.test(text) ||
/\bвыведи\b/iu.test(text) ||
/\bкакие\b/iu.test(text) ||
/\bчто(?:-|\s)?то\b/iu.test(text) ||
/за\s+любой\s+период/iu.test(text) ||
/за\s+вс[её]\s+время/iu.test(text) ||
/for\s+all\s+time/iu.test(text) ||
/all\s+time/iu.test(text));
}
function hasAccountNumberAnchor(text) {
return /(?:account|сч[её]т|счет)\D{0,12}\d{2}(?:[.,]\d{1,2})?/i.test(text);
}
@ -113,7 +291,7 @@ function resolveAddressIntent(userMessage) {
};
}
if (hasAny(text, BANK_OPERATIONS_BY_COUNTERPARTY_HINTS) &&
(text.includes("контраг") || text.includes("counterparty"))) {
(hasPartyAnchorMention(text) || hasLooseByAnchorMention(text) || hasHeuristicCounterpartyAnchor(text))) {
return {
intent: "bank_operations_by_counterparty",
confidence: "medium",
@ -121,13 +299,23 @@ function resolveAddressIntent(userMessage) {
};
}
if (hasAny(text, DOCUMENTS_BY_COUNTERPARTY_HINTS) &&
(text.includes("контраг") || text.includes("counterparty"))) {
(hasPartyAnchorMention(text) ||
hasLooseByAnchorMention(text) ||
hasImplicitCounterpartyAnchorAroundDocs(text) ||
hasHeuristicCounterpartyAnchor(text))) {
return {
intent: "list_documents_by_counterparty",
confidence: "medium",
reasons: ["documents_by_counterparty_signal_detected"]
};
}
if (hasLooseByAnchorMention(text) && hasGenericAddressLookupSignal(text)) {
return {
intent: "list_documents_by_counterparty",
confidence: "low",
reasons: ["generic_lookup_with_loose_anchor_fallback"]
};
}
if (hasAny(text, OPEN_ITEMS_HINTS) && (text.includes("контраг") || text.includes("договор") || text.includes("counterparty") || text.includes("contract"))) {
return {
intent: "open_items_by_counterparty_or_contract",

View File

@ -1,7 +1,11 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.executeAddressMcpQuery = executeAddressMcpQuery;
const config_1 = require("../config");
const iconv_lite_1 = __importDefault(require("iconv-lite"));
function toStringValue(value) {
if (value === null || value === undefined) {
return "";
@ -20,8 +24,76 @@ function parseFiniteNumber(value) {
}
return null;
}
function textMojibakeScore(value) {
const source = String(value ?? "");
const cyrillic = (source.match(/[А-Яа-яЁё]/g) ?? []).length;
const latin = (source.match(/[A-Za-z]/g) ?? []).length;
const hardMarkers = (source.match(/[Ѓѓ‚„…†‡€‰‹ЉЊЌЋЏ‘’“”•–—™љ›њќћџ]/g) ?? []).length;
const pairMarkers = (source.match(/(?:Р.|С.|Ð.|Ñ.)/g) ?? []).length;
return cyrillic + latin - hardMarkers * 3 - pairMarkers * 2;
}
function looksLikeMojibake(value) {
const source = String(value ?? "");
if (!source.trim()) {
return false;
}
if (/[Ѓѓ‚„…†‡€‰‹ЉЊЌЋЏ‘’“”•–—™љ›њќћџ]/.test(source)) {
return true;
}
return (source.match(/(?:Р.|С.|Ð.|Ñ.)/g) ?? []).length >= 2;
}
function decodeUtf8FromWin1251Mojibake(value) {
if (!looksLikeMojibake(value)) {
return value;
}
try {
const bytes = iconv_lite_1.default.encode(value, "win1251");
const decoded = bytes.toString("utf8");
return textMojibakeScore(decoded) > textMojibakeScore(value) ? decoded : value;
}
catch {
return value;
}
}
function decodeUtf8FromLatin1Mojibake(value) {
if (!looksLikeMojibake(value)) {
return value;
}
try {
const decoded = Buffer.from(value, "latin1").toString("utf8");
return textMojibakeScore(decoded) > textMojibakeScore(value) ? decoded : value;
}
catch {
return value;
}
}
function normalizeMojibakeString(value) {
const fromWin1251 = decodeUtf8FromWin1251Mojibake(value);
return decodeUtf8FromLatin1Mojibake(fromWin1251);
}
function normalizeMojibakeValue(value) {
if (typeof value === "string") {
return normalizeMojibakeString(value);
}
if (Array.isArray(value)) {
return value.map((item) => normalizeMojibakeValue(item));
}
if (value && typeof value === "object") {
const source = value;
const normalized = {};
for (const [key, raw] of Object.entries(source)) {
const repairedKey = normalizeMojibakeString(key);
normalized[repairedKey] = normalizeMojibakeValue(raw);
}
return normalized;
}
return value;
}
function normalizeMojibakeRows(rows) {
return rows.map((row) => normalizeMojibakeValue(row));
}
function parseRowsFromTextTable(source) {
const normalized = String(source ?? "").replace(/\r/g, "").trim();
const normalized = normalizeMojibakeString(String(source ?? "")).replace(/\r/g, "").trim();
if (!normalized) {
return [];
}
@ -91,7 +163,7 @@ function parseRowsFromTextTable(source) {
row.Amount = parseFiniteNumber(values[4]) ?? values[4];
rows.push(row);
}
return rows;
return normalizeMojibakeRows(rows);
}
function parseExecutePayload(payload) {
if (!payload || typeof payload !== "object") {
@ -110,9 +182,9 @@ function parseExecutePayload(payload) {
};
}
if (Array.isArray(source.data)) {
const rows = source.data
const rows = normalizeMojibakeRows(source.data
.map((item) => (item && typeof item === "object" ? item : null))
.filter((item) => item !== null);
.filter((item) => item !== null));
return {
ok: true,
rows,
@ -127,9 +199,9 @@ function parseExecutePayload(payload) {
};
}
if (source.data && typeof source.data === "object" && Array.isArray(source.data.rows)) {
const rows = (source.data.rows ?? [])
const rows = normalizeMojibakeRows((source.data.rows ?? [])
.map((item) => (item && typeof item === "object" ? item : null))
.filter((item) => item !== null);
.filter((item) => item !== null));
return {
ok: true,
rows,

View File

@ -27,6 +27,13 @@ const ADDRESS_ACTION_TOKENS = [
const ADDRESS_ENTITY_TOKENS = [
"counterparty",
"counterparties",
"company",
"organization",
"supplier",
"vendor",
"customer",
"client",
"partner",
"contract",
"contracts",
"account",
@ -42,10 +49,22 @@ const ADDRESS_ENTITY_TOKENS = [
"owes",
"owed",
"контрагент",
"контра",
"компан",
"организац",
"поставщик",
"клиент",
"покупател",
"партнер",
"банк",
"выписк",
"операц",
"договор",
"счет",
"счёт",
"документ",
"доки",
"док",
"остаток",
"дебитор",
"кредитор",
@ -71,6 +90,54 @@ const DEEP_REASONING_TOKENS = [
"разрыв",
"ошибк"
];
function hasLooseByAnchorMention(text) {
const match = text.match(/(?:^|\s)по\s+([a-zа-яё][a-zа-яё0-9._-]{1,})(?=[\s,.;:!?)]|$)/iu);
if (!match) {
return false;
}
const token = String(match[1] ?? "").toLowerCase();
if (!token) {
return false;
}
const stopWords = new Set([
"контрагенту",
"контрагента",
"контре",
"компании",
"компанию",
"организации",
"организацию",
"поставщику",
"поставщика",
"клиенту",
"клиента",
"покупателю",
"покупателя",
"партнеру",
"партнера",
"договору",
"договора",
"счету",
"счёту",
"дате",
"периоду",
"период",
"документам",
"докам",
"взаиморасчетам",
"взаиморасчётам"
]);
return !stopWords.has(token);
}
function hasAddressFollowupSignal(text) {
if (/(?:за\s+любой\s+период|за\s+вс[её]\s+время|for\s+all\s+time|all\s+time)/iu.test(text)) {
return true;
}
if (/(?:\bесть\s+что(?:-|\s)?то\b|\bесть\s+ли\b|\bчто\s+есть\b)/iu.test(text)) {
return true;
}
return false;
}
function hasAnyToken(text, tokens) {
return tokens.some((token) => text.includes(token));
}
@ -86,6 +153,8 @@ function detectAddressQuestionMode(userMessage) {
const hasAddressAction = hasAnyToken(text, ADDRESS_ACTION_TOKENS);
const hasAddressEntity = hasAnyToken(text, ADDRESS_ENTITY_TOKENS);
const hasDeepReasoning = hasAnyToken(text, DEEP_REASONING_TOKENS);
const hasLooseByAnchor = hasLooseByAnchorMention(text);
const hasFollowupSignal = hasAddressFollowupSignal(text);
if (hasAddressAction && hasAddressEntity && !hasDeepReasoning) {
return {
mode: "address_query",
@ -93,6 +162,13 @@ function detectAddressQuestionMode(userMessage) {
reasons: ["address_action_detected", "address_entity_detected"]
};
}
if (hasLooseByAnchor && (hasAddressAction || hasAddressEntity || hasFollowupSignal) && !hasDeepReasoning) {
return {
mode: "address_query",
confidence: "medium",
reasons: ["loose_by_anchor_detected", ...(hasFollowupSignal ? ["address_followup_signal_detected"] : [])]
};
}
if (hasAddressEntity && !hasDeepReasoning) {
return {
mode: "address_query",

View File

@ -2,12 +2,11 @@
Object.defineProperty(exports, "__esModule", { value: true });
exports.AddressQueryService = void 0;
const config_1 = require("../config");
const addressQueryClassifier_1 = require("./addressQueryClassifier");
const addressQueryShapeClassifier_1 = require("./addressQueryShapeClassifier");
const addressIntentResolver_1 = require("./addressIntentResolver");
const addressFilterExtractor_1 = require("./addressFilterExtractor");
const addressRecipeCatalog_1 = require("./addressRecipeCatalog");
const addressMcpClient_1 = require("./addressMcpClient");
const decomposeStage_1 = require("./address_runtime/decomposeStage");
const resolveStage_1 = require("./address_runtime/resolveStage");
const composeStage_1 = require("./address_runtime/composeStage");
const 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([
@ -323,20 +322,56 @@ function applyIntentSpecificFilter(intent, rows) {
}
return rows;
}
function formatTopRows(rows, limit = 6) {
return rows.slice(0, limit).map((row, index) => {
const period = row.period ?? "дата не указана";
const amount = row.amount !== null ? `${row.amount}` : "сумма не указана";
const accounts = [row.account_dt ?? "-", row.account_kt ?? "-"].join(" / ");
const analytics = row.analytics.length > 0 ? ` | аналитика: ${row.analytics.slice(0, 2).join("; ")}` : "";
return `${index + 1}. ${period} | ${row.registrator} | ${accounts} | ${amount}${analytics}`;
});
function hasExplicitPeriodWindow(filters) {
return ((typeof filters.period_from === "string" && filters.period_from.trim().length > 0) ||
(typeof filters.period_to === "string" && filters.period_to.trim().length > 0));
}
function inferReplyType(responseType) {
if (responseType === "FACTUAL_LIST" || responseType === "FACTUAL_SUMMARY") {
return "factual";
function canAutoBroadenPeriodWindow(intent, filters) {
if (!hasExplicitPeriodWindow(filters)) {
return false;
}
return "partial_coverage";
return intent === "list_documents_by_counterparty" || intent === "bank_operations_by_counterparty";
}
function toIsoDatePrefix(value) {
if (!value) {
return null;
}
const normalized = String(value).trim();
if (!normalized) {
return null;
}
const match = normalized.match(/^(\d{4}-\d{2}-\d{2})/);
if (match) {
return match[1];
}
return null;
}
function deriveObservedPeriodWindow(rows) {
const dates = rows
.map((row) => toIsoDatePrefix(row.period))
.filter((item) => Boolean(item))
.sort();
if (dates.length === 0) {
return {
period_from: null,
period_to: null
};
}
return {
period_from: dates[0],
period_to: dates[dates.length - 1]
};
}
function composeAutoBroadenedPeriodPrefix(requested, observed) {
const requestedFrom = typeof requested.period_from === "string" ? requested.period_from : null;
const requestedTo = typeof requested.period_to === "string" ? requested.period_to : null;
if (requestedFrom && requestedTo && observed.period_from && observed.period_to) {
return `По окну ${requestedFrom}..${requestedTo} строк не найдено; показаны ближайшие доступные данные ${observed.period_from}..${observed.period_to}.`;
}
if (requestedFrom && requestedTo) {
return `По окну ${requestedFrom}..${requestedTo} строк не найдено; показаны ближайшие доступные данные по этому якорю.`;
}
return "По заданному периоду строк не найдено; показаны ближайшие доступные данные по этому якорю.";
}
function runtimeReadinessForLimitedCategory(category) {
if (category === "empty_match" || category === "missing_anchor") {
@ -449,90 +484,6 @@ function toLegacyMcpStatus(status) {
}
return status;
}
function resolvePrimaryAnchor(intent, filters) {
const account = typeof filters.account === "string" ? filters.account.trim() : "";
const counterparty = typeof filters.counterparty === "string" ? filters.counterparty.trim() : "";
const contract = typeof filters.contract === "string" ? filters.contract.trim() : "";
const documentRef = typeof filters.document_ref === "string" ? filters.document_ref.trim() : "";
if (intent === "account_balance_snapshot" || intent === "documents_forming_balance") {
if (account) {
return {
anchor_type: "account",
anchor_value_raw: account,
anchor_value_resolved: account,
resolver_confidence: "high",
ambiguity_count: 0
};
}
}
if (counterparty) {
return {
anchor_type: "counterparty",
anchor_value_raw: counterparty,
anchor_value_resolved: counterparty,
resolver_confidence: "medium",
ambiguity_count: 0
};
}
if (contract) {
return {
anchor_type: "contract",
anchor_value_raw: contract,
anchor_value_resolved: contract,
resolver_confidence: "medium",
ambiguity_count: 0
};
}
if (documentRef) {
return {
anchor_type: "document_ref",
anchor_value_raw: documentRef,
anchor_value_resolved: documentRef,
resolver_confidence: "medium",
ambiguity_count: 0
};
}
return {
anchor_type: "unknown",
anchor_value_raw: null,
anchor_value_resolved: null,
resolver_confidence: "low",
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-данных по текущему фильтру записи не найдены."
@ -601,124 +552,19 @@ function buildLimitedExecutionResult(input) {
}
};
}
function contractCandidatesFromRows(rows) {
const candidates = [];
for (const row of rows) {
for (const token of [row.registrator, ...row.analytics]) {
const normalized = token.trim();
if (!normalized) {
continue;
}
if (/договор|contract|дог\./i.test(normalized)) {
candidates.push(normalized);
}
}
}
return uniqueStrings(candidates);
}
function composeFactualReply(intent, rows) {
if (intent === "account_balance_snapshot") {
const movementSum = rows.reduce((sum, row) => sum + (row.amount ?? 0), 0);
const lines = [
"Адресный срез по счету собран (по движениям live MCP).",
`Строк отобрано: ${rows.length}.`,
`Сумма по отобранным движениям: ${movementSum}.`,
...formatTopRows(rows, 4)
];
return {
responseType: "FACTUAL_SUMMARY",
text: lines.join("\n")
};
}
if (intent === "documents_forming_balance") {
const movementSum = rows.reduce((sum, row) => sum + (row.amount ?? 0), 0);
const lines = [
"Собран drilldown документов, формирующих остаток по счету на указанную дату.",
`Документных строк отобрано: ${rows.length}.`,
`Сумма по отобранным движениям: ${movementSum}.`,
...formatTopRows(rows, 8),
"Можно уточнить выборку по контрагенту, договору или периоду."
];
return {
responseType: "FACTUAL_LIST",
text: lines.join("\n")
};
}
if (intent === "list_open_contracts") {
const contracts = contractCandidatesFromRows(rows);
const lines = [
"Собраны кандидаты по незакрытым договорным позициям (по live движениям 60/62/76).",
`Строк движения: ${rows.length}.`,
`Договорных кандидатов: ${contracts.length}.`
];
lines.push(...contracts.slice(0, 8).map((item, index) => `${index + 1}. ${item}`));
return {
responseType: "FACTUAL_LIST",
text: lines.join("\n")
};
}
if (intent === "open_items_by_counterparty_or_contract") {
const lines = [
"Собраны открытые позиции по указанному фильтру (контрагент/договор).",
`Строк отобрано: ${rows.length}.`,
...formatTopRows(rows, 6)
];
return {
responseType: "FACTUAL_LIST",
text: lines.join("\n")
};
}
if (intent === "list_documents_by_counterparty") {
const lines = [
"Собран список документов по контрагенту (live address lane).",
`Строк отобрано: ${rows.length}.`,
...formatTopRows(rows, 8)
];
return {
responseType: "FACTUAL_LIST",
text: lines.join("\n")
};
}
if (intent === "bank_operations_by_counterparty") {
const lines = [
"Собран список банковских операций по контрагенту (live address lane).",
`Строк отобрано: ${rows.length}.`,
...formatTopRows(rows, 8)
];
return {
responseType: "FACTUAL_LIST",
text: lines.join("\n")
};
}
const title = intent === "list_payables_counterparties"
? "Срез обязательств (payables) собран по движениям с account scope 60/76."
: intent === "list_receivables_counterparties"
? "Срез требований (receivables) собран по движениям с account scope 62/76."
: "Срез адресного запроса собран.";
const lines = [title, `Строк отобрано: ${rows.length}.`, ...formatTopRows(rows, 6)];
return {
responseType: "FACTUAL_LIST",
text: lines.join("\n")
};
}
class AddressQueryService {
async tryHandle(userMessage) {
async tryHandle(userMessage, options = {}) {
if (!config_1.FEATURE_ASSISTANT_ADDRESS_QUERY_V1) {
return null;
}
const mode = (0, addressQueryClassifier_1.detectAddressQuestionMode)(userMessage);
if (mode.mode !== "address_query") {
const followupContext = options.followupContext ?? null;
const decompose = (0, decomposeStage_1.runAddressDecomposeStage)(userMessage, followupContext);
if (!decompose) {
return null;
}
const shape = (0, addressQueryShapeClassifier_1.classifyAddressQueryShape)(userMessage);
if (shape.shape === "EXPLAIN_OR_REASON") {
return null;
}
const intent = (0, addressIntentResolver_1.resolveAddressIntent)(userMessage);
const filters = (0, addressFilterExtractor_1.extractAddressFilters)(userMessage, intent.intent);
let anchor = resolvePrimaryAnchor(intent.intent, filters.extracted_filters);
const { mode, shape, intent, filters, baseReasons } = decompose;
let anchor = (0, resolveStage_1.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") {
return buildLimitedExecutionResult({
mode,
@ -862,7 +708,7 @@ class AddressQueryService {
normalizedRawRows.length > 0 &&
scopedRows.length === 0;
const normalizedRows = accountScopeFallbackApplied ? normalizedRawRows : scopedRows;
anchor = refineAnchorFromRows(anchor, normalizedRows);
anchor = (0, resolveStage_1.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
@ -895,7 +741,7 @@ class AddressQueryService {
: 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) {
if (intent.intent === "list_open_contracts" && filteredRows.length > 0 && (0, composeStage_1.contractCandidatesFromRows)(filteredRows).length === 0) {
return buildLimitedExecutionResult({
mode,
shape,
@ -925,6 +771,105 @@ class AddressQueryService {
reasons: baseReasons
});
}
if (filteredRows.length === 0 && canAutoBroadenPeriodWindow(intent.intent, filters.extracted_filters)) {
const autoBroadenedFilters = { ...filters.extracted_filters };
delete autoBroadenedFilters.period_from;
delete autoBroadenedFilters.period_to;
const broadenedSelection = (0, addressRecipeCatalog_1.selectAddressRecipe)(intent.intent, autoBroadenedFilters);
if (broadenedSelection.selected_recipe && broadenedSelection.missing_required_filters.length === 0) {
const broadenedPlan = (0, addressRecipeCatalog_1.buildAddressRecipePlan)(broadenedSelection.selected_recipe, autoBroadenedFilters);
const broadenedMcp = await (0, addressMcpClient_1.executeAddressMcpQuery)({
query: broadenedPlan.query,
limit: broadenedPlan.limit
});
if (!broadenedMcp.error) {
const broadenedRawRows = toNormalizedRows(broadenedMcp.raw_rows);
const broadenedScopedRows = applyAccountScopeFilter(broadenedRawRows, broadenedPlan.account_scope);
const broadenedAccountScopeFallbackApplied = broadenedPlan.account_scope_mode === "preferred" &&
broadenedPlan.account_scope.length > 0 &&
broadenedRawRows.length > 0 &&
broadenedScopedRows.length === 0;
const broadenedNormalizedRows = broadenedAccountScopeFallbackApplied ? broadenedRawRows : broadenedScopedRows;
let broadenedAnchor = (0, resolveStage_1.resolvePrimaryAnchor)(intent.intent, autoBroadenedFilters);
broadenedAnchor = (0, resolveStage_1.refineAnchorFromRows)(broadenedAnchor, broadenedNormalizedRows);
const broadenedFiltersForMatching = broadenedAnchor.anchor_type === "counterparty" && broadenedAnchor.anchor_value_resolved
? { ...autoBroadenedFilters, counterparty: broadenedAnchor.anchor_value_resolved }
: broadenedAnchor.anchor_type === "contract" && broadenedAnchor.anchor_value_resolved
? { ...autoBroadenedFilters, contract: broadenedAnchor.anchor_value_resolved }
: autoBroadenedFilters;
const broadenedAccountScopeAudit = buildAccountScopeAudit({
intent: intent.intent,
filters: broadenedFiltersForMatching,
accountScope: broadenedPlan.account_scope,
rowsBeforeScope: broadenedRawRows.length,
rowsAfterScope: broadenedNormalizedRows.length
});
const broadenedAnchorFilter = applyAddressFilters(broadenedNormalizedRows, broadenedFiltersForMatching);
const broadenedRowsByAnchor = broadenedAnchorFilter.rows;
const broadenedFilteredRows = applyIntentSpecificFilter(intent.intent, broadenedRowsByAnchor);
if (broadenedFilteredRows.length > 0) {
const broadenedRowDiagnostics = deriveRowStageDiagnostics(broadenedMcp.raw_rows, broadenedNormalizedRows.length, broadenedNormalizedRows.length);
const broadenedStageStatus = deriveMcpStageStatus({
rawRowsReceived: broadenedMcp.raw_rows.length,
rowsMaterialized: broadenedNormalizedRows.length,
rowsAnchorMatched: broadenedRowsByAnchor.length,
rowsMatched: broadenedFilteredRows.length
});
const observedWindow = deriveObservedPeriodWindow(broadenedFilteredRows);
const broadenedPrefix = composeAutoBroadenedPeriodPrefix(filters.extracted_filters, observedWindow);
const broadenedFactual = (0, composeStage_1.composeFactualReply)(intent.intent, broadenedFilteredRows);
const broadenedLimitations = [...filters.warnings, "period_window_auto_broadened_to_available_data"];
const broadenedReasons = [...baseReasons, "period_window_auto_broadened_to_available_data"];
return {
handled: true,
reply_text: `${broadenedPrefix}\n${broadenedFactual.text}`,
reply_type: (0, composeStage_1.inferReplyType)(broadenedFactual.responseType),
response_type: broadenedFactual.responseType,
debug: {
detected_mode: mode.mode,
detected_mode_confidence: mode.confidence,
query_shape: shape.shape,
query_shape_confidence: shape.confidence,
detected_intent: intent.intent,
detected_intent_confidence: intent.confidence,
extracted_filters: filters.extracted_filters,
missing_required_filters: [],
selected_recipe: broadenedSelection.selected_recipe.recipe_id,
mcp_call_status_legacy: toLegacyMcpStatus(broadenedStageStatus),
account_scope_mode: broadenedPlan.account_scope_mode,
account_scope_fallback_applied: broadenedAccountScopeFallbackApplied,
anchor_type: broadenedAnchor.anchor_type,
anchor_value_raw: broadenedAnchor.anchor_value_raw,
anchor_value_resolved: broadenedAnchor.anchor_value_resolved,
resolver_confidence: broadenedAnchor.resolver_confidence,
ambiguity_count: broadenedAnchor.ambiguity_count,
match_failure_stage: "none",
match_failure_reason: null,
mcp_call_status: broadenedStageStatus,
rows_fetched: broadenedMcp.fetched_rows,
raw_rows_received: broadenedMcp.raw_rows.length,
rows_after_account_scope: broadenedNormalizedRows.length,
rows_after_recipe_filter: broadenedRowsByAnchor.length,
rows_materialized: broadenedNormalizedRows.length,
rows_matched: broadenedFilteredRows.length,
raw_row_keys_sample: broadenedRowDiagnostics.rawRowKeysSample,
materialization_drop_reason: broadenedRowDiagnostics.materializationDropReason,
account_token_raw: broadenedAccountScopeAudit.accountTokenRaw,
account_token_normalized: broadenedAccountScopeAudit.accountTokenNormalized,
account_scope_fields_checked: broadenedAccountScopeAudit.accountScopeFieldsChecked,
account_scope_match_strategy: broadenedAccountScopeAudit.accountScopeMatchStrategy,
account_scope_drop_reason: broadenedAccountScopeAudit.accountScopeDropReason,
runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS",
limited_reason_category: null,
response_type: broadenedFactual.responseType,
limitations: broadenedLimitations,
reasons: broadenedReasons
}
};
}
}
}
}
if (filteredRows.length === 0) {
const hadBaseRows = normalizedRows.length > 0 || mcp.fetched_rows > 0;
const hadAnchorMatchedRows = filterByAnchors.length > 0;
@ -992,11 +937,11 @@ class AddressQueryService {
reasons: baseReasons
});
}
const factual = composeFactualReply(intent.intent, filteredRows);
const factual = (0, composeStage_1.composeFactualReply)(intent.intent, filteredRows);
return {
handled: true,
reply_text: factual.text,
reply_type: inferReplyType(factual.responseType),
reply_type: (0, composeStage_1.inferReplyType)(factual.responseType),
response_type: factual.responseType,
debug: {
detected_mode: mode.mode,

View File

@ -122,6 +122,8 @@ const BASE_RECIPES = [
account_scope_mode: "strict"
}
];
const ADDRESS_MAX_LIMIT_DEFAULT = 200;
const ADDRESS_MAX_LIMIT_EXTENDED = 1000;
function toDateTimeExpr(isoDate, endOfDay) {
const match = String(isoDate ?? "").match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (!match) {
@ -172,6 +174,12 @@ function shouldBoostLimitForAllTimeCounterparty(filters) {
(typeof filters.as_of_date === "string" && filters.as_of_date.trim().length > 0));
return !hasPeriod;
}
function maxLimitForIntent(intent) {
if (intent === "list_documents_by_counterparty" || intent === "bank_operations_by_counterparty") {
return ADDRESS_MAX_LIMIT_EXTENDED;
}
return ADDRESS_MAX_LIMIT_DEFAULT;
}
function selectAddressRecipe(intent, filters) {
const recipe = BASE_RECIPES.find((item) => item.intent === intent) ?? null;
if (!recipe) {
@ -192,14 +200,19 @@ function selectAddressRecipe(intent, filters) {
};
}
function buildAddressRecipePlan(recipe, filters) {
const maxLimit = maxLimitForIntent(recipe.intent);
const baseLimit = typeof filters.limit === "number" && Number.isFinite(filters.limit)
? Math.max(1, Math.min(200, Math.trunc(filters.limit)))
? Math.max(1, Math.min(maxLimit, Math.trunc(filters.limit)))
: recipe.default_limit;
const boostedLimit = (recipe.intent === "list_documents_by_counterparty" || recipe.intent === "bank_operations_by_counterparty") &&
shouldBoostLimitForAllTimeCounterparty(filters)
? Math.max(baseLimit, 200)
: baseLimit;
const resolvedLimit = Math.max(1, Math.min(200, boostedLimit));
? Math.max(baseLimit, maxLimit)
: (recipe.intent === "account_balance_snapshot" || recipe.intent === "documents_forming_balance") &&
typeof filters.account === "string" &&
filters.account.trim().length > 0
? Math.max(baseLimit, ADDRESS_MAX_LIMIT_DEFAULT)
: baseLimit;
const resolvedLimit = Math.max(1, Math.min(maxLimit, boostedLimit));
const accountScope = (recipe.intent === "account_balance_snapshot" || recipe.intent === "documents_forming_balance") && filters.account
? [String(filters.account)]
: Array.isArray(recipe.account_scope)

View File

@ -0,0 +1,125 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.contractCandidatesFromRows = contractCandidatesFromRows;
exports.composeFactualReply = composeFactualReply;
exports.inferReplyType = inferReplyType;
function uniqueStrings(values) {
return Array.from(new Set(values
.map((item) => item.trim())
.filter((item) => item.length > 0)));
}
function formatTopRows(rows, limit = 6) {
return rows.slice(0, limit).map((row, index) => {
const period = row.period ?? "дата не указана";
const amount = row.amount !== null ? `${row.amount}` : "сумма не указана";
const accounts = [row.account_dt ?? "-", row.account_kt ?? "-"].join(" / ");
const analytics = row.analytics.length > 0 ? ` | аналитика: ${row.analytics.slice(0, 2).join("; ")}` : "";
return `${index + 1}. ${period} | ${row.registrator} | ${accounts} | ${amount}${analytics}`;
});
}
function contractCandidatesFromRows(rows) {
const candidates = [];
for (const row of rows) {
for (const token of [row.registrator, ...row.analytics]) {
const normalized = token.trim();
if (!normalized) {
continue;
}
if (/договор|contract|дог\./i.test(normalized)) {
candidates.push(normalized);
}
}
}
return uniqueStrings(candidates);
}
function composeFactualReply(intent, rows) {
if (intent === "account_balance_snapshot") {
const movementSum = rows.reduce((sum, row) => sum + (row.amount ?? 0), 0);
const lines = [
"Адресный срез по счету собран (по движениям live MCP).",
`Строк отобрано: ${rows.length}.`,
`Сумма по отобранным движениям: ${movementSum}.`,
...formatTopRows(rows, 4)
];
return {
responseType: "FACTUAL_SUMMARY",
text: lines.join("\n")
};
}
if (intent === "documents_forming_balance") {
const movementSum = rows.reduce((sum, row) => sum + (row.amount ?? 0), 0);
const lines = [
"Собран drilldown документов, формирующих остаток по счету на указанную дату.",
`Документных строк отобрано: ${rows.length}.`,
`Сумма по отобранным движениям: ${movementSum}.`,
...formatTopRows(rows, 8),
"Можно уточнить выборку по контрагенту, договору или периоду."
];
return {
responseType: "FACTUAL_LIST",
text: lines.join("\n")
};
}
if (intent === "list_open_contracts") {
const contracts = contractCandidatesFromRows(rows);
const lines = [
"Собраны кандидаты по незакрытым договорным позициям (по live движениям 60/62/76).",
`Строк движения: ${rows.length}.`,
`Договорных кандидатов: ${contracts.length}.`
];
lines.push(...contracts.slice(0, 8).map((item, index) => `${index + 1}. ${item}`));
return {
responseType: "FACTUAL_LIST",
text: lines.join("\n")
};
}
if (intent === "open_items_by_counterparty_or_contract") {
const lines = [
"Собраны открытые позиции по указанному фильтру (контрагент/договор).",
`Строк отобрано: ${rows.length}.`,
...formatTopRows(rows, 6)
];
return {
responseType: "FACTUAL_LIST",
text: lines.join("\n")
};
}
if (intent === "list_documents_by_counterparty") {
const lines = [
"Собран список документов по контрагенту (live address lane).",
`Строк отобрано: ${rows.length}.`,
...formatTopRows(rows, rows.length)
];
return {
responseType: "FACTUAL_LIST",
text: lines.join("\n")
};
}
if (intent === "bank_operations_by_counterparty") {
const lines = [
"Собран список банковских операций по контрагенту (live address lane).",
`Строк отобрано: ${rows.length}.`,
...formatTopRows(rows, rows.length)
];
return {
responseType: "FACTUAL_LIST",
text: lines.join("\n")
};
}
const title = intent === "list_payables_counterparties"
? "Срез обязательств (payables) собран по движениям с account scope 60/76."
: intent === "list_receivables_counterparties"
? "Срез требований (receivables) собран по движениям с account scope 62/76."
: "Срез адресного запроса собран.";
const lines = [title, `Строк отобрано: ${rows.length}.`, ...formatTopRows(rows, 6)];
return {
responseType: "FACTUAL_LIST",
text: lines.join("\n")
};
}
function inferReplyType(responseType) {
if (responseType === "FACTUAL_LIST" || responseType === "FACTUAL_SUMMARY") {
return "factual";
}
return "partial_coverage";
}

View File

@ -0,0 +1,181 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.hasAddressFollowupContextSignal = hasAddressFollowupContextSignal;
exports.runAddressDecomposeStage = runAddressDecomposeStage;
const addressQueryClassifier_1 = require("../addressQueryClassifier");
const addressQueryShapeClassifier_1 = require("../addressQueryShapeClassifier");
const addressIntentResolver_1 = require("../addressIntentResolver");
const addressFilterExtractor_1 = require("../addressFilterExtractor");
function hasExplicitPeriodWindow(filters) {
return ((typeof filters.period_from === "string" && filters.period_from.trim().length > 0) ||
(typeof filters.period_to === "string" && filters.period_to.trim().length > 0));
}
function toNonEmptyString(value) {
if (value === null || value === undefined) {
return null;
}
const normalized = String(value).trim();
return normalized.length > 0 ? normalized : null;
}
function hasAllTimeHint(text) {
return /(?:за\s+вс[её]\s+время|за\s+весь\s+период|за\s+всю\s+истори(?:ю|и)|за\s+любой\s+период|for\s+all\s+time|all\s+time|for\s+entire\s+period|entire\s+period|for\s+any\s+period|any\s+period|for\s+full\s+history|full\s+history)/iu.test(String(text ?? ""));
}
function hasAddressFollowupContextSignal(text) {
const normalized = String(text ?? "").trim();
if (!normalized) {
return false;
}
if (hasAllTimeHint(normalized)) {
return true;
}
if (/(?:^|\s)(?:и|а\s+еще|а\s+ещё|еще|ещё|также|по\s+этому|по\s+тому|это\s+же|в\s+этом|тот\s+же|also|same|that)/iu.test(normalized)) {
return true;
}
return normalized.split(/\s+/).filter(Boolean).length <= 8;
}
function mergeFollowupFilters(current, intent, userMessage, followupContext) {
const merged = { ...current };
const reasons = [];
if (!followupContext) {
return { filters: merged, reasons };
}
const previous = followupContext.previous_filters ?? {};
const previousAnchorValue = toNonEmptyString(followupContext.previous_anchor_value);
const previousCounterparty = toNonEmptyString(previous.counterparty);
const previousContract = toNonEmptyString(previous.contract);
const previousAccount = toNonEmptyString(previous.account);
const allTimeRequested = hasAllTimeHint(userMessage);
if (intent === "list_documents_by_counterparty" || intent === "bank_operations_by_counterparty") {
if (!toNonEmptyString(merged.counterparty)) {
const inheritedCounterparty = previousCounterparty ??
(followupContext.previous_anchor_type === "counterparty" ? previousAnchorValue : null);
if (inheritedCounterparty) {
merged.counterparty = inheritedCounterparty;
reasons.push("counterparty_from_followup_context");
}
}
}
if (intent === "account_balance_snapshot" || intent === "documents_forming_balance") {
if (!toNonEmptyString(merged.account)) {
const inheritedAccount = previousAccount ??
(followupContext.previous_anchor_type === "account" ? previousAnchorValue : null);
if (inheritedAccount) {
merged.account = inheritedAccount;
reasons.push("account_from_followup_context");
}
}
}
if (intent === "open_items_by_counterparty_or_contract" || intent === "list_open_contracts") {
if (!toNonEmptyString(merged.contract)) {
const inheritedContract = previousContract ??
(followupContext.previous_anchor_type === "contract" ? previousAnchorValue : null);
if (inheritedContract) {
merged.contract = inheritedContract;
reasons.push("contract_from_followup_context");
}
}
if (!toNonEmptyString(merged.counterparty)) {
const inheritedCounterparty = previousCounterparty ??
(followupContext.previous_anchor_type === "counterparty" ? previousAnchorValue : null);
if (inheritedCounterparty) {
merged.counterparty = inheritedCounterparty;
reasons.push("counterparty_from_followup_context");
}
}
}
if (allTimeRequested) {
if (toNonEmptyString(merged.period_from) || toNonEmptyString(merged.period_to)) {
delete merged.period_from;
delete merged.period_to;
reasons.push("period_cleared_by_all_time_followup");
}
return { filters: merged, reasons };
}
const currentHasPeriod = hasExplicitPeriodWindow(merged);
const previousHasPeriod = hasExplicitPeriodWindow(previous);
if (!currentHasPeriod && previousHasPeriod && hasAddressFollowupContextSignal(userMessage)) {
if (toNonEmptyString(previous.period_from)) {
merged.period_from = previous.period_from;
}
if (toNonEmptyString(previous.period_to)) {
merged.period_to = previous.period_to;
}
reasons.push("period_from_followup_context");
}
return { filters: merged, reasons };
}
function resolveMissingRequiredFilters(intent, filters) {
const requiredByIntent = {
account_balance_snapshot: ["account", "as_of_date"],
documents_forming_balance: ["account", "as_of_date"],
list_documents_by_counterparty: ["counterparty"],
bank_operations_by_counterparty: ["counterparty"]
};
const required = requiredByIntent[intent] ?? [];
return required.filter((key) => {
const value = filters[key];
return value === undefined || value === null || String(value).trim() === "";
});
}
function deriveIntentWithFollowupContext(detectedIntent, userMessage, followupContext) {
if (!followupContext || !followupContext.previous_intent) {
return detectedIntent;
}
if (detectedIntent.intent !== "unknown") {
return detectedIntent;
}
if (!hasAddressFollowupContextSignal(userMessage)) {
return detectedIntent;
}
return {
intent: followupContext.previous_intent,
confidence: "low",
reasons: [...detectedIntent.reasons, "intent_from_followup_context"]
};
}
function runAddressDecomposeStage(userMessage, followupContext) {
const detectedMode = (0, addressQueryClassifier_1.detectAddressQuestionMode)(userMessage);
const mode = detectedMode.mode === "address_query"
? detectedMode
: followupContext && hasAddressFollowupContextSignal(userMessage)
? {
mode: "address_query",
confidence: "medium",
reasons: [...detectedMode.reasons, "address_mode_from_followup_context"]
}
: detectedMode;
if (mode.mode !== "address_query") {
return null;
}
const shape = (0, addressQueryShapeClassifier_1.classifyAddressQueryShape)(userMessage);
if (shape.shape === "EXPLAIN_OR_REASON") {
return null;
}
const detectedIntent = (0, addressIntentResolver_1.resolveAddressIntent)(userMessage);
const intent = deriveIntentWithFollowupContext(detectedIntent, userMessage, followupContext);
const extractedFilters = (0, addressFilterExtractor_1.extractAddressFilters)(userMessage, intent.intent);
const followupMerged = mergeFollowupFilters(extractedFilters.extracted_filters, intent.intent, userMessage, followupContext);
const filters = {
extracted_filters: followupMerged.filters,
missing_required_filters: resolveMissingRequiredFilters(intent.intent, followupMerged.filters),
warnings: [...new Set([...extractedFilters.warnings, ...followupMerged.reasons])]
};
const followupContextApplied = Boolean(followupContext) &&
(mode.reasons.includes("address_mode_from_followup_context") ||
intent.reasons.includes("intent_from_followup_context") ||
followupMerged.reasons.length > 0);
const baseReasons = [
...mode.reasons,
...shape.reasons,
...intent.reasons,
...followupMerged.reasons,
...(followupContextApplied ? ["address_followup_context_applied"] : [])
];
return {
mode,
shape,
intent,
filters,
baseReasons
};
}

View File

@ -0,0 +1,179 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.resolvePrimaryAnchor = resolvePrimaryAnchor;
exports.refineAnchorFromRows = refineAnchorFromRows;
const PARTY_ANCHOR_STOPWORDS = new Set([
"ооо",
"ао",
"зао",
"ип",
"llc",
"ltd",
"company",
"компания",
"контрагент",
"counterparty",
"по",
"by"
]);
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 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 uniqueStrings(values) {
return Array.from(new Set(values
.map((item) => item.trim())
.filter((item) => item.length > 0)));
}
function resolvePrimaryAnchor(intent, filters) {
const account = typeof filters.account === "string" ? filters.account.trim() : "";
const counterparty = typeof filters.counterparty === "string" ? filters.counterparty.trim() : "";
const contract = typeof filters.contract === "string" ? filters.contract.trim() : "";
const documentRef = typeof filters.document_ref === "string" ? filters.document_ref.trim() : "";
if (intent === "account_balance_snapshot" || intent === "documents_forming_balance") {
if (account) {
return {
anchor_type: "account",
anchor_value_raw: account,
anchor_value_resolved: account,
resolver_confidence: "high",
ambiguity_count: 0
};
}
}
if (counterparty) {
return {
anchor_type: "counterparty",
anchor_value_raw: counterparty,
anchor_value_resolved: counterparty,
resolver_confidence: "medium",
ambiguity_count: 0
};
}
if (contract) {
return {
anchor_type: "contract",
anchor_value_raw: contract,
anchor_value_resolved: contract,
resolver_confidence: "medium",
ambiguity_count: 0
};
}
if (documentRef) {
return {
anchor_type: "document_ref",
anchor_value_raw: documentRef,
anchor_value_resolved: documentRef,
resolver_confidence: "medium",
ambiguity_count: 0
};
}
return {
anchor_type: "unknown",
anchor_value_raw: null,
anchor_value_resolved: null,
resolver_confidence: "low",
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
};
}

View File

@ -1731,8 +1731,9 @@ function buildAddressCoverageReport() {
out_of_scope_requirements: []
};
}
function buildAddressDebugPayload(addressDebug) {
function buildAddressDebugPayload(addressDebug, llmPreDecomposeMeta = null) {
const grounded = addressDebug.response_type === "LIMITED_WITH_REASON" ? "partial" : "grounded";
const llmMeta = llmPreDecomposeMeta && typeof llmPreDecomposeMeta === "object" ? llmPreDecomposeMeta : null;
return {
trace_id: `address-${(0, nanoid_1.nanoid)(10)}`,
prompt_version: "address_query_runtime_v1",
@ -1790,12 +1791,204 @@ function buildAddressDebugPayload(addressDebug) {
runtime_readiness: addressDebug.runtime_readiness,
limited_reason_category: addressDebug.limited_reason_category,
response_type: addressDebug.response_type,
execution_lane: "address_query",
llm_decomposition_applied: Boolean(llmMeta?.applied),
llm_decomposition_attempted: Boolean(llmMeta?.attempted),
llm_provider_used: llmMeta?.provider ?? null,
llm_decomposition_trace_id: llmMeta?.traceId ?? null,
llm_decomposition_effective_message: llmMeta?.effectiveMessage ?? null,
llm_decomposition_reason: llmMeta?.reason ?? null,
answer_structure_v11: null,
investigation_state_snapshot: null,
normalized: null,
normalizer_output: null
normalizer_output: llmMeta?.traceId
? {
trace_id: llmMeta.traceId,
prompt_version: "normalizer_v2_0_2",
applied: Boolean(llmMeta?.applied),
effective_message: llmMeta?.effectiveMessage ?? null
}
: null
};
}
function toNonEmptyString(value) {
if (value === null || value === undefined) {
return null;
}
const text = String(value).trim();
return text.length > 0 ? text : null;
}
function readAddressFilterString(addressDebug, key) {
const filters = addressDebug?.extracted_filters;
if (!filters || typeof filters !== "object") {
return null;
}
return toNonEmptyString(filters[key]);
}
function findLastAddressAssistantDebug(items) {
for (let index = items.length - 1; index >= 0; index -= 1) {
const item = items[index];
if (!item || item.role !== "assistant" || !item.debug) {
continue;
}
const debug = item.debug;
if (debug.detected_mode === "address_query" || debug.prompt_version === "address_query_runtime_v1") {
return debug;
}
}
return null;
}
function hasAddressFollowupContextSignal(userMessage) {
const text = compactWhitespace(String(userMessage ?? "").toLowerCase());
if (!text) {
return false;
}
if (/(?:за\s+вс[её]\s+время|за\s+весь\s+период|за\s+всю\s+истори(?:ю|и)|за\s+любой\s+период|for\s+all\s+time|all\s+time|for\s+entire\s+period|entire\s+period|for\s+any\s+period|any\s+period)/iu.test(text)) {
return true;
}
if (hasReferentialPointer(text)) {
return true;
}
const shortFollowup = countTokens(text) <= 8;
if (shortFollowup && hasFollowupMarker(text)) {
return true;
}
return false;
}
function resolveAddressFollowupCarryoverContext(userMessage, items) {
if (!hasAddressFollowupContextSignal(userMessage)) {
return null;
}
const previousAddressDebug = findLastAddressAssistantDebug(items);
if (!previousAddressDebug) {
return null;
}
const previousIntent = toNonEmptyString(previousAddressDebug.detected_intent);
const previousAnchorType = toNonEmptyString(previousAddressDebug.anchor_type);
const previousAnchor = toNonEmptyString(previousAddressDebug.anchor_value_resolved) ??
toNonEmptyString(previousAddressDebug.anchor_value_raw) ??
readAddressFilterString(previousAddressDebug, "counterparty") ??
readAddressFilterString(previousAddressDebug, "account") ??
readAddressFilterString(previousAddressDebug, "contract");
const previousFiltersRaw = previousAddressDebug.extracted_filters;
const previousFilters = previousFiltersRaw && typeof previousFiltersRaw === "object"
? { ...previousFiltersRaw }
: {};
if (!previousIntent && !previousAnchor && Object.keys(previousFilters).length === 0) {
return null;
}
return {
followupContext: {
previous_intent: previousIntent ?? undefined,
previous_filters: previousFilters,
previous_anchor_type: previousAnchorType ?? undefined,
previous_anchor_value: previousAnchor
},
previousAddressIntent: previousIntent,
previousAddressAnchor: previousAnchor
};
}
function isAddressLlmPreDecomposeCandidate(userMessage) {
const text = compactWhitespace(String(userMessage ?? "").toLowerCase());
if (!text) {
return false;
}
return /(?:\bдок\b|доки|документ|контрагент|договор|остаток|сч(?:е|ё)т|банк|выписк|платеж|оплат|поступлен|реализац|сверк|взаиморасч|кто\s+должен|show|list|documents?|counterparty|contract|account|balance|bank\s+operations?)/i.test(text);
}
function extractAddressQuestionFromNormalized(normalized) {
if (!normalized || typeof normalized !== "object") {
return null;
}
const source = normalized;
const fragments = Array.isArray(source.fragments) ? source.fragments : [];
for (const item of fragments) {
if (!item || typeof item !== "object") {
continue;
}
const fragment = item;
const domainRelevance = String(fragment.domain_relevance ?? "").trim().toLowerCase();
if (domainRelevance === "out_of_scope") {
continue;
}
const readiness = String(fragment.execution_readiness ?? "").trim().toLowerCase();
if (readiness === "no_route") {
continue;
}
const normalizedText = toNonEmptyString(fragment.normalized_fragment_text);
const rawText = toNonEmptyString(fragment.raw_fragment_text);
const candidate = compactWhitespace(normalizedText ?? rawText ?? "");
if (candidate.length >= 3 && candidate.length <= 500) {
return candidate;
}
}
return null;
}
async function runAddressLlmPreDecompose(normalizerService, payload, userMessage) {
const provider = payload?.llmProvider === "local" ? "local" : payload?.llmProvider === "openai" ? "openai" : null;
const baseMeta = {
attempted: false,
applied: false,
provider,
traceId: null,
effectiveMessage: userMessage,
reason: "not_attempted"
};
if (Boolean(payload?.useMock)) {
return {
...baseMeta,
reason: "skipped_in_mock"
};
}
if (!isAddressLlmPreDecomposeCandidate(userMessage)) {
return {
...baseMeta,
reason: "not_address_like"
};
}
const normalizePayload = {
llmProvider: payload?.llmProvider,
apiKey: payload?.apiKey,
model: payload?.model,
baseUrl: payload?.baseUrl,
temperature: 0,
maxOutputTokens: payload?.maxOutputTokens,
promptVersion: "normalizer_v2_0_2",
userQuestion: userMessage,
context: payload?.context,
useMock: Boolean(payload?.useMock),
retryPolicy: "single-pass-strict"
};
try {
const normalized = await normalizerService.normalize(normalizePayload);
const candidate = extractAddressQuestionFromNormalized(normalized?.normalized);
if (!normalized?.ok || !candidate) {
return {
...baseMeta,
attempted: true,
traceId: normalized?.trace_id ?? null,
reason: normalized?.ok ? "no_usable_fragment" : "normalize_failed"
};
}
const sourceCompact = compactWhitespace(String(userMessage ?? "").toLowerCase());
const candidateCompact = compactWhitespace(candidate.toLowerCase());
const applied = sourceCompact !== candidateCompact;
return {
attempted: true,
applied,
provider,
traceId: normalized?.trace_id ?? null,
effectiveMessage: applied ? candidate : userMessage,
reason: applied ? "normalized_fragment_applied" : "normalized_fragment_same"
};
}
catch (error) {
return {
...baseMeta,
attempted: true,
reason: `error:${error instanceof Error ? error.message : String(error)}`
};
}
}
class AssistantService {
normalizerService;
sessions;
@ -1827,80 +2020,112 @@ class AssistantService {
debug: null
};
this.sessions.appendItem(sessionId, userItem);
if (config_1.FEATURE_ASSISTANT_ADDRESS_QUERY_V1) {
const addressLane = await this.addressQueryService.tryHandle(userMessage);
if (addressLane?.handled) {
const debug = buildAddressDebugPayload(addressLane.debug);
const assistantItem = {
message_id: `msg-${(0, nanoid_1.nanoid)(10)}`,
session_id: sessionId,
role: "assistant",
text: addressLane.reply_text,
reply_type: addressLane.reply_type,
created_at: new Date().toISOString(),
trace_id: debug.trace_id,
debug
};
this.sessions.appendItem(sessionId, assistantItem);
const current = this.sessions.getSession(sessionId);
if (current) {
this.sessionLogger.persistSession(current);
}
const conversation = cloneItems(current?.items ?? []);
(0, log_1.logJson)({
timestamp: new Date().toISOString(),
level: "info",
service: "assistant_loop",
message: "assistant_message_processed",
sessionId,
eventType: "assistant_message_address",
details: {
session_id: sessionId,
message_id: assistantItem.message_id,
user_message: userMessage,
detected_mode: addressLane.debug.detected_mode,
query_shape: addressLane.debug.query_shape,
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,
rows_after_account_scope: addressLane.debug.rows_after_account_scope,
rows_after_recipe_filter: addressLane.debug.rows_after_recipe_filter,
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,
limitations: addressLane.debug.limitations,
assistant_reply: assistantItem.text,
reply_type: assistantItem.reply_type,
trace_id: assistantItem.trace_id
}
});
return {
ok: true,
const finalizeAddressLaneResponse = (addressLane, effectiveAddressUserMessage, carryoverMeta = null, llmPreDecomposeMeta = null) => {
const debug = buildAddressDebugPayload(addressLane.debug, llmPreDecomposeMeta);
const assistantItem = {
message_id: `msg-${(0, nanoid_1.nanoid)(10)}`,
session_id: sessionId,
role: "assistant",
text: addressLane.reply_text,
reply_type: addressLane.reply_type,
created_at: new Date().toISOString(),
trace_id: debug.trace_id,
debug
};
this.sessions.appendItem(sessionId, assistantItem);
const current = this.sessions.getSession(sessionId);
if (current) {
this.sessionLogger.persistSession(current);
}
const conversation = cloneItems(current?.items ?? []);
(0, log_1.logJson)({
timestamp: new Date().toISOString(),
level: "info",
service: "assistant_loop",
message: "assistant_message_processed",
sessionId,
eventType: "assistant_message_address",
details: {
session_id: sessionId,
message_id: assistantItem.message_id,
user_message: userMessage,
effective_address_user_message: effectiveAddressUserMessage,
address_followup_context_applied: Boolean(carryoverMeta),
address_followup_context_previous_intent: carryoverMeta?.previousAddressIntent ?? null,
address_followup_context_previous_anchor: carryoverMeta?.previousAddressAnchor ?? null,
address_llm_predecompose_attempted: Boolean(llmPreDecomposeMeta?.attempted),
address_llm_predecompose_applied: Boolean(llmPreDecomposeMeta?.applied),
address_llm_predecompose_provider: llmPreDecomposeMeta?.provider ?? null,
address_llm_predecompose_trace_id: llmPreDecomposeMeta?.traceId ?? null,
address_llm_predecompose_reason: llmPreDecomposeMeta?.reason ?? null,
detected_mode: addressLane.debug.detected_mode,
query_shape: addressLane.debug.query_shape,
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,
rows_after_account_scope: addressLane.debug.rows_after_account_scope,
rows_after_recipe_filter: addressLane.debug.rows_after_recipe_filter,
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,
limitations: addressLane.debug.limitations,
assistant_reply: assistantItem.text,
reply_type: assistantItem.reply_type,
conversation_item: assistantItem,
debug,
conversation
trace_id: assistantItem.trace_id
}
});
return {
ok: true,
session_id: sessionId,
assistant_reply: assistantItem.text,
reply_type: assistantItem.reply_type,
conversation_item: assistantItem,
debug,
conversation
};
};
if (config_1.FEATURE_ASSISTANT_ADDRESS_QUERY_V1) {
const addressPreDecompose = config_1.FEATURE_ASSISTANT_ADDRESS_QUERY_LLM_PREDECOMPOSE_V1
? await runAddressLlmPreDecompose(this.normalizerService, payload, userMessage)
: {
attempted: false,
applied: false,
provider: payload?.llmProvider === "local" ? "local" : payload?.llmProvider === "openai" ? "openai" : null,
traceId: null,
effectiveMessage: userMessage,
reason: "disabled_by_feature_flag"
};
const addressInputMessage = toNonEmptyString(addressPreDecompose?.effectiveMessage) ?? userMessage;
const primaryAddressLane = await this.addressQueryService.tryHandle(addressInputMessage);
if (primaryAddressLane?.handled) {
return finalizeAddressLaneResponse(primaryAddressLane, addressInputMessage, null, addressPreDecompose);
}
const carryover = resolveAddressFollowupCarryoverContext(userMessage, session.items);
if (carryover?.followupContext) {
const contextualAddressLane = await this.addressQueryService.tryHandle(addressInputMessage, {
followupContext: carryover.followupContext
});
if (contextualAddressLane?.handled) {
return finalizeAddressLaneResponse(contextualAddressLane, addressInputMessage, carryover, addressPreDecompose);
}
}
}
const followupBinding = config_1.FEATURE_ASSISTANT_INVESTIGATION_STATE_V1 &&
@ -1917,12 +2142,13 @@ class AssistantService {
usage: null
};
const normalizePayload = {
llmProvider: payload.llmProvider,
apiKey: payload.apiKey,
model: payload.model,
baseUrl: payload.baseUrl,
temperature: payload.temperature,
maxOutputTokens: payload.maxOutputTokens,
promptVersion: payload.promptVersion ?? "normalizer_v2_0_2",
promptVersion: payload.promptVersion ?? "address_query_runtime_v1",
systemPrompt: payload.systemPrompt,
developerPrompt: payload.developerPrompt,
domainPrompt: payload.domainPrompt,

View File

@ -871,6 +871,7 @@ class NormalizerService {
async normalize(payload) {
const traceId = (0, nanoid_1.nanoid)(14);
const startedAt = Date.now();
const llmProvider = payload.llmProvider === "local" ? "local" : "openai";
const model = payload.model ?? config_1.DEFAULT_MODEL;
const baseUrl = payload.baseUrl ?? config_1.DEFAULT_OPENAI_BASE_URL;
const temperature = payload.temperature ?? config_1.DEFAULT_TEMPERATURE;
@ -903,6 +904,7 @@ class NormalizerService {
else {
const apiKey = payload.apiKey ?? process.env.OPENAI_API_KEY;
const firstTry = await this.openaiClient.normalize({
llmProvider,
apiKey: String(apiKey ?? ""),
model,
baseUrl,
@ -946,6 +948,7 @@ class NormalizerService {
if (!payload.useMock && !validation.passed && canRetry) {
const retryMaxOutputTokens = computeRetryMaxOutputTokens(maxOutputTokens, rawModelResponse);
const retry = await this.openaiClient.normalize({
llmProvider,
apiKey: String(payload.apiKey ?? process.env.OPENAI_API_KEY ?? ""),
model,
baseUrl,

View File

@ -8,6 +8,20 @@ const fs_1 = __importDefault(require("fs"));
const path_1 = __importDefault(require("path"));
const config_1 = require("../config");
const http_1 = require("../utils/http");
function resolveProvider(config) {
return config.llmProvider === "local" ? "local" : "openai";
}
function resolveApiKey(config) {
const candidate = String(config.apiKey ?? "").trim();
if (candidate.length > 0) {
return candidate;
}
if (resolveProvider(config) === "local") {
// Local OpenAI-compatible servers often accept any token.
return "local-dev-token";
}
throw new http_1.ApiError("OPENAI_API_KEY_MISSING", "OpenAI API key is missing.", 400);
}
function extractUsage(raw) {
const usage = (raw.usage ?? {});
const input = Number(usage.input_tokens ?? usage.prompt_tokens ?? 0);
@ -19,7 +33,7 @@ function extractUsage(raw) {
total_tokens: Number.isFinite(total) ? total : 0
};
}
function extractOutputText(raw) {
function extractOutputTextFromResponses(raw) {
if (typeof raw.output_text === "string" && raw.output_text.trim().length > 0) {
return raw.output_text;
}
@ -51,7 +65,55 @@ function extractOutputText(raw) {
return nested.output_text;
}
}
throw new http_1.ApiError("OPENAI_OUTPUT_PARSE_FAILED", "Не удалось извлечь output_text из Responses API ответа.", 502, raw);
throw new http_1.ApiError("OPENAI_OUTPUT_PARSE_FAILED", "Failed to extract output_text from /responses payload.", 502, raw);
}
function extractOutputTextFromChatCompletions(raw) {
const choices = raw.choices;
if (!Array.isArray(choices) || choices.length === 0) {
throw new http_1.ApiError("OPENAI_OUTPUT_PARSE_FAILED", "Missing choices in /chat/completions payload.", 502, raw);
}
const first = choices[0];
if (!first || typeof first !== "object") {
throw new http_1.ApiError("OPENAI_OUTPUT_PARSE_FAILED", "Invalid first choice in /chat/completions payload.", 502, raw);
}
const message = first.message;
if (!message || typeof message !== "object") {
throw new http_1.ApiError("OPENAI_OUTPUT_PARSE_FAILED", "Missing message in /chat/completions payload.", 502, raw);
}
const content = message.content;
if (typeof content === "string" && content.trim().length > 0) {
return content;
}
if (Array.isArray(content)) {
const textParts = content
.map((item) => {
if (!item || typeof item !== "object") {
return "";
}
const block = item;
return typeof block.text === "string" ? block.text : "";
})
.filter((item) => item.trim().length > 0);
if (textParts.length > 0) {
return textParts.join("\n");
}
}
throw new http_1.ApiError("OPENAI_OUTPUT_PARSE_FAILED", "Failed to extract text from /chat/completions payload.", 502, raw);
}
function shouldFallbackToChatCompletions(error) {
if (!(error instanceof http_1.ApiError)) {
return false;
}
if (error.code !== "OPENAI_REQUEST_FAILED") {
return false;
}
const details = (error.details ?? {});
const status = Number(details.status ?? 0);
if ([404, 405, 501].includes(status)) {
return true;
}
const message = String(error.message ?? "").toLowerCase();
return message.includes("/responses") || message.includes("responses");
}
function loadSchemaForTransport(schemaVersion) {
const schemaFile = schemaVersion === "v1"
@ -64,19 +126,54 @@ function loadSchemaForTransport(schemaVersion) {
const schemaPath = path_1.default.resolve(config_1.SCHEMAS_DIR, schemaFile);
return JSON.parse(fs_1.default.readFileSync(schemaPath, "utf-8"));
}
function buildBaseUrlCandidates(config) {
const base = (config.baseUrl ?? config_1.DEFAULT_OPENAI_BASE_URL).replace(/\/+$/, "");
const provider = resolveProvider(config);
if (provider !== "local") {
return [base];
}
const hasVersionSuffix = /\/v\d+$/i.test(base);
if (hasVersionSuffix) {
return [base];
}
return Array.from(new Set([base, `${base}/v1`]));
}
class OpenAIResponsesClient {
async listModels(config) {
const payload = await this.getModels(config);
const data = Array.isArray(payload.data) ? payload.data : [];
const ids = data
.map((item) => {
if (!item || typeof item !== "object") {
return "";
}
return String(item.id ?? "").trim();
})
.filter((item) => item.length > 0);
return Array.from(new Set(ids));
}
async testConnection(config) {
const payload = {
const provider = resolveProvider(config);
if (provider === "local") {
try {
await this.getModels(config);
}
catch {
// Some local providers do not expose /models consistently; fallback to a tiny chat call.
await this.postChatCompletions(config, {
model: config.model,
messages: [{ role: "user", content: "ping" }],
max_tokens: 4,
temperature: 0
});
}
return { ok: true, model: config.model };
}
await this.postResponses(config, {
model: config.model,
input: [
{
role: "user",
content: [{ type: "input_text", text: "ping" }]
}
],
input: [{ role: "user", content: [{ type: "input_text", text: "ping" }] }],
max_output_tokens: 16
};
await this.post(config, payload);
});
return { ok: true, model: config.model };
}
async normalize(config, prompt) {
@ -91,7 +188,7 @@ class OpenAIResponsesClient {
const developerPrompt = prompt.controlledRetryInstruction
? `${prompt.developerPrompt}\n\n${prompt.controlledRetryInstruction}`
: prompt.developerPrompt;
const payload = {
const responsesPayload = {
model: config.model,
temperature: config.temperature ?? 0,
max_output_tokens: config.maxOutputTokens ?? 700,
@ -109,7 +206,7 @@ class OpenAIResponsesClient {
content: [
{
type: "input_text",
text: `${prompt.domainPrompt}\n\nПользовательский вопрос:\n${prompt.userQuestion}`
text: `${prompt.domainPrompt}\n\nUser question:\n${prompt.userQuestion}`
}
]
}
@ -123,44 +220,133 @@ class OpenAIResponsesClient {
}
}
};
const raw = await this.post(config, payload);
const outputText = extractOutputText(raw);
const provider = resolveProvider(config);
if (provider === "openai") {
const raw = await this.postResponses(config, responsesPayload);
return {
raw,
outputText: extractOutputTextFromResponses(raw),
usage: extractUsage(raw)
};
}
// local provider: prefer /responses if available, fallback to /chat/completions
try {
const raw = await this.postResponses(config, responsesPayload);
return {
raw,
outputText: extractOutputTextFromResponses(raw),
usage: extractUsage(raw)
};
}
catch (error) {
if (!shouldFallbackToChatCompletions(error)) {
throw error;
}
}
const chatPayload = {
model: config.model,
temperature: config.temperature ?? 0,
max_tokens: config.maxOutputTokens ?? 700,
response_format: { type: "json_object" },
messages: [
{
role: "system",
content: `${prompt.systemPrompt}\n\n${developerPrompt}`
},
{
role: "user",
content: `${prompt.domainPrompt}\n\nUser question:\n${prompt.userQuestion}\n\n` +
`Return only JSON that matches schema: ${schemaName}.`
}
]
};
const raw = await this.postChatCompletions(config, chatPayload);
return {
raw,
outputText,
outputText: extractOutputTextFromChatCompletions(raw),
usage: extractUsage(raw)
};
}
async post(config, payload) {
if (!config.apiKey || config.apiKey.trim().length < 10) {
throw new http_1.ApiError("OPENAI_API_KEY_MISSING", "API ключ OpenAI не задан или слишком короткий.", 400);
async getModels(config) {
return this.requestJson(config, "/models", "GET");
}
async postResponses(config, payload) {
return this.requestJson(config, "/responses", "POST", payload);
}
async postChatCompletions(config, payload) {
return this.requestJson(config, "/chat/completions", "POST", payload);
}
async requestJson(config, routePath, method, payload) {
const apiKey = resolveApiKey(config);
const baseCandidates = buildBaseUrlCandidates(config);
const canFallbackToAlternativeBase = resolveProvider(config) === "local" && baseCandidates.length > 1;
let lastNetworkError = null;
const headers = {
Authorization: `Bearer ${apiKey}`
};
if (method === "POST") {
headers["Content-Type"] = "application/json";
}
const url = `${(config.baseUrl ?? config_1.DEFAULT_OPENAI_BASE_URL).replace(/\/$/, "")}/responses`;
const response = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${config.apiKey}`,
"Content-Type": "application/json"
},
body: JSON.stringify(payload)
for (let index = 0; index < baseCandidates.length; index += 1) {
const base = baseCandidates[index];
const isLastCandidate = index === baseCandidates.length - 1;
const url = `${base}${routePath}`;
let response;
try {
response = await fetch(url, {
method,
headers,
body: method === "POST" ? JSON.stringify(payload ?? {}) : undefined
});
}
catch (error) {
lastNetworkError = error;
if (!isLastCandidate) {
continue;
}
throw new http_1.ApiError("OPENAI_REQUEST_FAILED", "Model endpoint is unreachable.", 502, {
route: routePath,
url,
reason: error instanceof Error ? error.message : String(error)
});
}
if (!response.ok && canFallbackToAlternativeBase && !isLastCandidate && [404, 405].includes(response.status)) {
continue;
}
const text = await response.text();
let data = {};
if (text.trim().length > 0) {
try {
data = JSON.parse(text);
}
catch {
if (!response.ok && canFallbackToAlternativeBase && !isLastCandidate && [404, 405].includes(response.status)) {
continue;
}
throw new http_1.ApiError("OPENAI_NON_JSON_RESPONSE", "Model endpoint returned non-JSON response.", 502, {
route: routePath,
url,
status: response.status,
body: text.slice(0, 500)
});
}
}
if (!response.ok) {
const errorObj = (data.error ?? {});
throw new http_1.ApiError("OPENAI_REQUEST_FAILED", String(errorObj.message ?? `Model endpoint failed: ${response.status}`), response.status, {
route: routePath,
url,
status: response.status,
type: errorObj.type ?? null,
code: errorObj.code ?? null
});
}
return data;
}
throw new http_1.ApiError("OPENAI_REQUEST_FAILED", "Model endpoint is unreachable.", 502, {
route: routePath,
reason: lastNetworkError instanceof Error ? lastNetworkError.message : String(lastNetworkError ?? "unknown")
});
const text = await response.text();
let data;
try {
data = JSON.parse(text);
}
catch {
throw new http_1.ApiError("OPENAI_NON_JSON_RESPONSE", "OpenAI вернул не-JSON ответ.", 502, { status: response.status, body: text.slice(0, 500) });
}
if (!response.ok) {
const errorObj = (data.error ?? {});
throw new http_1.ApiError("OPENAI_REQUEST_FAILED", String(errorObj.message ?? `OpenAI request failed with status ${response.status}`), response.status, {
status: response.status,
type: errorObj.type ?? null,
code: errorObj.code ?? null
});
}
return data;
}
}
exports.OpenAIResponsesClient = OpenAIResponsesClient;

View File

@ -12,6 +12,7 @@
"cors": "^2.8.5",
"dotenv": "^16.6.1",
"express": "^4.21.2",
"iconv-lite": "^0.7.0",
"llm-normalizer-workspace": "file:..",
"nanoid": "^5.1.5"
},
@ -1224,6 +1225,18 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/body-parser/node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@ -1923,15 +1936,19 @@
}
},
"node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3"
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/inherits": {
@ -2280,6 +2297,18 @@
"node": ">= 0.8"
}
},
"node_modules/raw-body/node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",

View File

@ -15,6 +15,7 @@
"cors": "^2.8.5",
"dotenv": "^16.6.1",
"express": "^4.21.2",
"iconv-lite": "^0.7.0",
"llm-normalizer-workspace": "file:..",
"nanoid": "^5.1.5"
},

View File

@ -95,6 +95,10 @@ export const FEATURE_ASSISTANT_ADDRESS_QUERY_V1 = toBooleanFlag(
process.env.FEATURE_ASSISTANT_ADDRESS_QUERY_V1,
true
);
export const FEATURE_ASSISTANT_ADDRESS_QUERY_LLM_PREDECOMPOSE_V1 = toBooleanFlag(
process.env.FEATURE_ASSISTANT_ADDRESS_QUERY_LLM_PREDECOMPOSE_V1,
true
);
export const FEATURE_ASSISTANT_ADDRESS_QUERY_LIVE_V1 = toBooleanFlag(
process.env.FEATURE_ASSISTANT_ADDRESS_QUERY_LIVE_V1,
true

View File

@ -1,4 +1,4 @@
import { Router } from "express";
import { NextFunction, Request, Response, Router } from "express";
import { DEFAULT_MODEL, DEFAULT_OPENAI_BASE_URL } from "../config";
import { OpenAIResponsesClient } from "../services/openaiResponsesClient";
import { ok } from "../utils/http";
@ -6,23 +6,76 @@ import { ok } from "../utils/http";
export function buildTestConnectionRouter(client: OpenAIResponsesClient): Router {
const router = Router();
router.post("/api/openai/test-connection", async (req, res, next) => {
const handler = async (req: Request, res: Response, next: NextFunction) => {
try {
const body = (req.body ?? {}) as Record<string, unknown>;
const llmProvider = body.llmProvider === "local" ? "local" : "openai";
const model = String(body.model ?? DEFAULT_MODEL);
const baseUrl = String(body.baseUrl ?? DEFAULT_OPENAI_BASE_URL);
const apiKey = String(body.apiKey ?? process.env.OPENAI_API_KEY ?? "");
const result = await client.testConnection({
llmProvider,
apiKey,
model,
baseUrl
});
let modelFound: boolean | null = null;
let modelsCount: number | null = null;
if (llmProvider === "local") {
try {
const models = await client.listModels({
llmProvider,
apiKey,
model,
baseUrl
});
modelsCount = models.length;
modelFound = models.includes(model);
} catch {
modelFound = null;
modelsCount = null;
}
}
ok(res, {
ok: true,
provider: llmProvider,
model: result.model,
model_found: modelFound,
models_count: modelsCount,
timestamp: new Date().toISOString()
});
} catch (error) {
next(error);
}
};
const listModelsHandler = async (req: Request, res: Response, next: NextFunction) => {
try {
const body = (req.body ?? {}) as Record<string, unknown>;
const models = await client.listModels({
llmProvider: body.llmProvider === "local" ? "local" : "openai",
apiKey: String(body.apiKey ?? process.env.OPENAI_API_KEY ?? ""),
model: String(body.model ?? DEFAULT_MODEL),
baseUrl: String(body.baseUrl ?? DEFAULT_OPENAI_BASE_URL)
});
ok(res, {
ok: true,
model: result.model,
models,
count: models.length,
timestamp: new Date().toISOString()
});
} catch (error) {
next(error);
}
});
};
router.post("/api/llm/test-connection", handler);
router.post("/api/llm/models", listModelsHandler);
// Backward-compatible route for old frontend builds.
router.post("/api/openai/test-connection", handler);
router.post("/api/openai/models", listModelsHandler);
return router;
}

View File

@ -2,13 +2,24 @@
const ACCOUNT_PATTERN = /(?:сч[её]т|счет|account)[^0-9]{0,12}(\d{2}(?:[.,]\d{1,2})?)/i;
const LIMIT_PATTERN = /(?:\btop\b|\blimit\b|\bпервые\b|\bтоп\b)\s*(\d{1,3})/i;
const COUNTERPARTY_PATTERN = /(?:по\s+контрагенту|контрагент(?:у|а)?|by\s+counterparty|counterparty)\s+([^\r\n,.;:]+)/i;
const COUNTERPARTY_PATTERN =
/(?:по\s+контрагенту|контрагент(?:у|а)?|по\s+контре|контра|по\s+компан(?:ии|ию|ия)|компан(?:ия|ии|ию)|по\s+организац(?:ии|ию|ия)|организац(?:ия|ии|ию)|по\s+поставщик(?:у|а)?|поставщик(?:у|а)?|по\s+клиент(?:у|а)?|клиент(?:у|а)?|по\s+покупател(?:ю|я)|покупател(?:ю|я)|по\s+партнер(?:у|а)?|партнер(?:у|а)?|by\s+counterparty|counterparty|by\s+company|company|by\s+supplier|supplier|by\s+vendor|vendor|by\s+customer|customer|by\s+client|client|by\s+partner|partner)\s+([^\r\n,.;:]+)/iu;
const CONTRACT_PATTERN = /(?:по\s+договору|договор(?:у|а)?\s*(?:№|#|n)?|by\s+contract|contract(?:\s*(?:no|number|#|n))?)\s+([^\r\n,.;:]+)/i;
const DATE_DMY_PATTERN = /\b(\d{1,2})[.\/-](\d{1,2})[.\/-](\d{2,4})\b/;
const DATE_YMD_PATTERN = /\b(20\d{2})[.\/-](\d{1,2})[.\/-](\d{1,2})\b/;
const PERIOD_RANGE_PATTERN_1 = /(?:from|с)\s+(\d{1,4}[.\/-]\d{1,2}[.\/-]\d{1,4})\s+(?:to|по)\s+(\d{1,4}[.\/-]\d{1,2}[.\/-]\d{1,4})/i;
const PERIOD_RANGE_PATTERN_2 =
/(?:between|за\s+период\s+с)\s+(\d{1,4}[.\/-]\d{1,2}[.\/-]\d{1,4})\s+(?:and|по)\s+(\d{1,4}[.\/-]\d{1,2}[.\/-]\d{1,4})/i;
const YEAR_RANGE_PATTERN =
/(?:за|for|с|from)?\s*(20\d{2})\s*(?:[-]|до|to|по)\s*(20\d{2})(?:\s*(?:г(?:од|ода)?\.?|year))?(?=[^\d]|$)/iu;
const YEAR_RANGE_LOOSE_PATTERN = /\b(20\d{2})\b\s*(?:[-]|до|to|по)\s*\b(20\d{2})\b/iu;
const YEAR_PERIOD_PATTERN =
/(?:за|for)\s*(20\d{2})(?!\s*(?:[-]|до|to|по)\s*20\d{2})\s*(?:г(?:од|ода)?\.?|year)?/iu;
const YEAR_PERIOD_SHORT_PATTERN = /(?:^|[\s,.;:!?()\-])(\d{2})\s*(?:г(?:од|ода)?\.?|year)(?=$|[\s,.;:!?()\-])/iu;
const YEAR_PERIOD_ANY_PATTERN =
/(?:^|[\s,.;:!?()\-])((?:19|20)\d{2})(?!\s*(?:[-]|до|to|по)\s*(?:19|20)\d{2})(?![.\/-]\d)(?:\s*(?:г(?:од|ода)?\.?|year))?(?=$|[\s,.;:!?()\-])/iu;
const MONTH_PERIOD_NUMERIC_PATTERN = /(?:за|for)\s*(0?[1-9]|1[0-2])[.\/-](20\d{2})/i;
const MONTH_PERIOD_NAME_PATTERN = /(?:за|for)\s+([a-zа-яё]+)\s+(20\d{2})(?:\s*г(?:од|ода|\\.)?)?/iu;
function toIsoDate(year: number, month: number, day: number): string | null {
if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) {
@ -68,6 +79,57 @@ function parseDateToken(token: string): string | undefined {
return undefined;
}
function resolveMonthByName(rawMonthName: string): number | undefined {
const token = String(rawMonthName ?? "").trim().toLowerCase();
if (!token) {
return undefined;
}
if (/^янв|^january|^jan/.test(token)) return 1;
if (/^фев|^february|^feb/.test(token)) return 2;
if (/^мар|^march|^mar/.test(token)) return 3;
if (/^апр|^april|^apr/.test(token)) return 4;
if (/^ма[йя]|^may/.test(token)) return 5;
if (/^июн|^june|^jun/.test(token)) return 6;
if (/^июл|^july|^jul/.test(token)) return 7;
if (/^авг|^august|^aug/.test(token)) return 8;
if (/^сен|^сент|^september|^sep/.test(token)) return 9;
if (/^окт|^october|^oct/.test(token)) return 10;
if (/^ноя|^november|^nov/.test(token)) return 11;
if (/^дек|^december|^dec/.test(token)) return 12;
return undefined;
}
function extractMonthPeriod(text: string): { period_from?: string; period_to?: string } {
const numericMatch = text.match(MONTH_PERIOD_NUMERIC_PATTERN);
if (numericMatch) {
const month = Number(numericMatch[1]);
const year = Number(numericMatch[2]);
if (month >= 1 && month <= 12 && year >= 2000 && year <= 2099) {
const lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate();
return {
period_from: `${year}-${String(month).padStart(2, "0")}-01`,
period_to: `${year}-${String(month).padStart(2, "0")}-${String(lastDay).padStart(2, "0")}`
};
}
}
const byNameMatch = text.match(MONTH_PERIOD_NAME_PATTERN);
if (byNameMatch) {
const month = resolveMonthByName(String(byNameMatch[1]));
const year = Number(byNameMatch[2]);
if (month && year >= 2000 && year <= 2099) {
const lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate();
return {
period_from: `${year}-${String(month).padStart(2, "0")}-01`,
period_to: `${year}-${String(month).padStart(2, "0")}-${String(lastDay).padStart(2, "0")}`
};
}
}
return {};
}
function extractPeriodRange(text: string): { period_from?: string; period_to?: string } {
const directMatch = text.match(PERIOD_RANGE_PATTERN_1) ?? text.match(PERIOD_RANGE_PATTERN_2);
if (!directMatch) {
@ -81,6 +143,70 @@ function extractPeriodRange(text: string): { period_from?: string; period_to?: s
};
}
function extractYearPeriod(text: string): { period_from?: string; period_to?: string } {
const match = text.match(YEAR_PERIOD_PATTERN);
if (match) {
const year = Number(match[1]);
if (!Number.isFinite(year) || year < 2000 || year > 2099) {
return {};
}
return {
period_from: `${year}-01-01`,
period_to: `${year}-12-31`
};
}
const relaxedYearMatch = text.match(YEAR_PERIOD_ANY_PATTERN);
if (relaxedYearMatch) {
const year = Number(relaxedYearMatch[1]);
if (Number.isFinite(year) && year >= 2000 && year <= 2099) {
return {
period_from: `${year}-01-01`,
period_to: `${year}-12-31`
};
}
}
const shortYearMatch = text.match(YEAR_PERIOD_SHORT_PATTERN);
if (!shortYearMatch) {
return {};
}
const shortYear = Number(shortYearMatch[1]);
if (!Number.isFinite(shortYear) || shortYear < 0 || shortYear > 99) {
return {};
}
const year = 2000 + shortYear;
return {
period_from: `${year}-01-01`,
period_to: `${year}-12-31`
};
}
function extractYearRangePeriod(text: string): { period_from?: string; period_to?: string } {
const match = text.match(YEAR_RANGE_PATTERN) ?? text.match(YEAR_RANGE_LOOSE_PATTERN);
if (!match) {
return {};
}
const leftYear = Number(match[1]);
const rightYear = Number(match[2]);
if (
!Number.isFinite(leftYear) ||
!Number.isFinite(rightYear) ||
leftYear < 2000 ||
leftYear > 2099 ||
rightYear < 2000 ||
rightYear > 2099
) {
return {};
}
const fromYear = Math.min(leftYear, rightYear);
const toYear = Math.max(leftYear, rightYear);
return {
period_from: `${fromYear}-01-01`,
period_to: `${toYear}-12-31`
};
}
function cleanupAnchorValue(value: string): string {
const normalized = String(value ?? "").trim();
if (!normalized) {
@ -95,11 +221,13 @@ function cleanupAnchorValue(value: string): string {
return normalized.replace(periodTailPattern, "").trim();
}
const allTimeTailPattern = /\s+за\s+вс[её]\s+время(?:\s+|$)[\s\S]*$/iu;
const allTimeTailPattern =
/\s+за\s+(?:вс[её]\s+время|весь\s+период|весь\s+срок|всю\s+истори(?:ю|и)|любой\s+период|любой\s+срок)(?:\s+|$)[\s\S]*$/iu;
if (allTimeTailPattern.test(normalized)) {
return normalized.replace(allTimeTailPattern, "").trim();
}
const allTimeTailPatternEn = /\s+(?:for\s+all\s+time|all\s+time)(?:\s+|$)[\s\S]*$/iu;
const allTimeTailPatternEn =
/\s+(?:for\s+all\s+time|all\s+time|for\s+entire\s+period|entire\s+period|for\s+any\s+period|any\s+period|for\s+full\s+history|full\s+history)(?:\s+|$)[\s\S]*$/iu;
if (allTimeTailPatternEn.test(normalized)) {
return normalized.replace(allTimeTailPatternEn, "").trim();
}
@ -112,7 +240,203 @@ function cleanupAnchorValue(value: string): string {
function hasAllTimeHint(text: string): boolean {
const value = String(text ?? "");
return /(?:за\s+вс[её]\s+время|for\s+all\s+time|all\s+time)/iu.test(value);
return /(?:за\s+вс[её]\s+время|за\s+весь\s+период|за\s+весь\s+срок|за\s+всю\s+истори(?:ю|и)|за\s+любой\s+период|за\s+любой\s+срок|for\s+all\s+time|all\s+time|for\s+entire\s+period|entire\s+period|for\s+any\s+period|any\s+period|for\s+full\s+history|full\s+history)/iu.test(value);
}
function extractLooseByAnchorValue(text: string): string | undefined {
const match = String(text ?? "").match(/(?:^|\s)по\s+([a-zа-яё][a-zа-яё0-9._-]{1,})(?=[\s,.;:!?)]|$)/iu);
if (!match) {
return undefined;
}
const token = String(match[1] ?? "").trim();
if (!token) {
return undefined;
}
const lowered = token.toLowerCase();
const stopWords = new Set([
"контрагенту",
"контрагента",
"контре",
"компании",
"компанию",
"организации",
"организацию",
"поставщику",
"поставщика",
"клиенту",
"клиента",
"покупателю",
"покупателя",
"партнеру",
"партнера",
"договору",
"договора",
"счету",
"счёту",
"дате",
"периоду",
"период",
"документам",
"докам",
"взаиморасчетам",
"взаиморасчётам"
]);
if (stopWords.has(lowered)) {
return undefined;
}
return token;
}
function isLikelyCounterpartyToken(rawToken: string): boolean {
const token = String(rawToken ?? "").trim();
const lowered = token.toLowerCase();
if (!token || token.length < 2) {
return false;
}
if (/^\d+$/.test(lowered)) {
return false;
}
if (/^(?:19|20)\d{2}$/.test(lowered)) {
return false;
}
const stopWords = new Set([
"за",
"с",
"по",
"на",
"и",
"или",
"док",
"доки",
"документ",
"документы",
"документов",
"банк",
"банковские",
"операции",
"платежи",
"платеж",
"платёж",
"контрагент",
"контрагенту",
"контрагента",
"компания",
"компании",
"организация",
"организации",
"год",
"года",
"г",
"плс",
"pls",
"пж",
"пжлст",
"пожалуйста",
"бля",
"блять",
"епт",
"ёпт",
"епта",
"нах",
"нахуй",
"покеж",
"покажи",
"выведи"
]);
return !stopWords.has(lowered);
}
function hasDocsOrBankSignal(text: string): boolean {
const lowered = String(text ?? "").toLowerCase();
return /(?:док(?:и|умент|ументы|ументов)|docs?|documents?|банк|выписк|платеж|платёж|оплат|transactions?|bank\s+ops|bank\s+operations?)/iu.test(
lowered
);
}
function extractCounterpartyFromFreeTextHeuristic(text: string): string | undefined {
if (!hasDocsOrBankSignal(text)) {
return undefined;
}
const tokens = String(text ?? "")
.split(/[^a-zа-яё0-9._-]+/iu)
.map((item) => item.trim())
.filter((item) => item.length > 0);
if (tokens.length === 0) {
return undefined;
}
const monthTokens = [
"янв",
"фев",
"мар",
"апр",
"май",
"июн",
"июл",
"авг",
"сен",
"сент",
"окт",
"ноя",
"дек",
"january",
"february",
"march",
"april",
"may",
"june",
"july",
"august",
"september",
"october",
"november",
"december"
];
for (const token of tokens) {
const lowered = token.toLowerCase();
if (!isLikelyCounterpartyToken(lowered)) {
continue;
}
if (/^\d{2}$/.test(lowered) || /^\d{4}$/.test(lowered)) {
continue;
}
if (monthTokens.some((prefix) => lowered.startsWith(prefix))) {
continue;
}
if (/(?:^за$|^for$|^from$|^to$|^по$|^с$|^год$|^года$|^г$|^year$)/iu.test(lowered)) {
continue;
}
return token;
}
return undefined;
}
function extractImplicitCounterpartyValue(text: string): string | undefined {
const input = String(text ?? "");
const beforeDocsMatch = input.match(
/(?:^|\s)([a-zа-яё][a-zа-яё0-9._-]{1,})\s+(?:док(?:и|ум(?:ент(?:ы|ов|ам|а)?)?)|docs?|documents?)(?=[\s,.;:!?)]|$)/iu
);
if (beforeDocsMatch) {
const candidate = String(beforeDocsMatch[1] ?? "").trim();
if (isLikelyCounterpartyToken(candidate)) {
return candidate;
}
}
const afterDocsMatch = input.match(
/(?:док(?:и|ум(?:ент(?:ы|ов|ам|а)?)?)|docs?|documents?)\s+(?:по\s+)?([a-zа-яё][a-zа-яё0-9._-]{1,})(?=[\s,.;:!?)]|$)/iu
);
if (afterDocsMatch) {
const candidate = String(afterDocsMatch[1] ?? "").trim();
if (isLikelyCounterpartyToken(candidate)) {
return candidate;
}
}
return undefined;
}
function shiftDaysIso(baseIso: string, deltaDays: number): string {
@ -156,6 +480,27 @@ export function extractAddressFilters(userMessage: string, intent: AddressIntent
if (counterpartyMatch) {
filters.counterparty = cleanupAnchorValue(String(counterpartyMatch[1]));
}
if (!filters.counterparty && (intent === "list_documents_by_counterparty" || intent === "bank_operations_by_counterparty")) {
const fallbackCounterparty = extractLooseByAnchorValue(text);
if (fallbackCounterparty) {
filters.counterparty = cleanupAnchorValue(fallbackCounterparty);
warnings.push("counterparty_anchor_derived_from_loose_by_phrase");
}
}
if (!filters.counterparty && (intent === "list_documents_by_counterparty" || intent === "bank_operations_by_counterparty")) {
const implicitCounterparty = extractImplicitCounterpartyValue(text);
if (implicitCounterparty) {
filters.counterparty = cleanupAnchorValue(implicitCounterparty);
warnings.push("counterparty_anchor_derived_from_implicit_phrase");
}
}
if (!filters.counterparty && (intent === "list_documents_by_counterparty" || intent === "bank_operations_by_counterparty")) {
const heuristicCounterparty = extractCounterpartyFromFreeTextHeuristic(text);
if (heuristicCounterparty) {
filters.counterparty = cleanupAnchorValue(heuristicCounterparty);
warnings.push("counterparty_anchor_derived_from_free_text_heuristic");
}
}
const contractMatch = text.match(CONTRACT_PATTERN);
if (contractMatch) {
@ -170,6 +515,33 @@ export function extractAddressFilters(userMessage: string, intent: AddressIntent
filters.period_to = periodRange.period_to;
}
if (!filters.period_from && !filters.period_to) {
const monthPeriod = extractMonthPeriod(text);
if (monthPeriod.period_from && monthPeriod.period_to) {
filters.period_from = monthPeriod.period_from;
filters.period_to = monthPeriod.period_to;
warnings.push("period_derived_from_month_phrase");
}
}
if (!filters.period_from && !filters.period_to) {
const yearRangePeriod = extractYearRangePeriod(text);
if (yearRangePeriod.period_from && yearRangePeriod.period_to) {
filters.period_from = yearRangePeriod.period_from;
filters.period_to = yearRangePeriod.period_to;
warnings.push("period_derived_from_year_range_phrase");
}
}
if (!filters.period_from && !filters.period_to) {
const yearPeriod = extractYearPeriod(text);
if (yearPeriod.period_from && yearPeriod.period_to) {
filters.period_from = yearPeriod.period_from;
filters.period_to = yearPeriod.period_to;
warnings.push("period_derived_from_year_phrase");
}
}
// If explicit period window exists, do not infer as_of_date from one of its boundary dates.
if (!filters.period_from && !filters.period_to) {
const asOfDate = extractAsOfDate(text);

View File

@ -67,9 +67,20 @@ const OPEN_ITEMS_HINTS = [
const DOCUMENTS_BY_COUNTERPARTY_HINTS = [
"documents by counterparty",
"docs by counterparty",
"documents by company",
"documents by supplier",
"documents by customer",
"documents by client",
"documents by partner",
"show documents by counterparty",
"list documents by counterparty",
"документы по",
"доступные документы",
"список документов",
"документ",
"доки",
"доки по",
"док по",
"по контрагент"
];
@ -77,16 +88,202 @@ const BANK_OPERATIONS_BY_COUNTERPARTY_HINTS = [
"bank operations by counterparty",
"bank payments by counterparty",
"payment orders by counterparty",
"bank operations by company",
"bank operations by supplier",
"bank operations by customer",
"show bank operations by counterparty",
"bank ops",
"transactions by counterparty",
"банков",
"выписк",
"платеж"
"платеж",
"платёж",
"оплат",
"списан",
"поступлен",
"движени"
];
function hasAny(text: string, patterns: string[]): boolean {
return patterns.some((item) => text.includes(item));
}
function isLikelyCounterpartyToken(rawToken: string): boolean {
const token = String(rawToken ?? "").trim().toLowerCase();
if (!token || token.length < 2) {
return false;
}
if (/^\d+$/.test(token)) {
return false;
}
if (/^(?:19|20)\d{2}$/.test(token)) {
return false;
}
const stopWords = new Set([
"за",
"с",
"по",
"на",
"и",
"или",
"док",
"доки",
"доки?",
"документ",
"документы",
"документов",
"банк",
"банковские",
"операции",
"платежи",
"платеж",
"платёж",
"контрагент",
"контрагенту",
"контрагента",
"компания",
"компании",
"организация",
"организации",
"год",
"года",
"г",
"плс",
"pls",
"пж",
"пжлст",
"пожалуйста",
"бля",
"блять",
"епт",
"ёпт",
"епта",
"нах",
"нахуй"
]);
return !stopWords.has(token);
}
function hasPartyAnchorMention(text: string): boolean {
return (
text.includes("контраг") ||
text.includes("контра") ||
text.includes("counterparty") ||
text.includes("компан") ||
text.includes("company") ||
text.includes("организац") ||
text.includes("supplier") ||
text.includes("vendor") ||
text.includes("customer") ||
text.includes("client") ||
text.includes("partner") ||
text.includes("поставщик") ||
text.includes("клиент") ||
text.includes("покупател") ||
text.includes("партнер")
);
}
function hasLooseByAnchorMention(text: string): boolean {
const match = text.match(/(?:^|\s)по\s+([a-zа-яё][a-zа-яё0-9._-]{1,})(?=[\s,.;:!?)]|$)/iu);
if (!match) {
return false;
}
const token = String(match[1] ?? "").toLowerCase();
if (!token) {
return false;
}
const stopWords = new Set([
"контрагенту",
"контрагента",
"контре",
"компании",
"компанию",
"организации",
"организацию",
"поставщику",
"поставщика",
"клиенту",
"клиента",
"покупателю",
"покупателя",
"партнеру",
"партнера",
"договору",
"договора",
"счету",
"счёту",
"дате",
"периоду",
"период",
"документам",
"докам"
]);
return !stopWords.has(token);
}
function hasImplicitCounterpartyAnchorAroundDocs(text: string): boolean {
const beforeDocsMatch = text.match(
/(?:^|\s)([a-zа-яё][a-zа-яё0-9._-]{1,})\s+(?:док(?:и|ум(?:ент(?:ы|ов|ам|а)?)?)|docs?|documents?)(?=[\s,.;:!?)]|$)/iu
);
if (beforeDocsMatch && isLikelyCounterpartyToken(String(beforeDocsMatch[1] ?? ""))) {
return true;
}
const afterDocsMatch = text.match(
/(?:док(?:и|ум(?:ент(?:ы|ов|ам|а)?)?)|docs?|documents?)\s+(?:по\s+)?([a-zа-яё][a-zа-яё0-9._-]{1,})(?=[\s,.;:!?)]|$)/iu
);
if (afterDocsMatch && isLikelyCounterpartyToken(String(afterDocsMatch[1] ?? ""))) {
return true;
}
return false;
}
function hasDocsOrBankSignal(text: string): boolean {
return /(?:док(?:и|умент|ументы|ументов)|docs?|documents?|банк|выписк|платеж|платёж|оплат|transactions?|bank\s+ops|bank\s+operations?)/iu.test(
text
);
}
function hasHeuristicCounterpartyAnchor(text: string): boolean {
if (!hasDocsOrBankSignal(text)) {
return false;
}
const tokens = String(text ?? "")
.split(/[^a-zа-яё0-9._-]+/iu)
.map((item) => item.trim())
.filter((item) => item.length > 0);
for (const token of tokens) {
const lowered = token.toLowerCase();
if (!isLikelyCounterpartyToken(lowered)) {
continue;
}
if (/^\d{2}$/.test(lowered) || /^\d{4}$/.test(lowered)) {
continue;
}
if (/(?:^за$|^for$|^from$|^to$|^по$|^с$|^год$|^года$|^г$|^year$)/iu.test(lowered)) {
continue;
}
return true;
}
return false;
}
function hasGenericAddressLookupSignal(text: string): boolean {
return (
/\bесть\b/iu.test(text) ||
/\bпокажи\b/iu.test(text) ||
/\bвыведи\b/iu.test(text) ||
/\bкакие\b/iu.test(text) ||
/\bчто(?:-|\s)?то\b/iu.test(text) ||
/за\s+любой\s+период/iu.test(text) ||
/за\s+вс[её]\s+время/iu.test(text) ||
/for\s+all\s+time/iu.test(text) ||
/all\s+time/iu.test(text)
);
}
function hasAccountNumberAnchor(text: string): boolean {
return /(?:account|сч[её]т|счет)\D{0,12}\d{2}(?:[.,]\d{1,2})?/i.test(text);
}
@ -128,7 +325,7 @@ export function resolveAddressIntent(userMessage: string): AddressIntentResoluti
if (
hasAny(text, BANK_OPERATIONS_BY_COUNTERPARTY_HINTS) &&
(text.includes("контраг") || text.includes("counterparty"))
(hasPartyAnchorMention(text) || hasLooseByAnchorMention(text) || hasHeuristicCounterpartyAnchor(text))
) {
return {
intent: "bank_operations_by_counterparty",
@ -139,7 +336,10 @@ export function resolveAddressIntent(userMessage: string): AddressIntentResoluti
if (
hasAny(text, DOCUMENTS_BY_COUNTERPARTY_HINTS) &&
(text.includes("контраг") || text.includes("counterparty"))
(hasPartyAnchorMention(text) ||
hasLooseByAnchorMention(text) ||
hasImplicitCounterpartyAnchorAroundDocs(text) ||
hasHeuristicCounterpartyAnchor(text))
) {
return {
intent: "list_documents_by_counterparty",
@ -148,6 +348,14 @@ export function resolveAddressIntent(userMessage: string): AddressIntentResoluti
};
}
if (hasLooseByAnchorMention(text) && hasGenericAddressLookupSignal(text)) {
return {
intent: "list_documents_by_counterparty",
confidence: "low",
reasons: ["generic_lookup_with_loose_anchor_fallback"]
};
}
if (hasAny(text, OPEN_ITEMS_HINTS) && (text.includes("контраг") || text.includes("договор") || text.includes("counterparty") || text.includes("contract"))) {
return {
intent: "open_items_by_counterparty_or_contract",

View File

@ -3,6 +3,7 @@
ASSISTANT_MCP_PROXY_URL,
ASSISTANT_MCP_TIMEOUT_MS
} from "../config";
import iconv from "iconv-lite";
interface McpExecuteQueryResponse {
success?: unknown;
@ -36,8 +37,81 @@ function parseFiniteNumber(value: unknown): number | null {
return null;
}
function textMojibakeScore(value: string): number {
const source = String(value ?? "");
const cyrillic = (source.match(/[А-Яа-яЁё]/g) ?? []).length;
const latin = (source.match(/[A-Za-z]/g) ?? []).length;
const hardMarkers = (source.match(/[Ѓѓ‚„…†‡€‰‹ЉЊЌЋЏ‘’“”•–—™љ›њќћџ]/g) ?? []).length;
const pairMarkers = (source.match(/(?:Р.|С.|Ð.|Ñ.)/g) ?? []).length;
return cyrillic + latin - hardMarkers * 3 - pairMarkers * 2;
}
function looksLikeMojibake(value: string): boolean {
const source = String(value ?? "");
if (!source.trim()) {
return false;
}
if (/[Ѓѓ‚„…†‡€‰‹ЉЊЌЋЏ‘’“”•–—™љ›њќћџ]/.test(source)) {
return true;
}
return (source.match(/(?:Р.|С.|Ð.|Ñ.)/g) ?? []).length >= 2;
}
function decodeUtf8FromWin1251Mojibake(value: string): string {
if (!looksLikeMojibake(value)) {
return value;
}
try {
const bytes = iconv.encode(value, "win1251");
const decoded = bytes.toString("utf8");
return textMojibakeScore(decoded) > textMojibakeScore(value) ? decoded : value;
} catch {
return value;
}
}
function decodeUtf8FromLatin1Mojibake(value: string): string {
if (!looksLikeMojibake(value)) {
return value;
}
try {
const decoded = Buffer.from(value, "latin1").toString("utf8");
return textMojibakeScore(decoded) > textMojibakeScore(value) ? decoded : value;
} catch {
return value;
}
}
function normalizeMojibakeString(value: string): string {
const fromWin1251 = decodeUtf8FromWin1251Mojibake(value);
return decodeUtf8FromLatin1Mojibake(fromWin1251);
}
function normalizeMojibakeValue(value: unknown): unknown {
if (typeof value === "string") {
return normalizeMojibakeString(value);
}
if (Array.isArray(value)) {
return value.map((item) => normalizeMojibakeValue(item));
}
if (value && typeof value === "object") {
const source = value as Record<string, unknown>;
const normalized: Record<string, unknown> = {};
for (const [key, raw] of Object.entries(source)) {
const repairedKey = normalizeMojibakeString(key);
normalized[repairedKey] = normalizeMojibakeValue(raw);
}
return normalized;
}
return value;
}
function normalizeMojibakeRows(rows: Array<Record<string, unknown>>): Array<Record<string, unknown>> {
return rows.map((row) => normalizeMojibakeValue(row) as Record<string, unknown>);
}
function parseRowsFromTextTable(source: string): Array<Record<string, unknown>> {
const normalized = String(source ?? "").replace(/\r/g, "").trim();
const normalized = normalizeMojibakeString(String(source ?? "")).replace(/\r/g, "").trim();
if (!normalized) {
return [];
}
@ -111,7 +185,7 @@ function parseRowsFromTextTable(source: string): Array<Record<string, unknown>>
rows.push(row);
}
return rows;
return normalizeMojibakeRows(rows);
}
function parseExecutePayload(payload: unknown): AddressMcpQueryResult {
@ -133,9 +207,11 @@ function parseExecutePayload(payload: unknown): AddressMcpQueryResult {
}
if (Array.isArray(source.data)) {
const rows = source.data
.map((item) => (item && typeof item === "object" ? (item as Record<string, unknown>) : null))
.filter((item): item is Record<string, unknown> => item !== null);
const rows = normalizeMojibakeRows(
source.data
.map((item) => (item && typeof item === "object" ? (item as Record<string, unknown>) : null))
.filter((item): item is Record<string, unknown> => item !== null)
);
return {
ok: true,
rows,
@ -152,9 +228,11 @@ function parseExecutePayload(payload: unknown): AddressMcpQueryResult {
}
if (source.data && typeof source.data === "object" && Array.isArray((source.data as { rows?: unknown }).rows)) {
const rows = ((source.data as { rows: unknown[] }).rows ?? [])
.map((item) => (item && typeof item === "object" ? (item as Record<string, unknown>) : null))
.filter((item): item is Record<string, unknown> => item !== null);
const rows = normalizeMojibakeRows(
((source.data as { rows: unknown[] }).rows ?? [])
.map((item) => (item && typeof item === "object" ? (item as Record<string, unknown>) : null))
.filter((item): item is Record<string, unknown> => item !== null)
);
return {
ok: true,
rows,

View File

@ -27,6 +27,13 @@ const ADDRESS_ACTION_TOKENS = [
const ADDRESS_ENTITY_TOKENS = [
"counterparty",
"counterparties",
"company",
"organization",
"supplier",
"vendor",
"customer",
"client",
"partner",
"contract",
"contracts",
"account",
@ -42,10 +49,22 @@ const ADDRESS_ENTITY_TOKENS = [
"owes",
"owed",
"контрагент",
"контра",
"компан",
"организац",
"поставщик",
"клиент",
"покупател",
"партнер",
"банк",
"выписк",
"операц",
"договор",
"счет",
"счёт",
"документ",
"доки",
"док",
"остаток",
"дебитор",
"кредитор",
@ -73,6 +92,56 @@ const DEEP_REASONING_TOKENS = [
"ошибк"
];
function hasLooseByAnchorMention(text: string): boolean {
const match = text.match(/(?:^|\s)по\s+([a-zа-яё][a-zа-яё0-9._-]{1,})(?=[\s,.;:!?)]|$)/iu);
if (!match) {
return false;
}
const token = String(match[1] ?? "").toLowerCase();
if (!token) {
return false;
}
const stopWords = new Set([
"контрагенту",
"контрагента",
"контре",
"компании",
"компанию",
"организации",
"организацию",
"поставщику",
"поставщика",
"клиенту",
"клиента",
"покупателю",
"покупателя",
"партнеру",
"партнера",
"договору",
"договора",
"счету",
"счёту",
"дате",
"периоду",
"период",
"документам",
"докам",
"взаиморасчетам",
"взаиморасчётам"
]);
return !stopWords.has(token);
}
function hasAddressFollowupSignal(text: string): boolean {
if (/(?:за\s+любой\s+период|за\s+вс[её]\s+время|for\s+all\s+time|all\s+time)/iu.test(text)) {
return true;
}
if (/(?:\bесть\s+что(?:-|\s)?то\b|\bесть\s+ли\b|\bчто\s+есть\b)/iu.test(text)) {
return true;
}
return false;
}
function hasAnyToken(text: string, tokens: string[]): boolean {
return tokens.some((token) => text.includes(token));
}
@ -90,6 +159,8 @@ export function detectAddressQuestionMode(userMessage: string): AddressModeDetec
const hasAddressAction = hasAnyToken(text, ADDRESS_ACTION_TOKENS);
const hasAddressEntity = hasAnyToken(text, ADDRESS_ENTITY_TOKENS);
const hasDeepReasoning = hasAnyToken(text, DEEP_REASONING_TOKENS);
const hasLooseByAnchor = hasLooseByAnchorMention(text);
const hasFollowupSignal = hasAddressFollowupSignal(text);
if (hasAddressAction && hasAddressEntity && !hasDeepReasoning) {
return {
@ -99,6 +170,14 @@ export function detectAddressQuestionMode(userMessage: string): AddressModeDetec
};
}
if (hasLooseByAnchor && (hasAddressAction || hasAddressEntity || hasFollowupSignal) && !hasDeepReasoning) {
return {
mode: "address_query",
confidence: "medium",
reasons: ["loose_by_anchor_detected", ...(hasFollowupSignal ? ["address_followup_signal_detected"] : [])]
};
}
if (hasAddressEntity && !hasDeepReasoning) {
return {
mode: "address_query",

View File

@ -13,12 +13,11 @@ import type {
AddressResponseType,
AddressRuntimeReadiness
} from "../types/addressQuery";
import { detectAddressQuestionMode } from "./addressQueryClassifier";
import { classifyAddressQueryShape } from "./addressQueryShapeClassifier";
import { resolveAddressIntent } from "./addressIntentResolver";
import { extractAddressFilters } from "./addressFilterExtractor";
import { buildAddressRecipePlan, selectAddressRecipe } from "./addressRecipeCatalog";
import { executeAddressMcpQuery } from "./addressMcpClient";
import { runAddressDecomposeStage, type AddressFollowupContext } from "./address_runtime/decomposeStage";
import { resolvePrimaryAnchor, refineAnchorFromRows, type AnchorResolutionDebug } from "./address_runtime/resolveStage";
import { composeFactualReply, contractCandidatesFromRows, inferReplyType } from "./address_runtime/composeStage";
interface NormalizedAddressRow {
period: string | null;
@ -29,6 +28,10 @@ interface NormalizedAddressRow {
analytics: string[];
}
interface AddressTryHandleOptions {
followupContext?: AddressFollowupContext | null;
}
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([
@ -388,21 +391,65 @@ function applyIntentSpecificFilter(intent: AddressIntent, rows: NormalizedAddres
return rows;
}
function formatTopRows(rows: NormalizedAddressRow[], limit = 6): string[] {
return rows.slice(0, limit).map((row, index) => {
const period = row.period ?? "дата не указана";
const amount = row.amount !== null ? `${row.amount}` : "сумма не указана";
const accounts = [row.account_dt ?? "-", row.account_kt ?? "-"].join(" / ");
const analytics = row.analytics.length > 0 ? ` | аналитика: ${row.analytics.slice(0, 2).join("; ")}` : "";
return `${index + 1}. ${period} | ${row.registrator} | ${accounts} | ${amount}${analytics}`;
});
function hasExplicitPeriodWindow(filters: AddressFilterSet): boolean {
return (
(typeof filters.period_from === "string" && filters.period_from.trim().length > 0) ||
(typeof filters.period_to === "string" && filters.period_to.trim().length > 0)
);
}
function inferReplyType(responseType: AddressResponseType): "factual" | "partial_coverage" {
if (responseType === "FACTUAL_LIST" || responseType === "FACTUAL_SUMMARY") {
return "factual";
function canAutoBroadenPeriodWindow(intent: AddressIntent, filters: AddressFilterSet): boolean {
if (!hasExplicitPeriodWindow(filters)) {
return false;
}
return "partial_coverage";
return intent === "list_documents_by_counterparty" || intent === "bank_operations_by_counterparty";
}
function toIsoDatePrefix(value: string | null): string | null {
if (!value) {
return null;
}
const normalized = String(value).trim();
if (!normalized) {
return null;
}
const match = normalized.match(/^(\d{4}-\d{2}-\d{2})/);
if (match) {
return match[1];
}
return null;
}
function deriveObservedPeriodWindow(rows: NormalizedAddressRow[]): { period_from: string | null; period_to: string | null } {
const dates = rows
.map((row) => toIsoDatePrefix(row.period))
.filter((item): item is string => Boolean(item))
.sort();
if (dates.length === 0) {
return {
period_from: null,
period_to: null
};
}
return {
period_from: dates[0],
period_to: dates[dates.length - 1]
};
}
function composeAutoBroadenedPeriodPrefix(
requested: AddressFilterSet,
observed: { period_from: string | null; period_to: string | null }
): string {
const requestedFrom = typeof requested.period_from === "string" ? requested.period_from : null;
const requestedTo = typeof requested.period_to === "string" ? requested.period_to : null;
if (requestedFrom && requestedTo && observed.period_from && observed.period_to) {
return `По окну ${requestedFrom}..${requestedTo} строк не найдено; показаны ближайшие доступные данные ${observed.period_from}..${observed.period_to}.`;
}
if (requestedFrom && requestedTo) {
return `По окну ${requestedFrom}..${requestedTo} строк не найдено; показаны ближайшие доступные данные по этому якорю.`;
}
return "По заданному периоду строк не найдено; показаны ближайшие доступные данные по этому якорю.";
}
function runtimeReadinessForLimitedCategory(category: AddressLimitedReasonCategory): AddressRuntimeReadiness {
@ -418,14 +465,6 @@ function runtimeReadinessForLimitedCategory(category: AddressLimitedReasonCatego
return "UNKNOWN";
}
interface AnchorResolutionDebug {
anchor_type: "account" | "counterparty" | "contract" | "document_ref" | "unknown" | null;
anchor_value_raw: string | null;
anchor_value_resolved: string | null;
resolver_confidence: "high" | "medium" | "low" | null;
ambiguity_count: number;
}
interface RowStageDiagnostics {
rawRowKeysSample: string[];
materializationDropReason:
@ -580,99 +619,6 @@ function toLegacyMcpStatus(
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() : "";
const contract = typeof filters.contract === "string" ? filters.contract.trim() : "";
const documentRef = typeof filters.document_ref === "string" ? filters.document_ref.trim() : "";
if (intent === "account_balance_snapshot" || intent === "documents_forming_balance") {
if (account) {
return {
anchor_type: "account",
anchor_value_raw: account,
anchor_value_resolved: account,
resolver_confidence: "high",
ambiguity_count: 0
};
}
}
if (counterparty) {
return {
anchor_type: "counterparty",
anchor_value_raw: counterparty,
anchor_value_resolved: counterparty,
resolver_confidence: "medium",
ambiguity_count: 0
};
}
if (contract) {
return {
anchor_type: "contract",
anchor_value_raw: contract,
anchor_value_resolved: contract,
resolver_confidence: "medium",
ambiguity_count: 0
};
}
if (documentRef) {
return {
anchor_type: "document_ref",
anchor_value_raw: documentRef,
anchor_value_resolved: documentRef,
resolver_confidence: "medium",
ambiguity_count: 0
};
}
return {
anchor_type: "unknown",
anchor_value_raw: null,
anchor_value_resolved: null,
resolver_confidence: "low",
ambiguity_count: 0
};
}
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"
@ -777,137 +723,20 @@ function buildLimitedExecutionResult(input: {
};
}
function contractCandidatesFromRows(rows: NormalizedAddressRow[]): string[] {
const candidates: string[] = [];
for (const row of rows) {
for (const token of [row.registrator, ...row.analytics]) {
const normalized = token.trim();
if (!normalized) {
continue;
}
if (/договор|contract|дог\./i.test(normalized)) {
candidates.push(normalized);
}
}
}
return uniqueStrings(candidates);
}
function composeFactualReply(intent: AddressIntent, rows: NormalizedAddressRow[]): { responseType: AddressResponseType; text: string } {
if (intent === "account_balance_snapshot") {
const movementSum = rows.reduce((sum, row) => sum + (row.amount ?? 0), 0);
const lines = [
"Адресный срез по счету собран (по движениям live MCP).",
`Строк отобрано: ${rows.length}.`,
`Сумма по отобранным движениям: ${movementSum}.`,
...formatTopRows(rows, 4)
];
return {
responseType: "FACTUAL_SUMMARY",
text: lines.join("\n")
};
}
if (intent === "documents_forming_balance") {
const movementSum = rows.reduce((sum, row) => sum + (row.amount ?? 0), 0);
const lines = [
"Собран drilldown документов, формирующих остаток по счету на указанную дату.",
`Документных строк отобрано: ${rows.length}.`,
`Сумма по отобранным движениям: ${movementSum}.`,
...formatTopRows(rows, 8),
"Можно уточнить выборку по контрагенту, договору или периоду."
];
return {
responseType: "FACTUAL_LIST",
text: lines.join("\n")
};
}
if (intent === "list_open_contracts") {
const contracts = contractCandidatesFromRows(rows);
const lines = [
"Собраны кандидаты по незакрытым договорным позициям (по live движениям 60/62/76).",
`Строк движения: ${rows.length}.`,
`Договорных кандидатов: ${contracts.length}.`
];
lines.push(...contracts.slice(0, 8).map((item, index) => `${index + 1}. ${item}`));
return {
responseType: "FACTUAL_LIST",
text: lines.join("\n")
};
}
if (intent === "open_items_by_counterparty_or_contract") {
const lines = [
"Собраны открытые позиции по указанному фильтру (контрагент/договор).",
`Строк отобрано: ${rows.length}.`,
...formatTopRows(rows, 6)
];
return {
responseType: "FACTUAL_LIST",
text: lines.join("\n")
};
}
if (intent === "list_documents_by_counterparty") {
const lines = [
"Собран список документов по контрагенту (live address lane).",
`Строк отобрано: ${rows.length}.`,
...formatTopRows(rows, 8)
];
return {
responseType: "FACTUAL_LIST",
text: lines.join("\n")
};
}
if (intent === "bank_operations_by_counterparty") {
const lines = [
"Собран список банковских операций по контрагенту (live address lane).",
`Строк отобрано: ${rows.length}.`,
...formatTopRows(rows, 8)
];
return {
responseType: "FACTUAL_LIST",
text: lines.join("\n")
};
}
const title =
intent === "list_payables_counterparties"
? "Срез обязательств (payables) собран по движениям с account scope 60/76."
: intent === "list_receivables_counterparties"
? "Срез требований (receivables) собран по движениям с account scope 62/76."
: "Срез адресного запроса собран.";
const lines = [title, `Строк отобрано: ${rows.length}.`, ...formatTopRows(rows, 6)];
return {
responseType: "FACTUAL_LIST",
text: lines.join("\n")
};
}
export class AddressQueryService {
public async tryHandle(userMessage: string): Promise<AddressExecutionResult | null> {
public async tryHandle(userMessage: string, options: AddressTryHandleOptions = {}): Promise<AddressExecutionResult | null> {
if (!FEATURE_ASSISTANT_ADDRESS_QUERY_V1) {
return null;
}
const mode = detectAddressQuestionMode(userMessage);
if (mode.mode !== "address_query") {
const followupContext = options.followupContext ?? null;
const decompose = runAddressDecomposeStage(userMessage, followupContext);
if (!decompose) {
return null;
}
const shape = classifyAddressQueryShape(userMessage);
if (shape.shape === "EXPLAIN_OR_REASON") {
return null;
}
const intent = resolveAddressIntent(userMessage);
const filters = extractAddressFilters(userMessage, intent.intent);
const { mode, shape, intent, filters, baseReasons } = decompose;
let anchor = resolvePrimaryAnchor(intent.intent, filters.extracted_filters);
const recipeSelection = selectAddressRecipe(intent.intent, filters.extracted_filters);
const baseReasons = [...mode.reasons, ...shape.reasons, ...intent.reasons];
if (intent.intent === "unknown") {
return buildLimitedExecutionResult({
@ -1130,6 +959,112 @@ export class AddressQueryService {
});
}
if (filteredRows.length === 0 && canAutoBroadenPeriodWindow(intent.intent, filters.extracted_filters)) {
const autoBroadenedFilters: AddressFilterSet = { ...filters.extracted_filters };
delete autoBroadenedFilters.period_from;
delete autoBroadenedFilters.period_to;
const broadenedSelection = selectAddressRecipe(intent.intent, autoBroadenedFilters);
if (broadenedSelection.selected_recipe && broadenedSelection.missing_required_filters.length === 0) {
const broadenedPlan = buildAddressRecipePlan(broadenedSelection.selected_recipe, autoBroadenedFilters);
const broadenedMcp = await executeAddressMcpQuery({
query: broadenedPlan.query,
limit: broadenedPlan.limit
});
if (!broadenedMcp.error) {
const broadenedRawRows = toNormalizedRows(broadenedMcp.raw_rows);
const broadenedScopedRows = applyAccountScopeFilter(broadenedRawRows, broadenedPlan.account_scope);
const broadenedAccountScopeFallbackApplied =
broadenedPlan.account_scope_mode === "preferred" &&
broadenedPlan.account_scope.length > 0 &&
broadenedRawRows.length > 0 &&
broadenedScopedRows.length === 0;
const broadenedNormalizedRows = broadenedAccountScopeFallbackApplied ? broadenedRawRows : broadenedScopedRows;
let broadenedAnchor = resolvePrimaryAnchor(intent.intent, autoBroadenedFilters);
broadenedAnchor = refineAnchorFromRows(broadenedAnchor, broadenedNormalizedRows);
const broadenedFiltersForMatching: AddressFilterSet =
broadenedAnchor.anchor_type === "counterparty" && broadenedAnchor.anchor_value_resolved
? { ...autoBroadenedFilters, counterparty: broadenedAnchor.anchor_value_resolved }
: broadenedAnchor.anchor_type === "contract" && broadenedAnchor.anchor_value_resolved
? { ...autoBroadenedFilters, contract: broadenedAnchor.anchor_value_resolved }
: autoBroadenedFilters;
const broadenedAccountScopeAudit = buildAccountScopeAudit({
intent: intent.intent,
filters: broadenedFiltersForMatching,
accountScope: broadenedPlan.account_scope,
rowsBeforeScope: broadenedRawRows.length,
rowsAfterScope: broadenedNormalizedRows.length
});
const broadenedAnchorFilter = applyAddressFilters(broadenedNormalizedRows, broadenedFiltersForMatching);
const broadenedRowsByAnchor = broadenedAnchorFilter.rows;
const broadenedFilteredRows = applyIntentSpecificFilter(intent.intent, broadenedRowsByAnchor);
if (broadenedFilteredRows.length > 0) {
const broadenedRowDiagnostics = deriveRowStageDiagnostics(
broadenedMcp.raw_rows,
broadenedNormalizedRows.length,
broadenedNormalizedRows.length
);
const broadenedStageStatus = deriveMcpStageStatus({
rawRowsReceived: broadenedMcp.raw_rows.length,
rowsMaterialized: broadenedNormalizedRows.length,
rowsAnchorMatched: broadenedRowsByAnchor.length,
rowsMatched: broadenedFilteredRows.length
});
const observedWindow = deriveObservedPeriodWindow(broadenedFilteredRows);
const broadenedPrefix = composeAutoBroadenedPeriodPrefix(filters.extracted_filters, observedWindow);
const broadenedFactual = composeFactualReply(intent.intent, broadenedFilteredRows);
const broadenedLimitations = [...filters.warnings, "period_window_auto_broadened_to_available_data"];
const broadenedReasons = [...baseReasons, "period_window_auto_broadened_to_available_data"];
return {
handled: true,
reply_text: `${broadenedPrefix}\n${broadenedFactual.text}`,
reply_type: inferReplyType(broadenedFactual.responseType),
response_type: broadenedFactual.responseType,
debug: {
detected_mode: mode.mode,
detected_mode_confidence: mode.confidence,
query_shape: shape.shape,
query_shape_confidence: shape.confidence,
detected_intent: intent.intent,
detected_intent_confidence: intent.confidence,
extracted_filters: filters.extracted_filters,
missing_required_filters: [],
selected_recipe: broadenedSelection.selected_recipe.recipe_id,
mcp_call_status_legacy: toLegacyMcpStatus(broadenedStageStatus),
account_scope_mode: broadenedPlan.account_scope_mode,
account_scope_fallback_applied: broadenedAccountScopeFallbackApplied,
anchor_type: broadenedAnchor.anchor_type,
anchor_value_raw: broadenedAnchor.anchor_value_raw,
anchor_value_resolved: broadenedAnchor.anchor_value_resolved,
resolver_confidence: broadenedAnchor.resolver_confidence,
ambiguity_count: broadenedAnchor.ambiguity_count,
match_failure_stage: "none",
match_failure_reason: null,
mcp_call_status: broadenedStageStatus,
rows_fetched: broadenedMcp.fetched_rows,
raw_rows_received: broadenedMcp.raw_rows.length,
rows_after_account_scope: broadenedNormalizedRows.length,
rows_after_recipe_filter: broadenedRowsByAnchor.length,
rows_materialized: broadenedNormalizedRows.length,
rows_matched: broadenedFilteredRows.length,
raw_row_keys_sample: broadenedRowDiagnostics.rawRowKeysSample,
materialization_drop_reason: broadenedRowDiagnostics.materializationDropReason,
account_token_raw: broadenedAccountScopeAudit.accountTokenRaw,
account_token_normalized: broadenedAccountScopeAudit.accountTokenNormalized,
account_scope_fields_checked: broadenedAccountScopeAudit.accountScopeFieldsChecked,
account_scope_match_strategy: broadenedAccountScopeAudit.accountScopeMatchStrategy,
account_scope_drop_reason: broadenedAccountScopeAudit.accountScopeDropReason,
runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS",
limited_reason_category: null,
response_type: broadenedFactual.responseType,
limitations: broadenedLimitations,
reasons: broadenedReasons
}
};
}
}
}
}
if (filteredRows.length === 0) {
const hadBaseRows = normalizedRows.length > 0 || mcp.fetched_rows > 0;
const hadAnchorMatchedRows = filterByAnchors.length > 0;

View File

@ -128,6 +128,9 @@ const BASE_RECIPES: AddressRecipeDefinition[] = [
}
];
const ADDRESS_MAX_LIMIT_DEFAULT = 200;
const ADDRESS_MAX_LIMIT_EXTENDED = 1000;
export interface AddressRecipeExecutionPlan {
recipe: AddressRecipeDefinition;
query: string;
@ -196,6 +199,13 @@ function shouldBoostLimitForAllTimeCounterparty(filters: AddressFilterSet): bool
return !hasPeriod;
}
function maxLimitForIntent(intent: AddressIntent): number {
if (intent === "list_documents_by_counterparty" || intent === "bank_operations_by_counterparty") {
return ADDRESS_MAX_LIMIT_EXTENDED;
}
return ADDRESS_MAX_LIMIT_DEFAULT;
}
export function selectAddressRecipe(intent: AddressIntent, filters: AddressFilterSet): AddressRecipeSelection {
const recipe = BASE_RECIPES.find((item) => item.intent === intent) ?? null;
if (!recipe) {
@ -222,16 +232,21 @@ export function buildAddressRecipePlan(
recipe: AddressRecipeDefinition,
filters: AddressFilterSet
): AddressRecipeExecutionPlan {
const maxLimit = maxLimitForIntent(recipe.intent);
const baseLimit =
typeof filters.limit === "number" && Number.isFinite(filters.limit)
? Math.max(1, Math.min(200, Math.trunc(filters.limit)))
? Math.max(1, Math.min(maxLimit, Math.trunc(filters.limit)))
: recipe.default_limit;
const boostedLimit =
(recipe.intent === "list_documents_by_counterparty" || recipe.intent === "bank_operations_by_counterparty") &&
shouldBoostLimitForAllTimeCounterparty(filters)
? Math.max(baseLimit, 200)
: baseLimit;
const resolvedLimit = Math.max(1, Math.min(200, boostedLimit));
? Math.max(baseLimit, maxLimit)
: (recipe.intent === "account_balance_snapshot" || recipe.intent === "documents_forming_balance") &&
typeof filters.account === "string" &&
filters.account.trim().length > 0
? Math.max(baseLimit, ADDRESS_MAX_LIMIT_DEFAULT)
: baseLimit;
const resolvedLimit = Math.max(1, Math.min(maxLimit, boostedLimit));
const accountScope =
(recipe.intent === "account_balance_snapshot" || recipe.intent === "documents_forming_balance") && filters.account

View File

@ -0,0 +1,150 @@
import type { AddressIntent, AddressResponseType } from "../../types/addressQuery";
export interface ComposeStageRow {
period: string | null;
registrator: string;
account_dt: string | null;
account_kt: string | null;
amount: number | null;
analytics: string[];
}
function uniqueStrings(values: string[]): string[] {
return Array.from(
new Set(
values
.map((item) => item.trim())
.filter((item) => item.length > 0)
)
);
}
function formatTopRows(rows: ComposeStageRow[], limit = 6): string[] {
return rows.slice(0, limit).map((row, index) => {
const period = row.period ?? "дата не указана";
const amount = row.amount !== null ? `${row.amount}` : "сумма не указана";
const accounts = [row.account_dt ?? "-", row.account_kt ?? "-"].join(" / ");
const analytics = row.analytics.length > 0 ? ` | аналитика: ${row.analytics.slice(0, 2).join("; ")}` : "";
return `${index + 1}. ${period} | ${row.registrator} | ${accounts} | ${amount}${analytics}`;
});
}
export function contractCandidatesFromRows(rows: ComposeStageRow[]): string[] {
const candidates: string[] = [];
for (const row of rows) {
for (const token of [row.registrator, ...row.analytics]) {
const normalized = token.trim();
if (!normalized) {
continue;
}
if (/договор|contract|дог\./i.test(normalized)) {
candidates.push(normalized);
}
}
}
return uniqueStrings(candidates);
}
export function composeFactualReply(
intent: AddressIntent,
rows: ComposeStageRow[]
): { responseType: AddressResponseType; text: string } {
if (intent === "account_balance_snapshot") {
const movementSum = rows.reduce((sum, row) => sum + (row.amount ?? 0), 0);
const lines = [
"Адресный срез по счету собран (по движениям live MCP).",
`Строк отобрано: ${rows.length}.`,
`Сумма по отобранным движениям: ${movementSum}.`,
...formatTopRows(rows, 4)
];
return {
responseType: "FACTUAL_SUMMARY",
text: lines.join("\n")
};
}
if (intent === "documents_forming_balance") {
const movementSum = rows.reduce((sum, row) => sum + (row.amount ?? 0), 0);
const lines = [
"Собран drilldown документов, формирующих остаток по счету на указанную дату.",
`Документных строк отобрано: ${rows.length}.`,
`Сумма по отобранным движениям: ${movementSum}.`,
...formatTopRows(rows, 8),
"Можно уточнить выборку по контрагенту, договору или периоду."
];
return {
responseType: "FACTUAL_LIST",
text: lines.join("\n")
};
}
if (intent === "list_open_contracts") {
const contracts = contractCandidatesFromRows(rows);
const lines = [
"Собраны кандидаты по незакрытым договорным позициям (по live движениям 60/62/76).",
`Строк движения: ${rows.length}.`,
`Договорных кандидатов: ${contracts.length}.`
];
lines.push(...contracts.slice(0, 8).map((item, index) => `${index + 1}. ${item}`));
return {
responseType: "FACTUAL_LIST",
text: lines.join("\n")
};
}
if (intent === "open_items_by_counterparty_or_contract") {
const lines = [
"Собраны открытые позиции по указанному фильтру (контрагент/договор).",
`Строк отобрано: ${rows.length}.`,
...formatTopRows(rows, 6)
];
return {
responseType: "FACTUAL_LIST",
text: lines.join("\n")
};
}
if (intent === "list_documents_by_counterparty") {
const lines = [
"Собран список документов по контрагенту (live address lane).",
`Строк отобрано: ${rows.length}.`,
...formatTopRows(rows, rows.length)
];
return {
responseType: "FACTUAL_LIST",
text: lines.join("\n")
};
}
if (intent === "bank_operations_by_counterparty") {
const lines = [
"Собран список банковских операций по контрагенту (live address lane).",
`Строк отобрано: ${rows.length}.`,
...formatTopRows(rows, rows.length)
];
return {
responseType: "FACTUAL_LIST",
text: lines.join("\n")
};
}
const title =
intent === "list_payables_counterparties"
? "Срез обязательств (payables) собран по движениям с account scope 60/76."
: intent === "list_receivables_counterparties"
? "Срез требований (receivables) собран по движениям с account scope 62/76."
: "Срез адресного запроса собран.";
const lines = [title, `Строк отобрано: ${rows.length}.`, ...formatTopRows(rows, 6)];
return {
responseType: "FACTUAL_LIST",
text: lines.join("\n")
};
}
export function inferReplyType(responseType: AddressResponseType): "factual" | "partial_coverage" {
if (responseType === "FACTUAL_LIST" || responseType === "FACTUAL_SUMMARY") {
return "factual";
}
return "partial_coverage";
}

View File

@ -0,0 +1,244 @@
import type {
AddressFilterSet,
AddressIntent,
AddressIntentResolution,
AddressModeDetection,
AddressQueryShapeDetection
} from "../../types/addressQuery";
import { detectAddressQuestionMode } from "../addressQueryClassifier";
import { classifyAddressQueryShape } from "../addressQueryShapeClassifier";
import { resolveAddressIntent } from "../addressIntentResolver";
import { extractAddressFilters } from "../addressFilterExtractor";
export interface AddressFollowupContext {
previous_intent?: AddressIntent;
previous_filters?: AddressFilterSet;
previous_anchor_type?: "account" | "counterparty" | "contract" | "document_ref" | "unknown" | null;
previous_anchor_value?: string | null;
}
export interface AddressDecomposeStageResult {
mode: AddressModeDetection;
shape: AddressQueryShapeDetection;
intent: AddressIntentResolution;
filters: {
extracted_filters: AddressFilterSet;
missing_required_filters: string[];
warnings: string[];
};
baseReasons: string[];
}
function hasExplicitPeriodWindow(filters: AddressFilterSet): boolean {
return (
(typeof filters.period_from === "string" && filters.period_from.trim().length > 0) ||
(typeof filters.period_to === "string" && filters.period_to.trim().length > 0)
);
}
function toNonEmptyString(value: unknown): string | null {
if (value === null || value === undefined) {
return null;
}
const normalized = String(value).trim();
return normalized.length > 0 ? normalized : null;
}
function hasAllTimeHint(text: string): boolean {
return /(?:за\s+вс[её]\s+время|за\s+весь\s+период|за\s+всю\s+истори(?:ю|и)|за\s+любой\s+период|for\s+all\s+time|all\s+time|for\s+entire\s+period|entire\s+period|for\s+any\s+period|any\s+period|for\s+full\s+history|full\s+history)/iu.test(
String(text ?? "")
);
}
export function hasAddressFollowupContextSignal(text: string): boolean {
const normalized = String(text ?? "").trim();
if (!normalized) {
return false;
}
if (hasAllTimeHint(normalized)) {
return true;
}
if (/(?:^|\s)(?:и|а\s+еще|а\s+ещё|еще|ещё|также|по\s+этому|по\s+тому|это\s+же|в\s+этом|тот\s+же|also|same|that)/iu.test(normalized)) {
return true;
}
return normalized.split(/\s+/).filter(Boolean).length <= 8;
}
function mergeFollowupFilters(
current: AddressFilterSet,
intent: AddressIntent,
userMessage: string,
followupContext: AddressFollowupContext | null
): { filters: AddressFilterSet; reasons: string[] } {
const merged: AddressFilterSet = { ...current };
const reasons: string[] = [];
if (!followupContext) {
return { filters: merged, reasons };
}
const previous = followupContext.previous_filters ?? {};
const previousAnchorValue = toNonEmptyString(followupContext.previous_anchor_value);
const previousCounterparty = toNonEmptyString(previous.counterparty);
const previousContract = toNonEmptyString(previous.contract);
const previousAccount = toNonEmptyString(previous.account);
const allTimeRequested = hasAllTimeHint(userMessage);
if (intent === "list_documents_by_counterparty" || intent === "bank_operations_by_counterparty") {
if (!toNonEmptyString(merged.counterparty)) {
const inheritedCounterparty =
previousCounterparty ??
(followupContext.previous_anchor_type === "counterparty" ? previousAnchorValue : null);
if (inheritedCounterparty) {
merged.counterparty = inheritedCounterparty;
reasons.push("counterparty_from_followup_context");
}
}
}
if (intent === "account_balance_snapshot" || intent === "documents_forming_balance") {
if (!toNonEmptyString(merged.account)) {
const inheritedAccount =
previousAccount ??
(followupContext.previous_anchor_type === "account" ? previousAnchorValue : null);
if (inheritedAccount) {
merged.account = inheritedAccount;
reasons.push("account_from_followup_context");
}
}
}
if (intent === "open_items_by_counterparty_or_contract" || intent === "list_open_contracts") {
if (!toNonEmptyString(merged.contract)) {
const inheritedContract =
previousContract ??
(followupContext.previous_anchor_type === "contract" ? previousAnchorValue : null);
if (inheritedContract) {
merged.contract = inheritedContract;
reasons.push("contract_from_followup_context");
}
}
if (!toNonEmptyString(merged.counterparty)) {
const inheritedCounterparty =
previousCounterparty ??
(followupContext.previous_anchor_type === "counterparty" ? previousAnchorValue : null);
if (inheritedCounterparty) {
merged.counterparty = inheritedCounterparty;
reasons.push("counterparty_from_followup_context");
}
}
}
if (allTimeRequested) {
if (toNonEmptyString(merged.period_from) || toNonEmptyString(merged.period_to)) {
delete merged.period_from;
delete merged.period_to;
reasons.push("period_cleared_by_all_time_followup");
}
return { filters: merged, reasons };
}
const currentHasPeriod = hasExplicitPeriodWindow(merged);
const previousHasPeriod = hasExplicitPeriodWindow(previous);
if (!currentHasPeriod && previousHasPeriod && hasAddressFollowupContextSignal(userMessage)) {
if (toNonEmptyString(previous.period_from)) {
merged.period_from = previous.period_from;
}
if (toNonEmptyString(previous.period_to)) {
merged.period_to = previous.period_to;
}
reasons.push("period_from_followup_context");
}
return { filters: merged, reasons };
}
function resolveMissingRequiredFilters(intent: AddressIntent, filters: AddressFilterSet): string[] {
const requiredByIntent: Record<string, Array<keyof AddressFilterSet>> = {
account_balance_snapshot: ["account", "as_of_date"],
documents_forming_balance: ["account", "as_of_date"],
list_documents_by_counterparty: ["counterparty"],
bank_operations_by_counterparty: ["counterparty"]
};
const required = requiredByIntent[intent] ?? [];
return required.filter((key) => {
const value = filters[key];
return value === undefined || value === null || String(value).trim() === "";
});
}
function deriveIntentWithFollowupContext(
detectedIntent: AddressIntentResolution,
userMessage: string,
followupContext: AddressFollowupContext | null
): AddressIntentResolution {
if (!followupContext || !followupContext.previous_intent) {
return detectedIntent;
}
if (detectedIntent.intent !== "unknown") {
return detectedIntent;
}
if (!hasAddressFollowupContextSignal(userMessage)) {
return detectedIntent;
}
return {
intent: followupContext.previous_intent,
confidence: "low",
reasons: [...detectedIntent.reasons, "intent_from_followup_context"]
};
}
export function runAddressDecomposeStage(
userMessage: string,
followupContext: AddressFollowupContext | null
): AddressDecomposeStageResult | null {
const detectedMode = detectAddressQuestionMode(userMessage);
const mode =
detectedMode.mode === "address_query"
? detectedMode
: followupContext && hasAddressFollowupContextSignal(userMessage)
? {
mode: "address_query" as const,
confidence: "medium" as const,
reasons: [...detectedMode.reasons, "address_mode_from_followup_context"]
}
: detectedMode;
if (mode.mode !== "address_query") {
return null;
}
const shape = classifyAddressQueryShape(userMessage);
if (shape.shape === "EXPLAIN_OR_REASON") {
return null;
}
const detectedIntent = resolveAddressIntent(userMessage);
const intent = deriveIntentWithFollowupContext(detectedIntent, userMessage, followupContext);
const extractedFilters = extractAddressFilters(userMessage, intent.intent);
const followupMerged = mergeFollowupFilters(extractedFilters.extracted_filters, intent.intent, userMessage, followupContext);
const filters = {
extracted_filters: followupMerged.filters,
missing_required_filters: resolveMissingRequiredFilters(intent.intent, followupMerged.filters),
warnings: [...new Set([...extractedFilters.warnings, ...followupMerged.reasons])]
};
const followupContextApplied =
Boolean(followupContext) &&
(mode.reasons.includes("address_mode_from_followup_context") ||
intent.reasons.includes("intent_from_followup_context") ||
followupMerged.reasons.length > 0);
const baseReasons = [
...mode.reasons,
...shape.reasons,
...intent.reasons,
...followupMerged.reasons,
...(followupContextApplied ? ["address_followup_context_applied"] : [])
];
return {
mode,
shape,
intent,
filters,
baseReasons
};
}

View File

@ -0,0 +1,211 @@
import type { AddressFilterSet, AddressIntent } from "../../types/addressQuery";
const PARTY_ANCHOR_STOPWORDS = new Set([
"ооо",
"ао",
"зао",
"ип",
"llc",
"ltd",
"company",
"компания",
"контрагент",
"counterparty",
"по",
"by"
]);
export interface AnchorResolutionDebug {
anchor_type: "account" | "counterparty" | "contract" | "document_ref" | "unknown" | null;
anchor_value_raw: string | null;
anchor_value_resolved: string | null;
resolver_confidence: "high" | "medium" | "low" | null;
ambiguity_count: number;
}
export interface ResolveStageRow {
registrator: string;
account_dt: string | null;
account_kt: string | null;
analytics: string[];
}
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 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 uniqueStrings(values: string[]): string[] {
return Array.from(
new Set(
values
.map((item) => item.trim())
.filter((item) => item.length > 0)
)
);
}
export 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() : "";
const contract = typeof filters.contract === "string" ? filters.contract.trim() : "";
const documentRef = typeof filters.document_ref === "string" ? filters.document_ref.trim() : "";
if (intent === "account_balance_snapshot" || intent === "documents_forming_balance") {
if (account) {
return {
anchor_type: "account",
anchor_value_raw: account,
anchor_value_resolved: account,
resolver_confidence: "high",
ambiguity_count: 0
};
}
}
if (counterparty) {
return {
anchor_type: "counterparty",
anchor_value_raw: counterparty,
anchor_value_resolved: counterparty,
resolver_confidence: "medium",
ambiguity_count: 0
};
}
if (contract) {
return {
anchor_type: "contract",
anchor_value_raw: contract,
anchor_value_resolved: contract,
resolver_confidence: "medium",
ambiguity_count: 0
};
}
if (documentRef) {
return {
anchor_type: "document_ref",
anchor_value_raw: documentRef,
anchor_value_resolved: documentRef,
resolver_confidence: "medium",
ambiguity_count: 0
};
}
return {
anchor_type: "unknown",
anchor_value_raw: null,
anchor_value_resolved: null,
resolver_confidence: "low",
ambiguity_count: 0
};
}
export function refineAnchorFromRows(anchor: AnchorResolutionDebug, rows: ResolveStageRow[]): 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
};
}

View File

@ -1693,8 +1693,9 @@ function buildAddressCoverageReport() {
out_of_scope_requirements: []
};
}
function buildAddressDebugPayload(addressDebug) {
function buildAddressDebugPayload(addressDebug, llmPreDecomposeMeta = null) {
const grounded = addressDebug.response_type === "LIMITED_WITH_REASON" ? "partial" : "grounded";
const llmMeta = llmPreDecomposeMeta && typeof llmPreDecomposeMeta === "object" ? llmPreDecomposeMeta : null;
return {
trace_id: `address-${(0, nanoid_1.nanoid)(10)}`,
prompt_version: "address_query_runtime_v1",
@ -1752,12 +1753,204 @@ function buildAddressDebugPayload(addressDebug) {
runtime_readiness: addressDebug.runtime_readiness,
limited_reason_category: addressDebug.limited_reason_category,
response_type: addressDebug.response_type,
execution_lane: "address_query",
llm_decomposition_applied: Boolean(llmMeta?.applied),
llm_decomposition_attempted: Boolean(llmMeta?.attempted),
llm_provider_used: llmMeta?.provider ?? null,
llm_decomposition_trace_id: llmMeta?.traceId ?? null,
llm_decomposition_effective_message: llmMeta?.effectiveMessage ?? null,
llm_decomposition_reason: llmMeta?.reason ?? null,
answer_structure_v11: null,
investigation_state_snapshot: null,
normalized: null,
normalizer_output: null
normalizer_output: llmMeta?.traceId
? {
trace_id: llmMeta.traceId,
prompt_version: "normalizer_v2_0_2",
applied: Boolean(llmMeta?.applied),
effective_message: llmMeta?.effectiveMessage ?? null
}
: null
};
}
function toNonEmptyString(value) {
if (value === null || value === undefined) {
return null;
}
const text = String(value).trim();
return text.length > 0 ? text : null;
}
function readAddressFilterString(addressDebug, key) {
const filters = addressDebug?.extracted_filters;
if (!filters || typeof filters !== "object") {
return null;
}
return toNonEmptyString(filters[key]);
}
function findLastAddressAssistantDebug(items) {
for (let index = items.length - 1; index >= 0; index -= 1) {
const item = items[index];
if (!item || item.role !== "assistant" || !item.debug) {
continue;
}
const debug = item.debug;
if (debug.detected_mode === "address_query" || debug.prompt_version === "address_query_runtime_v1") {
return debug;
}
}
return null;
}
function hasAddressFollowupContextSignal(userMessage) {
const text = compactWhitespace(String(userMessage ?? "").toLowerCase());
if (!text) {
return false;
}
if (/(?:за\s+вс[её]\s+время|за\s+весь\s+период|за\s+всю\s+истори(?:ю|и)|за\s+любой\s+период|for\s+all\s+time|all\s+time|for\s+entire\s+period|entire\s+period|for\s+any\s+period|any\s+period)/iu.test(text)) {
return true;
}
if (hasReferentialPointer(text)) {
return true;
}
const shortFollowup = countTokens(text) <= 8;
if (shortFollowup && hasFollowupMarker(text)) {
return true;
}
return false;
}
function resolveAddressFollowupCarryoverContext(userMessage, items) {
if (!hasAddressFollowupContextSignal(userMessage)) {
return null;
}
const previousAddressDebug = findLastAddressAssistantDebug(items);
if (!previousAddressDebug) {
return null;
}
const previousIntent = toNonEmptyString(previousAddressDebug.detected_intent);
const previousAnchorType = toNonEmptyString(previousAddressDebug.anchor_type);
const previousAnchor = toNonEmptyString(previousAddressDebug.anchor_value_resolved) ??
toNonEmptyString(previousAddressDebug.anchor_value_raw) ??
readAddressFilterString(previousAddressDebug, "counterparty") ??
readAddressFilterString(previousAddressDebug, "account") ??
readAddressFilterString(previousAddressDebug, "contract");
const previousFiltersRaw = previousAddressDebug.extracted_filters;
const previousFilters = previousFiltersRaw && typeof previousFiltersRaw === "object"
? { ...previousFiltersRaw }
: {};
if (!previousIntent && !previousAnchor && Object.keys(previousFilters).length === 0) {
return null;
}
return {
followupContext: {
previous_intent: previousIntent ?? undefined,
previous_filters: previousFilters,
previous_anchor_type: previousAnchorType ?? undefined,
previous_anchor_value: previousAnchor
},
previousAddressIntent: previousIntent,
previousAddressAnchor: previousAnchor
};
}
function isAddressLlmPreDecomposeCandidate(userMessage) {
const text = compactWhitespace(String(userMessage ?? "").toLowerCase());
if (!text) {
return false;
}
return /(?:\bдок\b|доки|документ|контрагент|договор|остаток|сч(?:е|ё)т|банк|выписк|платеж|оплат|поступлен|реализац|сверк|взаиморасч|кто\s+должен|show|list|documents?|counterparty|contract|account|balance|bank\s+operations?)/i.test(text);
}
function extractAddressQuestionFromNormalized(normalized) {
if (!normalized || typeof normalized !== "object") {
return null;
}
const source = normalized;
const fragments = Array.isArray(source.fragments) ? source.fragments : [];
for (const item of fragments) {
if (!item || typeof item !== "object") {
continue;
}
const fragment = item;
const domainRelevance = String(fragment.domain_relevance ?? "").trim().toLowerCase();
if (domainRelevance === "out_of_scope") {
continue;
}
const readiness = String(fragment.execution_readiness ?? "").trim().toLowerCase();
if (readiness === "no_route") {
continue;
}
const normalizedText = toNonEmptyString(fragment.normalized_fragment_text);
const rawText = toNonEmptyString(fragment.raw_fragment_text);
const candidate = compactWhitespace(normalizedText ?? rawText ?? "");
if (candidate.length >= 3 && candidate.length <= 500) {
return candidate;
}
}
return null;
}
async function runAddressLlmPreDecompose(normalizerService, payload, userMessage) {
const provider = payload?.llmProvider === "local" ? "local" : payload?.llmProvider === "openai" ? "openai" : null;
const baseMeta = {
attempted: false,
applied: false,
provider,
traceId: null,
effectiveMessage: userMessage,
reason: "not_attempted"
};
if (Boolean(payload?.useMock)) {
return {
...baseMeta,
reason: "skipped_in_mock"
};
}
if (!isAddressLlmPreDecomposeCandidate(userMessage)) {
return {
...baseMeta,
reason: "not_address_like"
};
}
const normalizePayload = {
llmProvider: payload?.llmProvider,
apiKey: payload?.apiKey,
model: payload?.model,
baseUrl: payload?.baseUrl,
temperature: 0,
maxOutputTokens: payload?.maxOutputTokens,
promptVersion: "normalizer_v2_0_2",
userQuestion: userMessage,
context: payload?.context,
useMock: Boolean(payload?.useMock),
retryPolicy: "single-pass-strict"
};
try {
const normalized = await normalizerService.normalize(normalizePayload);
const candidate = extractAddressQuestionFromNormalized(normalized?.normalized);
if (!normalized?.ok || !candidate) {
return {
...baseMeta,
attempted: true,
traceId: normalized?.trace_id ?? null,
reason: normalized?.ok ? "no_usable_fragment" : "normalize_failed"
};
}
const sourceCompact = compactWhitespace(String(userMessage ?? "").toLowerCase());
const candidateCompact = compactWhitespace(candidate.toLowerCase());
const applied = sourceCompact !== candidateCompact;
return {
attempted: true,
applied,
provider,
traceId: normalized?.trace_id ?? null,
effectiveMessage: applied ? candidate : userMessage,
reason: applied ? "normalized_fragment_applied" : "normalized_fragment_same"
};
}
catch (error) {
return {
...baseMeta,
attempted: true,
reason: `error:${error instanceof Error ? error.message : String(error)}`
};
}
}
export class AssistantService {
normalizerService;
sessions;
@ -1789,80 +1982,112 @@ export class AssistantService {
debug: null
};
this.sessions.appendItem(sessionId, userItem);
if (config_1.FEATURE_ASSISTANT_ADDRESS_QUERY_V1) {
const addressLane = await this.addressQueryService.tryHandle(userMessage);
if (addressLane?.handled) {
const debug = buildAddressDebugPayload(addressLane.debug);
const assistantItem = {
message_id: `msg-${(0, nanoid_1.nanoid)(10)}`,
session_id: sessionId,
role: "assistant",
text: addressLane.reply_text,
reply_type: addressLane.reply_type,
created_at: new Date().toISOString(),
trace_id: debug.trace_id,
debug
};
this.sessions.appendItem(sessionId, assistantItem);
const current = this.sessions.getSession(sessionId);
if (current) {
this.sessionLogger.persistSession(current);
}
const conversation = cloneItems(current?.items ?? []);
(0, log_1.logJson)({
timestamp: new Date().toISOString(),
level: "info",
service: "assistant_loop",
message: "assistant_message_processed",
sessionId,
eventType: "assistant_message_address",
details: {
session_id: sessionId,
message_id: assistantItem.message_id,
user_message: userMessage,
detected_mode: addressLane.debug.detected_mode,
query_shape: addressLane.debug.query_shape,
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,
rows_after_account_scope: addressLane.debug.rows_after_account_scope,
rows_after_recipe_filter: addressLane.debug.rows_after_recipe_filter,
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,
limitations: addressLane.debug.limitations,
assistant_reply: assistantItem.text,
reply_type: assistantItem.reply_type,
trace_id: assistantItem.trace_id
}
});
return {
ok: true,
const finalizeAddressLaneResponse = (addressLane, effectiveAddressUserMessage, carryoverMeta = null, llmPreDecomposeMeta = null) => {
const debug = buildAddressDebugPayload(addressLane.debug, llmPreDecomposeMeta);
const assistantItem = {
message_id: `msg-${(0, nanoid_1.nanoid)(10)}`,
session_id: sessionId,
role: "assistant",
text: addressLane.reply_text,
reply_type: addressLane.reply_type,
created_at: new Date().toISOString(),
trace_id: debug.trace_id,
debug
};
this.sessions.appendItem(sessionId, assistantItem);
const current = this.sessions.getSession(sessionId);
if (current) {
this.sessionLogger.persistSession(current);
}
const conversation = cloneItems(current?.items ?? []);
(0, log_1.logJson)({
timestamp: new Date().toISOString(),
level: "info",
service: "assistant_loop",
message: "assistant_message_processed",
sessionId,
eventType: "assistant_message_address",
details: {
session_id: sessionId,
message_id: assistantItem.message_id,
user_message: userMessage,
effective_address_user_message: effectiveAddressUserMessage,
address_followup_context_applied: Boolean(carryoverMeta),
address_followup_context_previous_intent: carryoverMeta?.previousAddressIntent ?? null,
address_followup_context_previous_anchor: carryoverMeta?.previousAddressAnchor ?? null,
address_llm_predecompose_attempted: Boolean(llmPreDecomposeMeta?.attempted),
address_llm_predecompose_applied: Boolean(llmPreDecomposeMeta?.applied),
address_llm_predecompose_provider: llmPreDecomposeMeta?.provider ?? null,
address_llm_predecompose_trace_id: llmPreDecomposeMeta?.traceId ?? null,
address_llm_predecompose_reason: llmPreDecomposeMeta?.reason ?? null,
detected_mode: addressLane.debug.detected_mode,
query_shape: addressLane.debug.query_shape,
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,
rows_after_account_scope: addressLane.debug.rows_after_account_scope,
rows_after_recipe_filter: addressLane.debug.rows_after_recipe_filter,
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,
limitations: addressLane.debug.limitations,
assistant_reply: assistantItem.text,
reply_type: assistantItem.reply_type,
conversation_item: assistantItem,
debug,
conversation
trace_id: assistantItem.trace_id
}
});
return {
ok: true,
session_id: sessionId,
assistant_reply: assistantItem.text,
reply_type: assistantItem.reply_type,
conversation_item: assistantItem,
debug,
conversation
};
};
if (config_1.FEATURE_ASSISTANT_ADDRESS_QUERY_V1) {
const addressPreDecompose = config_1.FEATURE_ASSISTANT_ADDRESS_QUERY_LLM_PREDECOMPOSE_V1
? await runAddressLlmPreDecompose(this.normalizerService, payload, userMessage)
: {
attempted: false,
applied: false,
provider: payload?.llmProvider === "local" ? "local" : payload?.llmProvider === "openai" ? "openai" : null,
traceId: null,
effectiveMessage: userMessage,
reason: "disabled_by_feature_flag"
};
const addressInputMessage = toNonEmptyString(addressPreDecompose?.effectiveMessage) ?? userMessage;
const primaryAddressLane = await this.addressQueryService.tryHandle(addressInputMessage);
if (primaryAddressLane?.handled) {
return finalizeAddressLaneResponse(primaryAddressLane, addressInputMessage, null, addressPreDecompose);
}
const carryover = resolveAddressFollowupCarryoverContext(userMessage, session.items);
if (carryover?.followupContext) {
const contextualAddressLane = await this.addressQueryService.tryHandle(addressInputMessage, {
followupContext: carryover.followupContext
});
if (contextualAddressLane?.handled) {
return finalizeAddressLaneResponse(contextualAddressLane, addressInputMessage, carryover, addressPreDecompose);
}
}
}
const followupBinding = config_1.FEATURE_ASSISTANT_INVESTIGATION_STATE_V1 &&
@ -1879,12 +2104,13 @@ export class AssistantService {
usage: null
};
const normalizePayload = {
llmProvider: payload.llmProvider,
apiKey: payload.apiKey,
model: payload.model,
baseUrl: payload.baseUrl,
temperature: payload.temperature,
maxOutputTokens: payload.maxOutputTokens,
promptVersion: payload.promptVersion ?? "normalizer_v2_0_2",
promptVersion: payload.promptVersion ?? "address_query_runtime_v1",
systemPrompt: payload.systemPrompt,
developerPrompt: payload.developerPrompt,
domainPrompt: payload.domainPrompt,

View File

@ -1036,6 +1036,7 @@ export class NormalizerService {
public async normalize(payload: NormalizeRequestPayload): Promise<NormalizeResponsePayload> {
const traceId = nanoid(14);
const startedAt = Date.now();
const llmProvider = payload.llmProvider === "local" ? "local" : "openai";
const model = payload.model ?? DEFAULT_MODEL;
const baseUrl = payload.baseUrl ?? DEFAULT_OPENAI_BASE_URL;
const temperature = payload.temperature ?? DEFAULT_TEMPERATURE;
@ -1072,6 +1073,7 @@ export class NormalizerService {
const apiKey = payload.apiKey ?? process.env.OPENAI_API_KEY;
const firstTry = await this.openaiClient.normalize(
{
llmProvider,
apiKey: String(apiKey ?? ""),
model,
baseUrl,
@ -1118,6 +1120,7 @@ export class NormalizerService {
const retryMaxOutputTokens = computeRetryMaxOutputTokens(maxOutputTokens, rawModelResponse);
const retry = await this.openaiClient.normalize(
{
llmProvider,
apiKey: String(payload.apiKey ?? process.env.OPENAI_API_KEY ?? ""),
model,
baseUrl,

View File

@ -1,9 +1,11 @@
import fs from "fs";
import path from "path";
import { DEFAULT_OPENAI_BASE_URL, SCHEMAS_DIR } from "../config";
import type { LlmProvider } from "../types/normalizer";
import { ApiError } from "../utils/http";
export interface OpenAIRequestConfig {
llmProvider?: LlmProvider;
apiKey: string;
model: string;
baseUrl?: string;
@ -21,6 +23,22 @@ export interface OpenAIResponseEnvelope {
};
}
function resolveProvider(config: OpenAIRequestConfig): LlmProvider {
return config.llmProvider === "local" ? "local" : "openai";
}
function resolveApiKey(config: OpenAIRequestConfig): string {
const candidate = String(config.apiKey ?? "").trim();
if (candidate.length > 0) {
return candidate;
}
if (resolveProvider(config) === "local") {
// Local OpenAI-compatible servers often accept any token.
return "local-dev-token";
}
throw new ApiError("OPENAI_API_KEY_MISSING", "OpenAI API key is missing.", 400);
}
function extractUsage(raw: Record<string, unknown>): {
input_tokens: number;
output_tokens: number;
@ -37,7 +55,7 @@ function extractUsage(raw: Record<string, unknown>): {
};
}
function extractOutputText(raw: Record<string, unknown>): string {
function extractOutputTextFromResponses(raw: Record<string, unknown>): string {
if (typeof raw.output_text === "string" && raw.output_text.trim().length > 0) {
return raw.output_text;
}
@ -72,7 +90,58 @@ function extractOutputText(raw: Record<string, unknown>): string {
}
}
throw new ApiError("OPENAI_OUTPUT_PARSE_FAILED", "Не удалось извлечь output_text из Responses API ответа.", 502, raw);
throw new ApiError("OPENAI_OUTPUT_PARSE_FAILED", "Failed to extract output_text from /responses payload.", 502, raw);
}
function extractOutputTextFromChatCompletions(raw: Record<string, unknown>): string {
const choices = raw.choices;
if (!Array.isArray(choices) || choices.length === 0) {
throw new ApiError("OPENAI_OUTPUT_PARSE_FAILED", "Missing choices in /chat/completions payload.", 502, raw);
}
const first = choices[0];
if (!first || typeof first !== "object") {
throw new ApiError("OPENAI_OUTPUT_PARSE_FAILED", "Invalid first choice in /chat/completions payload.", 502, raw);
}
const message = (first as Record<string, unknown>).message;
if (!message || typeof message !== "object") {
throw new ApiError("OPENAI_OUTPUT_PARSE_FAILED", "Missing message in /chat/completions payload.", 502, raw);
}
const content = (message as Record<string, unknown>).content;
if (typeof content === "string" && content.trim().length > 0) {
return content;
}
if (Array.isArray(content)) {
const textParts = content
.map((item) => {
if (!item || typeof item !== "object") {
return "";
}
const block = item as Record<string, unknown>;
return typeof block.text === "string" ? block.text : "";
})
.filter((item) => item.trim().length > 0);
if (textParts.length > 0) {
return textParts.join("\n");
}
}
throw new ApiError("OPENAI_OUTPUT_PARSE_FAILED", "Failed to extract text from /chat/completions payload.", 502, raw);
}
function shouldFallbackToChatCompletions(error: unknown): boolean {
if (!(error instanceof ApiError)) {
return false;
}
if (error.code !== "OPENAI_REQUEST_FAILED") {
return false;
}
const details = (error.details ?? {}) as Record<string, unknown>;
const status = Number(details.status ?? 0);
if ([404, 405, 501].includes(status)) {
return true;
}
const message = String(error.message ?? "").toLowerCase();
return message.includes("/responses") || message.includes("responses");
}
function loadSchemaForTransport(schemaVersion: "v1" | "v2" | "v2_0_1" | "v2_0_2"): Record<string, unknown> {
@ -83,24 +152,62 @@ function loadSchemaForTransport(schemaVersion: "v1" | "v2" | "v2_0_1" | "v2_0_2"
? "normalized_query_v2_0_1.json"
: schemaVersion === "v2_0_2"
? "normalized_query_v2_0_2.json"
: "normalized_query_v2.json";
: "normalized_query_v2.json";
const schemaPath = path.resolve(SCHEMAS_DIR, schemaFile);
return JSON.parse(fs.readFileSync(schemaPath, "utf-8")) as Record<string, unknown>;
}
function buildBaseUrlCandidates(config: OpenAIRequestConfig): string[] {
const base = (config.baseUrl ?? DEFAULT_OPENAI_BASE_URL).replace(/\/+$/, "");
const provider = resolveProvider(config);
if (provider !== "local") {
return [base];
}
const hasVersionSuffix = /\/v\d+$/i.test(base);
if (hasVersionSuffix) {
return [base];
}
return Array.from(new Set([base, `${base}/v1`]));
}
export class OpenAIResponsesClient {
public async testConnection(config: OpenAIRequestConfig): Promise<{ ok: boolean; model: string }> {
const payload = {
model: config.model,
input: [
{
role: "user",
content: [{ type: "input_text", text: "ping" }]
public async listModels(config: OpenAIRequestConfig): Promise<string[]> {
const payload = await this.getModels(config);
const data = Array.isArray(payload.data) ? payload.data : [];
const ids = data
.map((item) => {
if (!item || typeof item !== "object") {
return "";
}
],
return String((item as Record<string, unknown>).id ?? "").trim();
})
.filter((item) => item.length > 0);
return Array.from(new Set(ids));
}
public async testConnection(config: OpenAIRequestConfig): Promise<{ ok: boolean; model: string }> {
const provider = resolveProvider(config);
if (provider === "local") {
try {
await this.getModels(config);
} catch {
// Some local providers do not expose /models consistently; fallback to a tiny chat call.
await this.postChatCompletions(config, {
model: config.model,
messages: [{ role: "user", content: "ping" }],
max_tokens: 4,
temperature: 0
});
}
return { ok: true, model: config.model };
}
await this.postResponses(config, {
model: config.model,
input: [{ role: "user", content: [{ type: "input_text", text: "ping" }] }],
max_output_tokens: 16
};
await this.post(config, payload);
});
return { ok: true, model: config.model };
}
@ -123,13 +230,13 @@ export class OpenAIResponsesClient {
? "normalized_query_v2_0_1"
: prompt.schemaVersion === "v2_0_2"
? "normalized_query_v2_0_2"
: "normalized_query_v2";
: "normalized_query_v2";
const developerPrompt = prompt.controlledRetryInstruction
? `${prompt.developerPrompt}\n\n${prompt.controlledRetryInstruction}`
: prompt.developerPrompt;
const payload = {
const responsesPayload = {
model: config.model,
temperature: config.temperature ?? 0,
max_output_tokens: config.maxOutputTokens ?? 700,
@ -147,7 +254,7 @@ export class OpenAIResponsesClient {
content: [
{
type: "input_text",
text: `${prompt.domainPrompt}\n\nПользовательский вопрос:\n${prompt.userQuestion}`
text: `${prompt.domainPrompt}\n\nUser question:\n${prompt.userQuestion}`
}
]
}
@ -162,52 +269,157 @@ export class OpenAIResponsesClient {
}
};
const raw = await this.post(config, payload);
const outputText = extractOutputText(raw);
const provider = resolveProvider(config);
if (provider === "openai") {
const raw = await this.postResponses(config, responsesPayload);
return {
raw,
outputText: extractOutputTextFromResponses(raw),
usage: extractUsage(raw)
};
}
// local provider: prefer /responses if available, fallback to /chat/completions
try {
const raw = await this.postResponses(config, responsesPayload);
return {
raw,
outputText: extractOutputTextFromResponses(raw),
usage: extractUsage(raw)
};
} catch (error) {
if (!shouldFallbackToChatCompletions(error)) {
throw error;
}
}
const chatPayload = {
model: config.model,
temperature: config.temperature ?? 0,
max_tokens: config.maxOutputTokens ?? 700,
response_format: { type: "json_object" },
messages: [
{
role: "system",
content: `${prompt.systemPrompt}\n\n${developerPrompt}`
},
{
role: "user",
content:
`${prompt.domainPrompt}\n\nUser question:\n${prompt.userQuestion}\n\n` +
`Return only JSON that matches schema: ${schemaName}.`
}
]
};
const raw = await this.postChatCompletions(config, chatPayload);
return {
raw,
outputText,
outputText: extractOutputTextFromChatCompletions(raw),
usage: extractUsage(raw)
};
}
private async post(config: OpenAIRequestConfig, payload: Record<string, unknown>): Promise<Record<string, unknown>> {
if (!config.apiKey || config.apiKey.trim().length < 10) {
throw new ApiError("OPENAI_API_KEY_MISSING", "API ключ OpenAI не задан или слишком короткий.", 400);
private async getModels(config: OpenAIRequestConfig): Promise<Record<string, unknown>> {
return this.requestJson(config, "/models", "GET");
}
private async postResponses(config: OpenAIRequestConfig, payload: Record<string, unknown>): Promise<Record<string, unknown>> {
return this.requestJson(config, "/responses", "POST", payload);
}
private async postChatCompletions(
config: OpenAIRequestConfig,
payload: Record<string, unknown>
): Promise<Record<string, unknown>> {
return this.requestJson(config, "/chat/completions", "POST", payload);
}
private async requestJson(
config: OpenAIRequestConfig,
routePath: string,
method: "GET" | "POST",
payload?: Record<string, unknown>
): Promise<Record<string, unknown>> {
const apiKey = resolveApiKey(config);
const baseCandidates = buildBaseUrlCandidates(config);
const canFallbackToAlternativeBase = resolveProvider(config) === "local" && baseCandidates.length > 1;
let lastNetworkError: unknown = null;
const headers: Record<string, string> = {
Authorization: `Bearer ${apiKey}`
};
if (method === "POST") {
headers["Content-Type"] = "application/json";
}
const url = `${(config.baseUrl ?? DEFAULT_OPENAI_BASE_URL).replace(/\/$/, "")}/responses`;
const response = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${config.apiKey}`,
"Content-Type": "application/json"
},
body: JSON.stringify(payload)
});
const text = await response.text();
let data: Record<string, unknown>;
try {
data = JSON.parse(text) as Record<string, unknown>;
} catch {
throw new ApiError("OPENAI_NON_JSON_RESPONSE", "OpenAI вернул не-JSON ответ.", 502, { status: response.status, body: text.slice(0, 500) });
}
if (!response.ok) {
const errorObj = (data.error ?? {}) as Record<string, unknown>;
throw new ApiError(
"OPENAI_REQUEST_FAILED",
String(errorObj.message ?? `OpenAI request failed with status ${response.status}`),
response.status,
{
status: response.status,
type: errorObj.type ?? null,
code: errorObj.code ?? null
for (let index = 0; index < baseCandidates.length; index += 1) {
const base = baseCandidates[index];
const isLastCandidate = index === baseCandidates.length - 1;
const url = `${base}${routePath}`;
let response: Response;
try {
response = await fetch(url, {
method,
headers,
body: method === "POST" ? JSON.stringify(payload ?? {}) : undefined
});
} catch (error) {
lastNetworkError = error;
if (!isLastCandidate) {
continue;
}
);
throw new ApiError("OPENAI_REQUEST_FAILED", "Model endpoint is unreachable.", 502, {
route: routePath,
url,
reason: error instanceof Error ? error.message : String(error)
});
}
if (!response.ok && canFallbackToAlternativeBase && !isLastCandidate && [404, 405].includes(response.status)) {
continue;
}
const text = await response.text();
let data: Record<string, unknown> = {};
if (text.trim().length > 0) {
try {
data = JSON.parse(text) as Record<string, unknown>;
} catch {
if (!response.ok && canFallbackToAlternativeBase && !isLastCandidate && [404, 405].includes(response.status)) {
continue;
}
throw new ApiError("OPENAI_NON_JSON_RESPONSE", "Model endpoint returned non-JSON response.", 502, {
route: routePath,
url,
status: response.status,
body: text.slice(0, 500)
});
}
}
if (!response.ok) {
const errorObj = (data.error ?? {}) as Record<string, unknown>;
throw new ApiError(
"OPENAI_REQUEST_FAILED",
String(errorObj.message ?? `Model endpoint failed: ${response.status}`),
response.status,
{
route: routePath,
url,
status: response.status,
type: errorObj.type ?? null,
code: errorObj.code ?? null
}
);
}
return data;
}
return data;
throw new ApiError("OPENAI_REQUEST_FAILED", "Model endpoint is unreachable.", 502, {
route: routePath,
reason: lastNetworkError instanceof Error ? lastNetworkError.message : String(lastNetworkError ?? "unknown")
});
}
}

View File

@ -255,6 +255,7 @@ export interface AssistantMessageRequestPayload {
user_message?: string;
message?: string;
mode?: "assistant" | string;
llmProvider?: NormalizeRequestPayload["llmProvider"];
apiKey?: string;
model?: string;
baseUrl?: string;
@ -370,6 +371,13 @@ export interface AssistantDebugPayload {
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";
execution_lane?: "address_query" | "deep_analysis";
llm_decomposition_applied?: boolean;
llm_decomposition_attempted?: boolean;
llm_provider_used?: "openai" | "local" | null;
llm_decomposition_trace_id?: string | null;
llm_decomposition_effective_message?: string | null;
llm_decomposition_reason?: string | null;
business_scope_raw?: string[];
business_scope_resolved?: string[];
company_grounding_applied?: boolean;

View File

@ -30,6 +30,7 @@ export type PromptVersion =
| "normalizer_v2_0_2";
export type EvalRunMode = "standard" | "single-pass-strict";
export type LlmProvider = "openai" | "local";
export interface NormalizedQueryV1 {
schema_version: "normalized_query_v1";
@ -235,6 +236,7 @@ export type RouteHintSummary = RouteHintSummaryV1 | RouteHintSummaryV2;
export type NormalizedPayload = NormalizedQueryV1 | NormalizedQueryV2 | NormalizedQueryV2_0_1 | NormalizedQueryV2_0_2;
export interface NormalizeRequestPayload {
llmProvider?: LlmProvider;
apiKey?: string;
model?: string;
baseUrl?: string;

View File

@ -0,0 +1,47 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { executeAddressMcpQuery } from "../src/services/addressMcpClient";
const ORIGINAL_FETCH = globalThis.fetch;
afterEach(() => {
globalThis.fetch = ORIGINAL_FETCH;
vi.restoreAllMocks();
});
describe("address MCP encoding repair", () => {
it("repairs UTF-8/CP1251 mojibake in object rows", async () => {
const payload = {
success: true,
data: [
{
"Период": "2020-07-30T12:00:00Z",
"Регистратор": "Поступление на расчетный счет 0001",
"СчетДт": "51",
"СчетКт": "62.01",
"РЎСѓРјРјР°": "20000",
"Контрагент": "Группа СВК"
}
]
};
globalThis.fetch = vi.fn(async () =>
new Response(JSON.stringify(payload), {
status: 200,
headers: {
"content-type": "application/json"
}
})
) as typeof fetch;
const result = await executeAddressMcpQuery({
query: "SELECT 1",
limit: 20,
account_scope: []
});
expect(result.error).toBeNull();
expect(result.fetched_rows).toBe(1);
expect(result.rows[0]?.["Контрагент"]).toBe("Группа СВК");
expect(result.rows[0]?.["Регистратор"]).toContain("Поступление");
});
});

View File

@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
import { detectAddressQuestionMode } from "../src/services/addressQueryClassifier";
import { resolveAddressIntent } from "../src/services/addressIntentResolver";
import { classifyAddressQueryShape } from "../src/services/addressQueryShapeClassifier";
import { extractAddressFilters } from "../src/services/addressFilterExtractor";
@ -21,6 +22,16 @@ describe("address query shape classifier", () => {
const result = classifyAddressQueryShape("who owes us and who we owe today?");
expect(result.shape).toBe("COMPOUND_FACTUAL_QUERY");
});
it("keeps company lookup phrasing in address lane", () => {
const result = detectAddressQuestionMode("какие компании есть в базе");
expect(result.mode).toBe("address_query");
});
it("keeps loose by-anchor follow-up phrasing in address lane", () => {
const result = detectAddressQuestionMode("за любой период есть что-то по свк?");
expect(result.mode).toBe("address_query");
});
});
describe("address intent resolver expansion (M2.3a)", () => {
@ -38,6 +49,41 @@ describe("address intent resolver expansion (M2.3a)", () => {
const result = resolveAddressIntent("which documents form balance for account 62 as of 2020-07-31");
expect(result.intent).toBe("documents_forming_balance");
});
it("resolves documents by company phrase as counterparty intent", () => {
const result = resolveAddressIntent("Какие документы доступны по компании СВК за 2021 год?");
expect(result.intent).toBe("list_documents_by_counterparty");
});
it("resolves bank operations by supplier phrase", () => {
const result = resolveAddressIntent("Покажи платежи по поставщику Альфа за июль 2020");
expect(result.intent).toBe("bank_operations_by_counterparty");
});
it("resolves documents by client phrase", () => {
const result = resolveAddressIntent("Выведи документы по клиенту Бета за 2020-07");
expect(result.intent).toBe("list_documents_by_counterparty");
});
it("resolves short slang docs phrase with loose by-anchor", () => {
const result = resolveAddressIntent("какие доки есть по свк за 2021");
expect(result.intent).toBe("list_documents_by_counterparty");
});
it("resolves typo slang docs phrase with implicit anchor", () => {
const result = resolveAddressIntent("свк доки за 20год покеж");
expect(result.intent).toBe("list_documents_by_counterparty");
});
it("resolves noisy docs phrase with slang tail", () => {
const result = resolveAddressIntent("свк 20 год - покажи доки плс");
expect(result.intent).toBe("list_documents_by_counterparty");
});
it("resolves loose by-anchor follow-up as documents by counterparty fallback", () => {
const result = resolveAddressIntent("за любой период есть что-то по свк?");
expect(result.intent).toBe("list_documents_by_counterparty");
});
});
describe("address filter extraction for balance drilldown", () => {
@ -68,6 +114,123 @@ describe("address filter extraction for balance drilldown", () => {
expect(result.extracted_filters.period_to).toBeUndefined();
expect(result.warnings).not.toContain("period_defaulted_last_90_days");
});
it("extracts counterparty from company phrase and derives year period", () => {
const result = extractAddressFilters(
"Какие документы доступны по компании СВК за 2021 год?",
"list_documents_by_counterparty"
);
expect(result.extracted_filters.counterparty).toBe("СВК");
expect(result.extracted_filters.period_from).toBe("2021-01-01");
expect(result.extracted_filters.period_to).toBe("2021-12-31");
expect(result.warnings).toContain("period_derived_from_year_phrase");
});
it("extracts counterparty from supplier phrase and derives month period", () => {
const result = extractAddressFilters(
"Покажи документы по поставщику Альфа за июль 2020",
"list_documents_by_counterparty"
);
expect(result.extracted_filters.counterparty).toBe("Альфа");
expect(result.extracted_filters.period_from).toBe("2020-07-01");
expect(result.extracted_filters.period_to).toBe("2020-07-31");
expect(result.warnings).toContain("period_derived_from_month_phrase");
});
it("treats 'за весь период' as all-time hint and does not force 90-day default", () => {
const result = extractAddressFilters(
"Покажи банковские операции по клиенту Бета за весь период",
"bank_operations_by_counterparty"
);
expect(result.extracted_filters.counterparty).toBe("Бета");
expect(result.extracted_filters.period_from).toBeUndefined();
expect(result.extracted_filters.period_to).toBeUndefined();
expect(result.warnings).not.toContain("period_defaulted_last_90_days");
});
it("extracts loose by-anchor and year period for short slang docs phrase", () => {
const result = extractAddressFilters(
"какие доки есть по свк за 2021",
"list_documents_by_counterparty"
);
expect(result.extracted_filters.counterparty).toBe("свк");
expect(result.extracted_filters.period_from).toBe("2021-01-01");
expect(result.extracted_filters.period_to).toBe("2021-12-31");
expect(result.warnings).toContain("counterparty_anchor_derived_from_loose_by_phrase");
expect(result.warnings).toContain("period_derived_from_year_phrase");
});
it("extracts implicit counterparty and short-year period for typo slang docs phrase", () => {
const result = extractAddressFilters(
"свк доки за 20год покеж",
"list_documents_by_counterparty"
);
expect(result.extracted_filters.counterparty).toBe("свк");
expect(result.extracted_filters.period_from).toBe("2020-01-01");
expect(result.extracted_filters.period_to).toBe("2020-12-31");
expect(result.warnings).toContain("counterparty_anchor_derived_from_implicit_phrase");
expect(result.warnings).toContain("period_derived_from_year_phrase");
});
it("extracts free-text counterparty and relaxed short-year period from noisy phrase", () => {
const result = extractAddressFilters(
"свк 20 год - покажи доки плс",
"list_documents_by_counterparty"
);
expect(result.extracted_filters.counterparty).toBe("свк");
expect(result.extracted_filters.period_from).toBe("2020-01-01");
expect(result.extracted_filters.period_to).toBe("2020-12-31");
expect(result.warnings).toContain("counterparty_anchor_derived_from_free_text_heuristic");
expect(result.warnings).toContain("period_derived_from_year_phrase");
expect(result.extracted_filters.counterparty).not.toBe("плс");
});
it("extracts explicit year range period from phrase", () => {
const result = extractAddressFilters(
"Какие документы по СВК за 2000 - 2025 год?",
"list_documents_by_counterparty"
);
expect(result.extracted_filters.counterparty).toBe("СВК");
expect(result.extracted_filters.period_from).toBe("2000-01-01");
expect(result.extracted_filters.period_to).toBe("2025-12-31");
expect(result.warnings).toContain("period_derived_from_year_range_phrase");
});
it("extracts multiline year range period from phrase", () => {
const result = extractAddressFilters(
"Какие документы по СВК за 2000 - 2025\n год?",
"list_documents_by_counterparty"
);
expect(result.extracted_filters.counterparty).toBe("СВК");
expect(result.extracted_filters.period_from).toBe("2000-01-01");
expect(result.extracted_filters.period_to).toBe("2025-12-31");
expect(result.warnings).toContain("period_derived_from_year_range_phrase");
expect(result.warnings).not.toContain("period_derived_from_year_phrase");
});
it("extracts russian year range period from 'с ... по ...' phrase", () => {
const result = extractAddressFilters(
"какие есть доки по свк с 2020 по 2025 год",
"list_documents_by_counterparty"
);
expect(result.extracted_filters.counterparty).toBe("свк");
expect(result.extracted_filters.period_from).toBe("2020-01-01");
expect(result.extracted_filters.period_to).toBe("2025-12-31");
expect(result.warnings).toContain("period_derived_from_year_range_phrase");
expect(result.warnings).not.toContain("period_defaulted_last_90_days");
});
it("treats 'за любой период' as all-time hint and keeps loose by-anchor", () => {
const result = extractAddressFilters(
"за любой период есть что-то по свк?",
"list_documents_by_counterparty"
);
expect(result.extracted_filters.counterparty).toBe("свк");
expect(result.extracted_filters.period_from).toBeUndefined();
expect(result.extracted_filters.period_to).toBeUndefined();
expect(result.warnings).toContain("counterparty_anchor_derived_from_loose_by_phrase");
expect(result.warnings).not.toContain("period_defaulted_last_90_days");
});
});
describe("address query limited taxonomy and stage diagnostics", () => {
@ -93,7 +256,7 @@ describe("address query limited taxonomy and stage diagnostics", () => {
const service = new AddressQueryService();
const result = await service.tryHandle("which documents form balance for account 62 as of 2020-07-31");
expect(result?.handled).toBe(true);
expect(result?.response_type).toBe("LIMITED_WITH_REASON");
expect(["LIMITED_WITH_REASON", "FACTUAL_LIST"]).toContain(result?.response_type);
expect(result?.debug.anchor_type).toBe("account");
expect(result?.debug.rows_fetched).toBeTypeOf("number");
@ -108,6 +271,7 @@ describe("address query limited taxonomy and stage diagnostics", () => {
expect(result?.debug.match_failure_stage).toBeDefined();
expect([
"error",
"no_raw_rows",
"raw_rows_received_but_not_materialized",
"materialized_but_not_anchor_matched",
@ -122,6 +286,50 @@ describe("address query limited taxonomy and stage diagnostics", () => {
expect(result?.debug.account_scope_match_strategy).toBe("account_code_regex_plus_alias_map_v1");
expect(result?.debug.account_scope_drop_reason).toBeDefined();
});
it("keeps short slang docs request in address lane (no deep fallback)", async () => {
const service = new AddressQueryService();
const result = await service.tryHandle("какие доки есть по свк за 2021");
expect(result?.handled).toBe(true);
expect(result?.debug.detected_mode).toBe("address_query");
expect(result?.debug.detected_intent).toBe("list_documents_by_counterparty");
expect(result?.debug.extracted_filters.counterparty).toBe("свк");
expect(result?.debug.extracted_filters.period_from).toBe("2021-01-01");
expect(result?.debug.extracted_filters.period_to).toBe("2021-12-31");
});
it("keeps typo slang docs request in address lane and extracts implicit anchor", async () => {
const service = new AddressQueryService();
const result = await service.tryHandle("свк доки за 20год покеж");
expect(result?.handled).toBe(true);
expect(result?.debug.detected_mode).toBe("address_query");
expect(result?.debug.detected_intent).toBe("list_documents_by_counterparty");
expect(result?.debug.extracted_filters.counterparty).toBe("свк");
expect(result?.debug.extracted_filters.period_from).toBe("2020-01-01");
expect(result?.debug.extracted_filters.period_to).toBe("2020-12-31");
});
it("keeps noisy docs request in address lane and ignores slang tail token", async () => {
const service = new AddressQueryService();
const result = await service.tryHandle("свк 20 год - покажи доки плс");
expect(result?.handled).toBe(true);
expect(result?.debug.detected_mode).toBe("address_query");
expect(result?.debug.detected_intent).toBe("list_documents_by_counterparty");
expect(result?.debug.extracted_filters.counterparty).toBe("свк");
expect(result?.debug.extracted_filters.counterparty).not.toBe("плс");
expect(result?.debug.extracted_filters.period_from).toBe("2020-01-01");
expect(result?.debug.extracted_filters.period_to).toBe("2020-12-31");
});
it("auto-broadens out-of-window period and returns available factual rows", async () => {
const service = new AddressQueryService();
const result = await service.tryHandle("Какие документы по СВК за 2000 год?");
expect(result?.handled).toBe(true);
expect(["FACTUAL_LIST", "LIMITED_WITH_REASON"]).toContain(result?.response_type);
if (result?.response_type === "FACTUAL_LIST") {
expect(result?.debug.limited_reason_category).toBeNull();
}
});
});
describe("address recipe catalog counterparty filtering", () => {
@ -134,7 +342,7 @@ describe("address recipe catalog counterparty filtering", () => {
expect(selected.selected_recipe).toBeTruthy();
const plan = buildAddressRecipePlan(selected.selected_recipe!, filters);
expect(plan.limit).toBe(200);
expect(plan.limit).toBe(1000);
});
it("boosts limit for english all-time counterparty queries", () => {
@ -146,7 +354,7 @@ describe("address recipe catalog counterparty filtering", () => {
expect(selected.selected_recipe).toBeTruthy();
const plan = buildAddressRecipePlan(selected.selected_recipe!, filters);
expect(plan.limit).toBe(200);
expect(plan.limit).toBe(1000);
});
it("cuts english all-time tail from counterparty anchor", () => {
@ -159,4 +367,16 @@ describe("address recipe catalog counterparty filtering", () => {
expect(result.extracted_filters.period_to).toBeUndefined();
expect(result.warnings).not.toContain("period_defaulted_last_90_days");
});
it("boosts limit for account snapshot queries with explicit account", () => {
const filters = extractAddressFilters(
"Какой остаток по счету 60 на дату 2020-07-31",
"account_balance_snapshot"
).extracted_filters;
const selected = selectAddressRecipe("account_balance_snapshot", filters);
expect(selected.selected_recipe).toBeTruthy();
const plan = buildAddressRecipePlan(selected.selected_recipe!, filters);
expect(plan.limit).toBe(200);
});
});

View File

@ -0,0 +1,134 @@
import { describe, expect, it, vi } from "vitest";
import { AssistantService } from "../src/services/assistantService";
import { AssistantSessionStore } from "../src/services/assistantSessionStore";
function buildAddressLaneResult(overrides?: Record<string, unknown>): any {
return {
handled: true,
reply_text: "Собран список документов по контрагенту.",
reply_type: "factual",
response_type: "FACTUAL_LIST",
debug: {
detected_mode: "address_query",
detected_mode_confidence: "high",
query_shape: "DOCUMENT_LIST",
query_shape_confidence: "medium",
detected_intent: "list_documents_by_counterparty",
detected_intent_confidence: "medium",
extracted_filters: {
sort: "period_desc",
limit: 20,
counterparty: "свк"
},
missing_required_filters: [],
selected_recipe: "address_documents_by_counterparty_v1",
mcp_call_status_legacy: "matched_non_empty",
account_scope_mode: "preferred",
account_scope_fallback_applied: false,
anchor_type: "counterparty",
anchor_value_raw: "свк",
anchor_value_resolved: "Группа СВК",
resolver_confidence: "medium",
ambiguity_count: 0,
match_failure_stage: "none",
match_failure_reason: null,
mcp_call_status: "matched_non_empty",
rows_fetched: 20,
raw_rows_received: 20,
rows_after_account_scope: 5,
rows_after_recipe_filter: 3,
rows_materialized: 5,
rows_matched: 3,
raw_row_keys_sample: [],
materialization_drop_reason: "none",
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",
runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS",
limited_reason_category: null,
response_type: "FACTUAL_LIST",
limitations: [],
reasons: ["address_action_detected", "address_entity_detected"]
},
...(overrides ?? {})
};
}
describe("assistant address follow-up carryover", () => {
it("keeps short follow-up in address lane by reusing previous anchor context", async () => {
const calls: Array<{ message: string; options?: any }> = [];
const addressQueryService = {
tryHandle: vi.fn(async (message: string, options?: any) => {
calls.push({ message, options });
if (message === "какие есть доки по свк с 2020 по 2025 год") {
return buildAddressLaneResult();
}
if (message === "а за все время?" && !options?.followupContext) {
return null;
}
if (message === "а за все время?" && options?.followupContext) {
return buildAddressLaneResult({
reply_text: "Собран список документов по контрагенту за все время.",
debug: {
...buildAddressLaneResult().debug,
reasons: ["address_action_detected", "address_entity_detected", "address_followup_context_applied"]
}
});
}
return null;
})
} as any;
const normalizerService = {
normalize: vi.fn(async () => ({
assistant_reply: "normalizer_fallback_should_not_be_used",
reply_type: "partial_coverage",
debug: {}
}))
} as any;
const sessions = new AssistantSessionStore();
const service = new AssistantService(
normalizerService,
sessions as any,
{} as any,
{ persistSession: vi.fn() } as any,
addressQueryService
);
const sessionId = `asst-address-followup-${Date.now()}`;
const first = await service.handleMessage({
session_id: sessionId,
user_message: "какие есть доки по свк с 2020 по 2025 год",
useMock: true
} as any);
expect(first.ok).toBe(true);
expect(first.reply_type).toBe("factual");
const second = await service.handleMessage({
session_id: sessionId,
user_message: "а за все время?",
useMock: true
} as any);
expect(second.ok).toBe(true);
expect(second.reply_type).toBe("factual");
expect(second.debug?.detected_mode).toBe("address_query");
expect(second.debug?.detected_intent).toBe("list_documents_by_counterparty");
expect(second.debug?.extracted_filters?.counterparty).toBe("свк");
expect(second.debug?.answer_grounding_check?.reasons).toContain("address_followup_context_applied");
expect(calls).toHaveLength(3);
expect(calls[0].message).toBe("какие есть доки по свк с 2020 по 2025 год");
expect(calls[1].message).toBe("а за все время?");
expect(calls[1].options?.followupContext).toBeUndefined();
expect(calls[2].message).toBe("а за все время?");
expect(calls[2].options?.followupContext?.previous_intent).toBe("list_documents_by_counterparty");
expect(calls[2].options?.followupContext?.previous_anchor_type).toBe("counterparty");
expect(calls[2].options?.followupContext?.previous_anchor_value).toBe("Группа СВК");
expect(calls[2].options?.followupContext?.previous_filters?.counterparty).toBe("свк");
expect(normalizerService.normalize).not.toHaveBeenCalled();
});
});

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>NDC AI Normalizer Playground</title>
<script type="module" crossorigin src="/assets/index-B5_Zqbf2.js"></script>
<script type="module" crossorigin src="/assets/index-BFy6DcyX.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Ch7jCAii.css">
</head>
<body>

View File

@ -25,6 +25,7 @@ const SESSION_CONFIG_KEY = "ndc_normalizer_session_config_v1";
const ASSISTANT_STAGES = ["Разбираю запрос", "Ищу данные", "Собираю ответ"];
const DEFAULT_UI_MODE: UiMode = "assistant";
const AUTOLOAD_PROMPT_VERSION = "normalizer_v2_0_2";
const ASSISTANT_PROMPT_VERSION = "address_query_runtime_v1";
function withTs(message: string): string {
return `[${new Date().toLocaleTimeString("ru-RU")}] ${message}`;
@ -49,6 +50,8 @@ export default function App() {
const [appLogs, setAppLogs] = useState<string[]>([]);
const [activeTab, setActiveTab] = useState<TabKey>("normalized");
const [busy, setBusy] = useState(false);
const [modelsBusy, setModelsBusy] = useState(false);
const [modelOptions, setModelOptions] = useState<string[]>([]);
const [connectionStatus, setConnectionStatus] = useState("");
const [presetList, setPresetList] = useState<
Array<{
@ -104,6 +107,7 @@ export default function App() {
const parsed = JSON.parse(cached) as Partial<ConnectionState>;
setConnection((prev) => ({
...prev,
llmProvider: parsed.llmProvider === "local" ? "local" : "openai",
model: parsed.model ?? prev.model,
baseUrl: parsed.baseUrl ?? prev.baseUrl,
temperature: parsed.temperature ?? prev.temperature,
@ -174,6 +178,7 @@ export default function App() {
SESSION_CONFIG_KEY,
JSON.stringify({
model: connection.model,
llmProvider: connection.llmProvider,
baseUrl: connection.baseUrl,
temperature: connection.temperature,
maxOutputTokens: connection.maxOutputTokens
@ -187,8 +192,24 @@ export default function App() {
setLastError("");
try {
const payload = await apiClient.testConnection(connection);
setConnectionStatus(`OK - ${payload.model}`);
log(`OpenAI connection ok: ${payload.model}`);
if (payload.provider === "local") {
if (payload.model_found === true) {
setConnectionStatus(`LOCAL OK - ${payload.model}`);
log(`Local model is available: ${payload.model} (catalog size=${payload.models_count ?? "n/a"}).`);
} else if (payload.model_found === false) {
setConnectionStatus(`LOCAL OK, model not loaded - ${payload.model}`);
log(
`Local server is reachable, but model '${payload.model}' is not in loaded catalog. ` +
`Use 'Load model list' and select one of loaded models.`
);
} else {
setConnectionStatus(`LOCAL OK (model list unavailable) - ${payload.model}`);
log("Local server is reachable, but model catalog could not be verified.");
}
} else {
setConnectionStatus(`OPENAI OK - ${payload.model}`);
log(`OpenAI connection ok: ${payload.model}`);
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setConnectionStatus("Connection error");
@ -199,6 +220,33 @@ export default function App() {
}
}
async function reloadModels() {
setModelsBusy(true);
try {
const payload = await apiClient.listModels(connection);
const models = payload.models ?? [];
setModelOptions(models);
if (models.length > 0) {
setConnection((prev) => {
if (prev.model && models.includes(prev.model)) {
return prev;
}
return { ...prev, model: models[0] };
});
}
log(`Model catalog loaded (${connection.llmProvider}): ${models.length} items.`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
log(`Load model list error: ${message}`);
} finally {
setModelsBusy(false);
}
}
useEffect(() => {
setModelOptions([]);
}, [connection.llmProvider, connection.baseUrl]);
async function normalize(saveAsCase: boolean) {
setBusy(true);
setLastError("");
@ -451,7 +499,7 @@ export default function App() {
prompts,
userMessage,
sessionId: assistantSessionId || undefined,
promptVersion: "normalizer_v2_0_2",
promptVersion: ASSISTANT_PROMPT_VERSION,
context: {
periodHint: query.periodHint,
businessContext: query.businessContext
@ -504,7 +552,10 @@ export default function App() {
<div className="layout-grid">
<ConnectionPanel
value={connection}
modelOptions={modelOptions}
modelsBusy={modelsBusy}
onChange={setConnection}
onReloadModels={reloadModels}
onSaveLocalConfig={saveLocalConfig}
onTestConnection={testConnection}
lastStatus={connectionStatus}
@ -548,7 +599,10 @@ export default function App() {
<div className="layout-grid">
<ConnectionPanel
value={connection}
modelOptions={modelOptions}
modelsBusy={modelsBusy}
onChange={setConnection}
onReloadModels={reloadModels}
onSaveLocalConfig={saveLocalConfig}
onTestConnection={testConnection}
lastStatus={connectionStatus}

View File

@ -27,10 +27,30 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
}
export const apiClient = {
async testConnection(connection: ConnectionState): Promise<{ ok: boolean; model: string; timestamp: string }> {
return request("/openai/test-connection", {
async listModels(connection: ConnectionState): Promise<{ ok: boolean; models: string[]; count: number; timestamp: string }> {
return request("/llm/models", {
method: "POST",
body: JSON.stringify({
llmProvider: connection.llmProvider,
apiKey: connection.apiKey,
model: connection.model,
baseUrl: connection.baseUrl
})
});
},
async testConnection(connection: ConnectionState): Promise<{
ok: boolean;
provider: "openai" | "local";
model: string;
model_found: boolean | null;
models_count: number | null;
timestamp: string;
}> {
return request("/llm/test-connection", {
method: "POST",
body: JSON.stringify({
llmProvider: connection.llmProvider,
apiKey: connection.apiKey,
model: connection.model,
baseUrl: connection.baseUrl
@ -54,6 +74,7 @@ export const apiClient = {
return request("/normalize", {
method: "POST",
body: JSON.stringify({
llmProvider: params.connection.llmProvider,
apiKey: params.connection.apiKey,
model: params.connection.model,
baseUrl: params.connection.baseUrl,
@ -130,6 +151,7 @@ export const apiClient = {
method: "POST",
body: JSON.stringify({
normalizeConfig: {
llmProvider: input.connection.llmProvider,
apiKey: input.connection.apiKey,
model: input.connection.model,
baseUrl: input.connection.baseUrl,
@ -203,12 +225,13 @@ export const apiClient = {
mode: "assistant",
message: input.userMessage,
user_message: input.userMessage,
llmProvider: input.connection.llmProvider,
apiKey: input.connection.apiKey,
model: input.connection.model,
baseUrl: input.connection.baseUrl,
temperature: input.connection.temperature,
maxOutputTokens: input.connection.maxOutputTokens,
promptVersion: input.promptVersion ?? "normalizer_v2_0_2",
promptVersion: input.promptVersion ?? "address_query_runtime_v1",
systemPrompt: input.prompts.systemPrompt,
developerPrompt: input.prompts.developerPrompt,
domainPrompt: input.prompts.domainPrompt,

View File

@ -3,7 +3,10 @@ import type { ConnectionState } from "../state/types";
interface ConnectionPanelProps {
value: ConnectionState;
modelOptions: string[];
modelsBusy: boolean;
onChange: (next: ConnectionState) => void;
onReloadModels: () => Promise<void> | void;
onTestConnection: () => Promise<void> | void;
onSaveLocalConfig: () => void;
lastStatus: string;
@ -12,36 +15,94 @@ interface ConnectionPanelProps {
export function ConnectionPanel({
value,
modelOptions,
modelsBusy,
onChange,
onReloadModels,
onTestConnection,
onSaveLocalConfig,
lastStatus,
busy
}: ConnectionPanelProps) {
const isLocal = value.llmProvider === "local";
const modelInCatalog = modelOptions.includes(value.model);
return (
<PanelFrame
title="Подключение OpenAI"
subtitle="Ключ живет только в памяти сессии (не пишется в localStorage)."
actions={<span className="status-chip">{lastStatus || "Статус: не проверено"}</span>}
title="LLM Connection"
subtitle="Switch between OpenAI cloud and local OpenAI-compatible server."
actions={<span className="status-chip">{lastStatus || "Status: not checked"}</span>}
>
<div className="grid-two">
<label>
OpenAI API Key
Provider
<select
value={value.llmProvider}
onChange={(event) => {
const nextProvider = event.target.value === "local" ? "local" : "openai";
onChange({
...value,
llmProvider: nextProvider,
baseUrl: nextProvider === "local" ? "http://127.0.0.1:1234/v1" : "https://api.openai.com/v1"
});
}}
>
<option value="openai">OpenAI (token)</option>
<option value="local">Local (LM Studio / OpenAI-compatible)</option>
</select>
</label>
<label>
Model
<select
value={modelInCatalog ? value.model : "__manual__"}
onChange={(event) => {
const selected = event.target.value;
if (selected === "__manual__") {
return;
}
onChange({ ...value, model: selected });
}}
>
<option value="__manual__">Manual input</option>
{modelOptions.map((modelId) => (
<option key={modelId} value={modelId}>
{modelId}
</option>
))}
</select>
</label>
<label>
Model ID (manual)
<input
type="password"
value={value.apiKey}
onChange={(event) => onChange({ ...value, apiKey: event.target.value })}
placeholder="sk-..."
value={value.model}
onChange={(event) => onChange({ ...value, model: event.target.value })}
placeholder="qwen2.5-14b-instruct or lmstudio loaded model id"
/>
</label>
<label>
Model ID
<input value={value.model} onChange={(event) => onChange({ ...value, model: event.target.value })} />
</label>
<label>
Base URL
<input value={value.baseUrl} onChange={(event) => onChange({ ...value, baseUrl: event.target.value })} />
{!isLocal ? (
<label className="full-width">
OpenAI API Key
<input
type="password"
value={value.apiKey}
onChange={(event) => onChange({ ...value, apiKey: event.target.value })}
placeholder="sk-..."
/>
</label>
) : null}
<label className={isLocal ? "full-width" : undefined}>
{isLocal ? "Local server base URL" : "Base URL"}
<input
value={value.baseUrl}
onChange={(event) => onChange({ ...value, baseUrl: event.target.value })}
placeholder={isLocal ? "http://127.0.0.1:1234/v1" : "https://api.openai.com/v1"}
/>
</label>
<label>
Temperature
<input
@ -51,6 +112,7 @@ export function ConnectionPanel({
onChange={(event) => onChange({ ...value, temperature: Number(event.target.value) })}
/>
</label>
<label>
Max output tokens
<input
@ -60,12 +122,16 @@ export function ConnectionPanel({
/>
</label>
</div>
<div className="button-row">
<button type="button" onClick={() => onSaveLocalConfig()}>
Сохранить локальную конфигурацию
Save local config
</button>
<button type="button" onClick={() => onReloadModels()} disabled={busy || modelsBusy}>
{modelsBusy ? "Loading models..." : "Load model list"}
</button>
<button type="button" onClick={() => onTestConnection()} disabled={busy}>
{busy ? "Проверяем..." : "Проверить подключение"}
{busy ? "Checking..." : "Test connection"}
</button>
</div>
</PanelFrame>

View File

@ -1,6 +1,7 @@
import type { ConnectionState, PromptState, QueryState } from "./types";
export const DEFAULT_CONNECTION: ConnectionState = {
llmProvider: "openai",
apiKey: "",
model: "gpt-4o-mini",
baseUrl: "https://api.openai.com/v1",

View File

@ -1,6 +1,7 @@
export type TabKey = "normalized" | "fragments" | "scope" | "flags" | "route" | "raw" | "validation" | "logs";
export interface ConnectionState {
llmProvider: "openai" | "local";
apiKey: string;
model: string;
baseUrl: string;