diff --git a/docs/ADDRESS/1915.txt b/docs/ADDRESS/1915.txt
index e43ee54..6e32b1b 100644
--- a/docs/ADDRESS/1915.txt
+++ b/docs/ADDRESS/1915.txt
@@ -1,202 +1,682 @@
# Assistant conversation export
-session_id: asst-qsZmniA1bx
+session_id: asst-iK3ROKDxc-
export_mode: technical
-exported_at: 2026-04-08T08:42:43.575Z
+exported_at: 2026-04-08T20:11:03.943Z
## 1. user
-message_id: msg-f9gF7eEEGy
-created_at: 2026-04-08T08:40:56.493Z
+message_id: msg-G8gFxxM5kv
+created_at: 2026-04-08T20:08:42.509Z
reply_type: n/a
-Покажи документы по договору 19/15 за 2020
+а какие фичи по работе с 1с у тебя отработаны максималльно?
## 2. assistant
-message_id: msg-Je6z6-84tL
-created_at: 2026-04-08T08:41:03.503Z
-reply_type: factual
-trace_id: address-bMqqNTkC2y
+message_id: msg-LtTTVb7Xt-
+created_at: 2026-04-08T20:08:53.645Z
+reply_type: factual_with_explanation
+trace_id: chat-nlxaS_9G53
-Период сохранен. Глубина live-выборки автоматически расширена до 1000 строк.
-Собран список документов по договору (live address lane).
-Строк отобрано: 8.
-1. 2020-08-31T15:00:06Z | Поступление товаров и услуг 00000000024 от 31.08.2020 15:00:06 | 19.04 / 76.05 | 233.33
-2. 2020-07-15T13:11:19Z | Реализация товаров и услуг 00000000008 от 15.07.2020 13:11:19 | 62.01 / 90.01.1 | 43804.8
-3. 2020-07-15T13:11:19Z | Реализация товаров и услуг 00000000008 от 15.07.2020 13:11:19 | 62.01 / 90.01.1 | 9776
-4. 2020-07-15T13:11:19Z | Реализация товаров и услуг 00000000008 от 15.07.2020 13:11:19 | 62.01 / 90.01.1 | 73968
-5. 2020-07-15T13:11:19Z | Реализация товаров и услуг 00000000008 от 15.07.2020 13:11:19 | 62.01 / 90.01.1 | 94924.8
-6. 2020-07-15T13:11:19Z | Реализация товаров и услуг 00000000008 от 15.07.2020 13:11:19 | 62.01 / 90.01.1 | 54400
-7. 2020-07-15T13:11:19Z | Реализация товаров и услуг 00000000008 от 15.07.2020 13:11:19 | 62.02 / 62.01 | 276873.6
-8. 2020-06-30T12:00:00Z | Поступление товаров и услуг 00000000015 от 30.06.2020 12:00:00 | 19.04 / 76.05 | 233.33
+Отлично справляюсь с анализом данных из справочников, документов и отчетов 1С. Максимально отлажены функции: 1. Чтение структуры базы (организации, сотрудники, контрагенты)
+2. Анализ финансовых операций по счетам
+3. Проверка правильности заполнения документов
+4. Поиск ошибок в данных (дублирование, несоответствия)
+5. Выполнение запросов к отчетам и формирование таблиц на их основе Для консультаций или помощи с конкретными запросами - обращайся!
### technical_debug_payload_json
```json
{
- "trace_id": "address-bMqqNTkC2y",
- "prompt_version": "address_query_runtime_v1",
- "schema_version": "address_query_runtime_v1",
+ "trace_id": "chat-nlxaS_9G53",
+ "prompt_version": "living_chat_router_v1",
+ "schema_version": "living_chat_router_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",
- "document_list_signal_detected",
- "documents_by_contract_signal_detected",
- "query_limit_auto_expanded_for_anchor_recovery"
- ],
- "why_included_summary": [],
- "selection_reason_summary": []
- },
- "dropped_intent_segments": [],
- "detected_mode": "address_query",
+ "detected_mode": "chat",
"detected_mode_confidence": "high",
- "query_shape": "DOCUMENT_LIST",
- "query_shape_confidence": "medium",
- "detected_intent": "list_documents_by_contract",
- "detected_intent_confidence": "medium",
- "extracted_filters": {
- "sort": "period_desc",
- "limit": 20,
- "contract": "19/15",
- "period_from": "2020-01-01",
- "period_to": "2020-12-31"
- },
- "missing_required_filters": [],
- "selected_recipe": "address_documents_by_contract_v1",
- "mcp_call_status_legacy": "matched_non_empty",
- "account_scope_mode": "preferred",
- "account_scope_fallback_applied": false,
- "anchor_type": "contract",
- "anchor_value_raw": "19/15",
- "anchor_value_resolved": "19/15",
- "resolver_confidence": "medium",
- "ambiguity_count": 0,
- "match_failure_stage": "none",
- "match_failure_reason": null,
- "mcp_call_status": "matched_non_empty",
- "rows_fetched": 1000,
- "raw_rows_received": 1000,
- "rows_after_account_scope": 438,
- "rows_after_recipe_filter": 11,
- "rows_materialized": 438,
- "rows_matched": 8,
- "raw_row_keys_sample": [
- "Период",
- "Регистратор",
- "СчетДт",
- "СчетКт",
- "Сумма",
- "Period",
- "Registrator",
- "AccountDt",
- "AccountKt",
- "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",
- "execution_lane": "address_query",
- "llm_decomposition_applied": true,
- "llm_decomposition_attempted": true,
- "llm_provider_used": "local",
- "llm_decomposition_trace_id": "RsRWAyD29sjgY7",
- "llm_decomposition_effective_message": "Показать документы по договору 19/15 за период 2020 года.",
- "llm_decomposition_reason": "normalized_fragment_applied",
- "llm_canonical_candidate_detected": true,
- "llm_predecompose_contract": {
+ "execution_lane": "living_chat",
+ "living_router_mode": "chat",
+ "living_router_reason": "predecompose_unsupported_mode",
+ "living_chat_response_source": "llm_chat",
+ "living_chat_script_guard_applied": false,
+ "living_chat_script_guard_reason": null,
+ "living_chat_data_scope_probe_status": null,
+ "living_chat_data_scope_probe_channel": null,
+ "living_chat_data_scope_probe_org_count": 0,
+ "living_chat_data_scope_probe_error": null,
+ "address_llm_predecompose_attempted": true,
+ "address_llm_predecompose_applied": true,
+ "address_llm_predecompose_reason": "normalized_fragment_applied",
+ "address_llm_predecompose_contract": {
"schema_version": "address_llm_predecompose_contract_v1",
- "source_message": "Покажи документы по договору 19/15 за 2020",
- "canonical_message": "Показать документы по договору 19/15 за период 2020 года.",
- "mode": "address_query",
- "mode_confidence": "high",
- "query_shape": "DOCUMENT_LIST",
- "query_shape_confidence": "medium",
- "intent": "list_documents_by_contract",
- "intent_confidence": "medium",
+ "source_message": "а какие фичи по работе с 1с у тебя отработаны максималльно?",
+ "canonical_message": "какие функциональности для работы с 1С максимально отлажены?",
+ "mode": "unsupported",
+ "mode_confidence": "low",
+ "query_shape": "UNKNOWN",
+ "query_shape_confidence": "low",
+ "intent": "unknown",
+ "intent_confidence": "low",
"entities": {
"account": null,
"counterparty": null,
- "contract": "19/15",
+ "contract": null,
"document_type": null,
"document_ref": null,
"organization": null
},
"period": {
- "scope": "year",
- "period_from": "2020-01-01",
- "period_to": "2020-12-31",
+ "scope": "unspecified",
+ "period_from": null,
+ "period_to": null,
"as_of_date": null,
- "has_explicit_period": true
+ "has_explicit_period": false
},
- "aggregation_profile": "list_lookup"
+ "aggregation_profile": "unknown"
},
- "fallback_rule_hit": null,
- "sanitized_user_message": "покажи документы по договору 19/15 за 2020",
- "tool_gate_decision": "run_address_lane",
- "tool_gate_reason": "address_mode_classifier_detected",
- "answer_structure_v11": null,
- "investigation_state_snapshot": null,
+ "orchestration_contract_v1": {
+ "schema_version": "assistant_orchestration_contract_v1",
+ "hard_meta_mode": null,
+ "address_mode": "unsupported",
+ "address_mode_confidence": "low",
+ "address_intent": "unknown",
+ "address_intent_confidence": "low",
+ "strong_data_signal_detected": false,
+ "data_retrieval_signal_detected": false,
+ "followup_context_detected": false,
+ "unsupported_address_intent_fallback_to_deep": false,
+ "final_decision": {
+ "run_address_lane": false,
+ "tool_gate_decision": "skip_address_lane",
+ "tool_gate_reason": "llm_predecompose_unsupported_mode",
+ "living_mode": "chat",
+ "living_reason": "predecompose_unsupported_mode"
+ }
+ },
+ "tool_gate_decision": "skip_address_lane",
+ "tool_gate_reason": "llm_predecompose_unsupported_mode",
"normalized": null,
- "normalizer_output": {
- "trace_id": "RsRWAyD29sjgY7",
- "prompt_version": "normalizer_v2_0_2",
- "applied": true,
- "effective_message": "Показать документы по договору 19/15 за период 2020 года."
- }
+ "normalizer_output": null
}
```
## 3. user
-message_id: msg-1w0963YnGN
-created_at: 2026-04-08T08:41:09.598Z
+message_id: msg-wY_jNk5Lc3
+created_at: 2026-04-08T20:09:48.347Z
reply_type: n/a
-а теперь банковские операции
+какого рода ошибки ты можешь найти:,?
## 4. assistant
-message_id: msg-76jKqn5b1O
-created_at: 2026-04-08T08:41:17.668Z
-reply_type: factual
-trace_id: address-mB-Zxj7T3o
+message_id: msg-RnJxRlLERw
+created_at: 2026-04-08T20:10:00.849Z
+reply_type: factual_with_explanation
+trace_id: chat-UQ2WjKGXZ8
-Период сохранен. Глубина live-выборки автоматически расширена до 1000 строк.
-Собран список банковских операций по договору (live address lane).
-Строк отобрано: 3.
-1. 2020-10-15T13:34:19Z | Списание с расчетного счета 00000000245 от 15.10.2020 13:34:19 | 66.01 / 51 | 134206.53
-2. 2020-07-15T13:11:23Z | Списание с расчетного счета 00000000191 от 15.07.2020 13:11:23 | 66.02 / 51 | 54125.93
-3. 2020-07-15T13:11:22Z | Списание с расчетного счета 00000000190 от 15.07.2020 13:11:22 | 66.01 / 51 | 122035.07
+Я могу помочь идентифицировать и проанализировать типичные проблемы в данных 1С: 1. Ошибки целостности: пропущенные или дублирующиеся записи, отсутствующие связи между документами.
+2. Финансовые расхождения: несбалансированные оборотно-полярные счета, лишние проводки.
+3. Неверная настройка параметров: неправильное указание налоговых ставок, типов документов, ошибки в настройках расчетов.
+4. Проблемы с датами: операции с несоответствующими периодами или некорректной хронологией. При запросе предоставлю детализированный анализ конкретных данных.
### technical_debug_payload_json
```json
{
- "trace_id": "address-mB-Zxj7T3o",
+ "trace_id": "chat-UQ2WjKGXZ8",
+ "prompt_version": "living_chat_router_v1",
+ "schema_version": "living_chat_router_v1",
+ "fallback_type": "none",
+ "detected_mode": "chat",
+ "detected_mode_confidence": "high",
+ "execution_lane": "living_chat",
+ "living_router_mode": "chat",
+ "living_router_reason": "predecompose_unsupported_mode",
+ "living_chat_response_source": "llm_chat",
+ "living_chat_script_guard_applied": false,
+ "living_chat_script_guard_reason": null,
+ "living_chat_data_scope_probe_status": null,
+ "living_chat_data_scope_probe_channel": null,
+ "living_chat_data_scope_probe_org_count": 0,
+ "living_chat_data_scope_probe_error": null,
+ "address_llm_predecompose_attempted": true,
+ "address_llm_predecompose_applied": true,
+ "address_llm_predecompose_reason": "normalized_fragment_applied",
+ "address_llm_predecompose_contract": {
+ "schema_version": "address_llm_predecompose_contract_v1",
+ "source_message": "какого рода ошибки ты можешь найти:,?",
+ "canonical_message": "Какие типы ошибок можно обнаружить?",
+ "mode": "unsupported",
+ "mode_confidence": "low",
+ "query_shape": "UNKNOWN",
+ "query_shape_confidence": "low",
+ "intent": "unknown",
+ "intent_confidence": "low",
+ "entities": {
+ "account": null,
+ "counterparty": null,
+ "contract": null,
+ "document_type": null,
+ "document_ref": null,
+ "organization": null
+ },
+ "period": {
+ "scope": "unspecified",
+ "period_from": null,
+ "period_to": null,
+ "as_of_date": null,
+ "has_explicit_period": false
+ },
+ "aggregation_profile": "unknown"
+ },
+ "orchestration_contract_v1": {
+ "schema_version": "assistant_orchestration_contract_v1",
+ "hard_meta_mode": null,
+ "address_mode": "unsupported",
+ "address_mode_confidence": "low",
+ "address_intent": "unknown",
+ "address_intent_confidence": "low",
+ "strong_data_signal_detected": false,
+ "data_retrieval_signal_detected": false,
+ "followup_context_detected": false,
+ "unsupported_address_intent_fallback_to_deep": false,
+ "final_decision": {
+ "run_address_lane": false,
+ "tool_gate_decision": "skip_address_lane",
+ "tool_gate_reason": "llm_predecompose_unsupported_mode",
+ "living_mode": "chat",
+ "living_reason": "predecompose_unsupported_mode"
+ }
+ },
+ "tool_gate_decision": "skip_address_lane",
+ "tool_gate_reason": "llm_predecompose_unsupported_mode",
+ "normalized": null,
+ "normalizer_output": null
+}
+```
+
+## 5. user
+message_id: msg-4wBsqCfU_d
+created_at: 2026-04-08T20:10:32.657Z
+reply_type: n/a
+
+найди какие либо ошибки на 21 мая 2022 года
+
+## 6. assistant
+message_id: msg-arsYpaOUVA
+created_at: 2026-04-08T20:10:51.465Z
+reply_type: clarification_required
+trace_id: FzrxiRJKw1OzOa
+
+Коротко: Проблема подтверждается частично; для уверенного вывода нужны уточнения. Что сломано:
+- Есть признаки проблемы, но без уточнений по периоду и объекту вывод ненадежен. Почему это похоже на проблему:
+- Сигнал проблемы есть, но механизм подтвержден не полностью. На чем это основано:
+- Опора частичная: часть требований покрыта не полностью.
+- Отдельно не подтверждено или покрыто частично: R1. Что проверить первым:
+- Проверьте связку документов и проводок по проблемному участку в указанном периоде. Ограничения:
+- Якоря из вопроса без прямого подтверждения: 2022, period:2022.
+- Ни одно требование не получило подтвержденного покрытия.
+- required anchors claim-bound grounding.
+- evidence.
+- Targeted evidence acquisition claim target path.
+
+### technical_debug_payload_json
+```json
+{
+ "trace_id": "FzrxiRJKw1OzOa",
+ "prompt_version": "address_query_runtime_v1",
+ "schema_version": "v1",
+ "fallback_type": "clarification",
+ "route_summary": null,
+ "fragments": [],
+ "requirements_extracted": [
+ {
+ "requirement_id": "R1",
+ "source_fragment_id": null,
+ "requirement_text": "найди какие либо ошибки на 21 мая 2022 года",
+ "subject_tokens": [],
+ "status": "clarification_needed",
+ "route": null
+ }
+ ],
+ "coverage_report": {
+ "requirements_total": 1,
+ "requirements_covered": 0,
+ "requirements_uncovered": [],
+ "requirements_partially_covered": [],
+ "clarification_needed_for": [
+ "R1"
+ ],
+ "out_of_scope_requirements": []
+ },
+ "routes": [],
+ "retrieval_status": [],
+ "retrieval_results": [],
+ "answer_grounding_check": {
+ "status": "no_grounded_answer",
+ "route_subject_match": true,
+ "missing_requirements": [
+ "R1"
+ ],
+ "reasons": [
+ "Ни одно требование не получило подтвержденного покрытия.",
+ "Нет explainable-сигналов why_included в результатах выборки.",
+ "Недостаточно покрытия required anchors для claim-bound grounding.",
+ "Недостаточно допустимого evidence для обоснованного ответа.",
+ "Targeted evidence acquisition не дал допустимых попаданий по claim target path."
+ ],
+ "why_included_summary": [],
+ "selection_reason_summary": []
+ },
+ "dropped_intent_segments": [],
+ "question_type_class": "unknown",
+ "company_anchors": {
+ "contract_numbers": [],
+ "document_numbers": [],
+ "dates": [],
+ "amounts": [],
+ "accounts": [],
+ "periods": [
+ "2022"
+ ],
+ "document_types": [],
+ "all": [
+ "period:2022"
+ ]
+ },
+ "business_scope_raw": [],
+ "business_scope_resolved": [
+ "company_specific_accounting"
+ ],
+ "company_grounding_applied": true,
+ "scope_resolution_reason": [
+ "settlement_claim_company_scope_recovery"
+ ],
+ "company_scope_resolution_reason": [
+ "settlement_claim_company_scope_recovery"
+ ],
+ "raw_time_anchor": null,
+ "raw_time_scope": null,
+ "resolved_time_anchor": null,
+ "resolved_primary_period": null,
+ "effective_primary_period": null,
+ "temporal_guard_input": null,
+ "temporal_alignment_status": "conflicting",
+ "temporal_resolution_source": "normalized_time_scope:missing",
+ "temporal_guard_basis": "none",
+ "temporal_guard_applied": false,
+ "temporal_guard_outcome": "passed",
+ "temporal_guard": {
+ "raw_time_anchor": null,
+ "raw_time_scope": null,
+ "resolved_time_anchor": null,
+ "resolved_primary_period": null,
+ "effective_primary_period": null,
+ "temporal_guard_input": null,
+ "temporal_alignment_status": "conflicting",
+ "temporal_resolution_source": "normalized_time_scope:missing",
+ "temporal_guard_basis": "none",
+ "temporal_guard_applied": false,
+ "temporal_guard_outcome": "passed",
+ "primary_period_window": null,
+ "allowed_context_window": null,
+ "controlled_temporal_expansion_enabled": false,
+ "context_expansion_reasons_allowed": [
+ "prehistory",
+ "carryover",
+ "post_period_closure",
+ "long_running_contract_context"
+ ],
+ "normalized_anchor_drift_detected": false,
+ "reason_codes": [
+ "missing_resolved_primary_period"
+ ]
+ },
+ "raw_numeric_tokens": [
+ "21",
+ "2022"
+ ],
+ "classified_numeric_tokens": [
+ {
+ "token": "21",
+ "classification": "date_token"
+ }
+ ],
+ "rejected_as_non_accounts": [
+ "21"
+ ],
+ "resolved_account_anchors": [],
+ "domain_polarity_guard": {
+ "applied": false,
+ "polarity": "not_applicable",
+ "outcome": "not_applicable",
+ "supplier_score": 0,
+ "customer_score": 0,
+ "account_scope": [],
+ "raw_numeric_tokens": [
+ "21",
+ "2022"
+ ],
+ "classified_numeric_tokens": [
+ {
+ "token": "21",
+ "classification": "date_token"
+ }
+ ],
+ "rejected_as_non_accounts": [
+ "21"
+ ],
+ "resolved_account_anchors": [],
+ "rejected_problem_units": 0,
+ "rejected_evidence": 0,
+ "critical_contradiction": false,
+ "reason_codes": []
+ },
+ "claim_anchor_audit": {
+ "claim_type": "prove_settlement_closure_state",
+ "settlement_role": "unknown",
+ "settlement_role_resolution_reason": [
+ "settlement_role_resolved_unknown"
+ ],
+ "polarity_resolution_status": "unknown",
+ "required_anchors": [
+ "period",
+ "account_scope",
+ "counterparty_scope_or_contract",
+ "closure_signal"
+ ],
+ "resolved_anchors": {
+ "period": [
+ "2022"
+ ],
+ "account_scope": [],
+ "amounts": [],
+ "contract": [],
+ "document_numbers": [],
+ "document_types": [],
+ "counterparty_scope": [],
+ "advance_signal": [],
+ "closure_signal": [],
+ "vat_signal": [],
+ "chain_signal": [],
+ "close_signal": [],
+ "cost_scope": [],
+ "rbp_signal": [],
+ "writeoff_signal": [],
+ "fixed_asset_signal": [],
+ "amortization_signal": [],
+ "expected_fa_set": [],
+ "actual_fa_set": []
+ },
+ "missing_anchors": [
+ "account_scope",
+ "counterparty_scope_or_contract",
+ "closure_signal"
+ ],
+ "claim_anchor_resolution_rate": 0.25,
+ "primary_period": null,
+ "allowed_context_window": null,
+ "context_expansion_reasons_allowed": [
+ "prehistory",
+ "carryover",
+ "post_period_closure",
+ "long_running_contract_context"
+ ],
+ "reason_codes": [
+ "claim_missing_required_anchors",
+ "claim_anchor_resolution_low",
+ "unresolved_supplier_customer_polarity"
+ ]
+ },
+ "settlement_role": "unknown",
+ "settlement_role_resolution_reason": [
+ "settlement_role_resolved_unknown"
+ ],
+ "polarity_resolution_status": "unknown",
+ "targeted_evidence_acquisition": {
+ "claim_type": "prove_settlement_closure_state",
+ "required_checks": [
+ "payment_document_found",
+ "contract_matched",
+ "settlement_object_matched",
+ "closing_document_found",
+ "register_closure_entry_found",
+ "posting_link_found"
+ ],
+ "check_status": {
+ "payment_document_found": "not_found",
+ "contract_matched": "not_found",
+ "settlement_object_matched": "not_found",
+ "closing_document_found": "not_found",
+ "register_closure_entry_found": "not_found",
+ "posting_link_found": "not_found"
+ },
+ "targeted_item_hits": 0,
+ "targeted_evidence_hits": 0,
+ "targeted_evidence_hit_rate": 0,
+ "targeted_evidence_source_refs": [],
+ "reason_codes": [
+ "targeted_evidence_not_found",
+ "targeted_evidence_hit_rate_low"
+ ]
+ },
+ "evidence_admissibility_gate": {
+ "candidate_evidence_total": 0,
+ "admissible_evidence_count": 0,
+ "rejected_evidence_count": 0,
+ "rejected_item_count": 0,
+ "reject_breakdown": {
+ "wrong_period": 0,
+ "wrong_domain": 0,
+ "wrong_account_scope": 0,
+ "weak_source_mapping": 0,
+ "zero_live_match": 0,
+ "future_dated_or_out_of_window": 0
+ },
+ "category_breakdown": {
+ "hard_evidence": 0,
+ "supporting_signal": 0,
+ "inadmissible_noise": 0
+ },
+ "reason_codes": [
+ "no_admissible_evidence_for_grounded_answer"
+ ]
+ },
+ "eligibility_time_basis": "none",
+ "grounded_answer_eligibility_guard": {
+ "eligible": false,
+ "temporal_passed": true,
+ "eligibility_time_basis": "none",
+ "business_scope_passed": true,
+ "polarity_passed": true,
+ "claim_anchors_passed": false,
+ "claim_anchor_resolution_rate": 0.25,
+ "missing_required_anchors": 3,
+ "admissible_evidence_count": 0,
+ "critical_contradiction": false,
+ "outcome": "limited_or_insufficient_evidence",
+ "grounding_mode": "limited_or_insufficient_evidence",
+ "reason_codes": [
+ "claim_anchor_coverage_insufficient",
+ "admissible_evidence_count_zero",
+ "targeted_evidence_hit_rate_zero"
+ ]
+ },
+ "problem_centric_answer_applied": false,
+ "problem_units_used_count": 0,
+ "problem_answer_mode": "stage1_policy_v11",
+ "address_llm_predecompose_attempted": true,
+ "address_llm_predecompose_applied": true,
+ "address_llm_predecompose_reason": "normalized_fragment_applied",
+ "address_llm_predecompose_provider": "local",
+ "address_fallback_rule_hit": null,
+ "address_tool_gate_decision": "run_address_lane",
+ "address_tool_gate_reason": "llm_canonical_data_signal_detected",
+ "address_llm_predecompose_contract": {
+ "schema_version": "address_llm_predecompose_contract_v1",
+ "source_message": "найди какие либо ошибки на 21 мая 2022 года",
+ "canonical_message": "Найти ошибки в бухгалтерии за 21 мая 2022 года.",
+ "mode": "deep_analysis",
+ "mode_confidence": "high",
+ "query_shape": "EXPLAIN_OR_REASON",
+ "query_shape_confidence": "high",
+ "intent": "unknown",
+ "intent_confidence": "low",
+ "entities": {
+ "account": null,
+ "counterparty": null,
+ "contract": null,
+ "document_type": null,
+ "document_ref": null,
+ "organization": null
+ },
+ "period": {
+ "scope": "range",
+ "period_from": "2022-05-01",
+ "period_to": "2022-05-31",
+ "as_of_date": null,
+ "has_explicit_period": true
+ },
+ "aggregation_profile": "unknown"
+ },
+ "orchestration_contract_v1": {
+ "schema_version": "assistant_orchestration_contract_v1",
+ "hard_meta_mode": null,
+ "address_mode": "deep_analysis",
+ "address_mode_confidence": "high",
+ "address_intent": "unknown",
+ "address_intent_confidence": "low",
+ "strong_data_signal_detected": true,
+ "data_retrieval_signal_detected": false,
+ "followup_context_detected": false,
+ "unsupported_address_intent_fallback_to_deep": false,
+ "final_decision": {
+ "run_address_lane": true,
+ "tool_gate_decision": "run_address_lane",
+ "tool_gate_reason": "llm_canonical_data_signal_detected",
+ "living_mode": "address_data",
+ "living_reason": "address_lane_triggered"
+ }
+ },
+ "answer_structure_v11": {
+ "schema_version": "answer_structure_v1_1",
+ "answer_summary": "Нужны уточнения: без сужения фокуса надежный вывод невозможен.",
+ "direct_answer": "Есть признаки проблемы, но без уточнений по периоду и объекту вывод ненадежен.",
+ "mechanism_block": {
+ "status": "unresolved",
+ "mechanism_notes": [],
+ "limitation_reason_codes": []
+ },
+ "evidence_block": {
+ "evidence_ids": [],
+ "source_refs": [],
+ "mechanism_notes": [],
+ "coverage_note": "coverage_partial_or_limited"
+ },
+ "uncertainty_block": {
+ "open_uncertainties": [
+ "R1",
+ "missing_anchor:document_or_object",
+ "missing_anchor:counterparty"
+ ],
+ "limitations": [
+ "Ни одно требование не получило подтвержденного покрытия.",
+ "Нет explainable-сигналов why_included в результатах выборки.",
+ "Недостаточно покрытия required anchors для claim-bound grounding.",
+ "Недостаточно допустимого evidence для обоснованного ответа.",
+ "Targeted evidence acquisition не дал допустимых попаданий по claim target path.",
+ "Часть якорей запроса пока не подтверждена в опоре: 2022, period:2022."
+ ]
+ },
+ "next_step_block": {
+ "recommended_actions": [
+ "Дайте недостающие якоря (период/счет/объект), иначе сильный factual вывод невозможен."
+ ],
+ "clarification_questions": [
+ "Укажите документ/GUID/конкретный объект для трассировки.",
+ "Укажите контрагента или группу контрагентов.",
+ "Закройте уточнения для требований: R1."
+ ]
+ }
+ },
+ "investigation_state_snapshot": {
+ "schema_version": "investigation_state_v1",
+ "session_id": "asst-iK3ROKDxc-",
+ "status": "active",
+ "turn_index": 1,
+ "updated_at": "2026-04-08T20:10:51.463Z",
+ "question_id": "msg-4wBsqCfU_d",
+ "question_scope_id": "p:2022|s:найди какие либо ошибки на 21 мая 2022 года",
+ "scope_origin": "explicit_from_message",
+ "focus": {
+ "domain": null,
+ "period": "2022",
+ "primary_accounts": [],
+ "active_query_subject": "найди какие либо ошибки на 21 мая 2022 года"
+ },
+ "narrowing_status": "unknown",
+ "evidence_refs": [],
+ "open_uncertainties": [
+ "clarify:R1"
+ ],
+ "last_answer_mode": "clarification_required",
+ "followup_context": {
+ "previous_question_id": null,
+ "last_user_message": "найди какие либо ошибки на 21 мая 2022 года",
+ "referenced_requirement_ids": [
+ "R1"
+ ],
+ "active_domain": null,
+ "active_requirement_ids": [
+ "R1"
+ ],
+ "uncovered_requirement_ids": [
+ "R1"
+ ],
+ "last_problem_unit_id": null,
+ "settlement_next_actions": [],
+ "evidence_summary": [],
+ "question_scope_id": "p:2022|s:найди какие либо ошибки на 21 мая 2022 года",
+ "scope_origin": "explicit_from_message"
+ },
+ "query_mode_hint": "investigation_candidate"
+ },
+ "normalized": null
+}
+```
+
+# Assistant conversation export
+session_id: asst-aIQwk-ahQZ
+export_mode: technical
+exported_at: 2026-04-08T20:21:49.326Z
+
+## 1. user
+message_id: msg-yAtAiths2a
+created_at: 2026-04-08T20:18:40.822Z
+reply_type: n/a
+
+какойй прогноз оплаты ндс на 12-05-2020
+
+## 2. assistant
+message_id: msg-gOiJAixZds
+created_at: 2026-04-08T20:18:46.602Z
+reply_type: factual
+trace_id: address-drMFIlDaL_
+
+Собран прогноз НДС к уплате по фактическим проводкам (НДС-субсчета 68.02*/19*).
+Строк агрегата: 4.
+Оборот по кредиту 68*: 142500.00.
+Оборот по дебету 68*: 933.32.
+Нетто НДС (68 Кт - 68 Дт): 141566.68.
+Прогноз НДС к уплате: 141566.68.
+Потенциальный перенос/переплата: 0.00.
+Справочно по 19*: дебет 233.33, кредит 933.32.
+Период расчета (срез обязательств): 01.04.2020..12.05.2020.
+Налоговый период: 2 кв. 2020.
+Срок сдачи декларации: до 25.07.2020.
+Сроки уплаты: 28.07.2020, 28.08.2020, 28.09.2020.
+Ориентир по долям к уплате: 47188.89 / 47188.89 / 47188.90.
+Важно: даже при нулевой сумме к уплате декларация по НДС подается в установленный срок; переносы по выходным/праздникам сверяйте по календарю ФНС/1С.
+
+### technical_debug_payload_json
+```json
+{
+ "trace_id": "address-drMFIlDaL_",
"prompt_version": "address_query_runtime_v1",
"schema_version": "address_query_runtime_v1",
"fallback_type": "none",
@@ -219,14 +699,9 @@ trace_id: address-mB-Zxj7T3o
"route_subject_match": true,
"missing_requirements": [],
"reasons": [
- "address_entity_detected",
+ "account_code_detected",
"shape_not_detected",
- "bank_ops_by_counterparty_signal_detected",
- "intent_adjusted_to_contract_followup_context",
- "contract_from_followup_context",
- "period_from_followup_context",
- "address_followup_context_applied",
- "query_limit_auto_expanded_for_anchor_recovery"
+ "forecast_tax_signal_detected"
],
"why_included_summary": [],
"selection_reason_summary": []
@@ -236,34 +711,32 @@ trace_id: address-mB-Zxj7T3o
"detected_mode_confidence": "medium",
"query_shape": "UNKNOWN",
"query_shape_confidence": "low",
- "detected_intent": "bank_operations_by_contract",
- "detected_intent_confidence": "low",
+ "detected_intent": "vat_payable_forecast",
+ "detected_intent_confidence": "high",
"extracted_filters": {
"sort": "period_desc",
- "limit": 20,
- "contract": "19/15",
- "period_from": "2020-01-01",
- "period_to": "2020-12-31"
+ "period_from": "2020-04-01",
+ "period_to": "2020-05-12"
},
"missing_required_filters": [],
- "selected_recipe": "address_bank_operations_by_contract_v1",
+ "selected_recipe": "address_vat_payable_forecast_v1",
"mcp_call_status_legacy": "matched_non_empty",
"account_scope_mode": "preferred",
"account_scope_fallback_applied": false,
- "anchor_type": "contract",
- "anchor_value_raw": "19/15",
- "anchor_value_resolved": "19/15",
- "resolver_confidence": "medium",
+ "anchor_type": "unknown",
+ "anchor_value_raw": null,
+ "anchor_value_resolved": null,
+ "resolver_confidence": "low",
"ambiguity_count": 0,
"match_failure_stage": "none",
"match_failure_reason": null,
"mcp_call_status": "matched_non_empty",
- "rows_fetched": 1000,
- "raw_rows_received": 1000,
- "rows_after_account_scope": 234,
- "rows_after_recipe_filter": 3,
- "rows_materialized": 234,
- "rows_matched": 3,
+ "rows_fetched": 4,
+ "raw_rows_received": 4,
+ "rows_after_account_scope": 4,
+ "rows_after_recipe_filter": 4,
+ "rows_materialized": 4,
+ "rows_matched": 4,
"raw_row_keys_sample": [
"Период",
"Регистратор",
@@ -273,7 +746,6 @@ trace_id: address-mB-Zxj7T3o
"Period",
"Registrator",
"AccountDt",
- "AccountKt",
"Amount"
],
"materialization_drop_reason": "none",
@@ -289,25 +761,25 @@ trace_id: address-mB-Zxj7T3o
"account_scope_drop_reason": "not_applicable",
"runtime_readiness": "LIVE_QUERYABLE_WITH_LIMITS",
"limited_reason_category": null,
- "response_type": "FACTUAL_LIST",
+ "response_type": "FACTUAL_SUMMARY",
"execution_lane": "address_query",
- "llm_decomposition_applied": false,
+ "llm_decomposition_applied": true,
"llm_decomposition_attempted": true,
"llm_provider_used": "local",
- "llm_decomposition_trace_id": "fQPoAyba2nzNW_",
- "llm_decomposition_effective_message": "а теперь банковские операции",
- "llm_decomposition_reason": "normalized_fragment_same",
+ "llm_decomposition_trace_id": "N0qN6tFVvM-jRN",
+ "llm_decomposition_effective_message": "Прогноз оплаты НДС на 12.05.2020.",
+ "llm_decomposition_reason": "normalized_fragment_applied",
"llm_canonical_candidate_detected": true,
"llm_predecompose_contract": {
"schema_version": "address_llm_predecompose_contract_v1",
- "source_message": "а теперь банковские операции",
- "canonical_message": "а теперь банковские операции",
+ "source_message": "какойй прогноз оплаты ндс на 12-05-2020",
+ "canonical_message": "Прогноз оплаты НДС на 12.05.2020.",
"mode": "address_query",
"mode_confidence": "medium",
"query_shape": "UNKNOWN",
"query_shape_confidence": "low",
- "intent": "bank_operations_by_counterparty",
- "intent_confidence": "medium",
+ "intent": "vat_payable_forecast",
+ "intent_confidence": "high",
"entities": {
"account": null,
"counterparty": null,
@@ -317,57 +789,106 @@ trace_id: address-mB-Zxj7T3o
"organization": null
},
"period": {
- "scope": "unspecified",
- "period_from": null,
- "period_to": null,
+ "scope": "range",
+ "period_from": "2020-04-01",
+ "period_to": "2020-05-12",
"as_of_date": null,
- "has_explicit_period": false
+ "has_explicit_period": true
},
- "aggregation_profile": "list_lookup"
+ "aggregation_profile": "management_profile"
},
"fallback_rule_hit": null,
- "sanitized_user_message": "а теперь банковские операции",
+ "sanitized_user_message": "какойй прогноз оплаты ндс на 12-05-2020",
"tool_gate_decision": "run_address_lane",
"tool_gate_reason": "address_mode_classifier_detected",
+ "orchestration_contract_v1": {
+ "schema_version": "assistant_orchestration_contract_v1",
+ "hard_meta_mode": null,
+ "address_mode": "address_query",
+ "address_mode_confidence": "medium",
+ "address_intent": "vat_payable_forecast",
+ "address_intent_confidence": "high",
+ "strong_data_signal_detected": true,
+ "data_retrieval_signal_detected": false,
+ "followup_context_detected": false,
+ "unsupported_address_intent_fallback_to_deep": false,
+ "final_decision": {
+ "run_address_lane": true,
+ "tool_gate_decision": "run_address_lane",
+ "tool_gate_reason": "address_mode_classifier_detected",
+ "living_mode": "address_data",
+ "living_reason": "address_lane_triggered"
+ }
+ },
+ "dialog_continuation_contract_v2": {
+ "schema_version": "address_dialog_continuation_contract_v2",
+ "source_message": "какойй прогноз оплаты ндс на 12-05-2020",
+ "effective_message": "Прогноз оплаты НДС на 12.05.2020.",
+ "decision": "new_topic",
+ "decision_reasons": [
+ "effective_message_rewritten_by_predecompose",
+ "llm_contract_intent_available"
+ ],
+ "followup_context_applied": false,
+ "previous_intent": null,
+ "target_intent": null,
+ "intent_selection_mode": null,
+ "anchor_type": null,
+ "anchor_value": null,
+ "implicit_continuation_signal": false
+ },
+ "address_retry_audit": {
+ "attempted": false,
+ "reason": null,
+ "initial_limited_category": null,
+ "retry_message": null,
+ "retry_used_followup_context": false,
+ "retry_result_category": null
+ },
"answer_structure_v11": null,
"investigation_state_snapshot": null,
"normalized": null,
"normalizer_output": {
- "trace_id": "fQPoAyba2nzNW_",
+ "trace_id": "N0qN6tFVvM-jRN",
"prompt_version": "normalizer_v2_0_2",
- "applied": false,
- "effective_message": "а теперь банковские операции"
+ "applied": true,
+ "effective_message": "Прогноз оплаты НДС на 12.05.2020."
}
}
```
-## 5. user
-message_id: msg-FfUbRQG205
-created_at: 2026-04-08T08:41:40.344Z
+## 3. user
+message_id: msg-h09QP6AAUy
+created_at: 2026-04-08T20:19:18.736Z
reply_type: n/a
-есть ли открытые позиции по договору 19/15 на 2020-12-31
+какойй прогноз оплаты ндс на 12-05-2021
-## 6. assistant
-message_id: msg-kML8gXvOyB
-created_at: 2026-04-08T08:41:48.227Z
+## 4. assistant
+message_id: msg-sERZH3v1j4
+created_at: 2026-04-08T20:19:24.125Z
reply_type: factual
-trace_id: address-oAwR0ldclP
+trace_id: address-0XCKhu8x7x
-Период сохранен. Глубина live-выборки автоматически расширена до 1000 строк.
-Собраны открытые позиции по указанному фильтру (контрагент/договор).
-Строк отобрано: 8.
-1. 2020-08-31T15:00:06Z | Поступление товаров и услуг 00000000024 от 31.08.2020 15:00:06 | 19.04 / 76.05 | 233.33
-2. 2020-07-15T13:11:19Z | Реализация товаров и услуг 00000000008 от 15.07.2020 13:11:19 | 62.01 / 90.01.1 | 43804.8
-3. 2020-07-15T13:11:19Z | Реализация товаров и услуг 00000000008 от 15.07.2020 13:11:19 | 62.01 / 90.01.1 | 9776
-4. 2020-07-15T13:11:19Z | Реализация товаров и услуг 00000000008 от 15.07.2020 13:11:19 | 62.01 / 90.01.1 | 73968
-5. 2020-07-15T13:11:19Z | Реализация товаров и услуг 00000000008 от 15.07.2020 13:11:19 | 62.01 / 90.01.1 | 94924.8
-6. 2020-07-15T13:11:19Z | Реализация товаров и услуг 00000000008 от 15.07.2020 13:11:19 | 62.01 / 90.01.1 | 54400
+Собран прогноз НДС к уплате по фактическим проводкам (НДС-субсчета 68.02*/19*).
+Строк агрегата: 4.
+Оборот по кредиту 68*: 0.00.
+Оборот по дебету 68*: 10010.00.
+Нетто НДС (68 Кт - 68 Дт): -10010.00.
+Прогноз НДС к уплате: 0.00.
+Потенциальный перенос/переплата: 10010.00.
+Справочно по 19*: дебет 233.33, кредит 0.00.
+Период расчета (срез обязательств): 01.04.2021..12.05.2021.
+Налоговый период: 2 кв. 2021.
+Срок сдачи декларации: до 25.07.2021.
+Сроки уплаты: 28.07.2021, 28.08.2021, 28.09.2021.
+Ориентир по долям к уплате: 0.00 / 0.00 / 0.00.
+Важно: даже при нулевой сумме к уплате декларация по НДС подается в установленный срок; переносы по выходным/праздникам сверяйте по календарю ФНС/1С.
### technical_debug_payload_json
```json
{
- "trace_id": "address-oAwR0ldclP",
+ "trace_id": "address-0XCKhu8x7x",
"prompt_version": "address_query_runtime_v1",
"schema_version": "address_query_runtime_v1",
"fallback_type": "none",
@@ -390,10 +911,9 @@ trace_id: address-oAwR0ldclP
"route_subject_match": true,
"missing_requirements": [],
"reasons": [
- "address_entity_detected",
- "verify_signal_detected",
- "open_items_signal_detected",
- "query_limit_auto_expanded_for_anchor_recovery"
+ "account_code_detected",
+ "shape_not_detected",
+ "forecast_tax_signal_detected"
],
"why_included_summary": [],
"selection_reason_summary": []
@@ -401,35 +921,34 @@ trace_id: address-oAwR0ldclP
"dropped_intent_segments": [],
"detected_mode": "address_query",
"detected_mode_confidence": "medium",
- "query_shape": "VERIFY_FACTUAL",
- "query_shape_confidence": "medium",
- "detected_intent": "open_items_by_counterparty_or_contract",
- "detected_intent_confidence": "medium",
+ "query_shape": "UNKNOWN",
+ "query_shape_confidence": "low",
+ "detected_intent": "vat_payable_forecast",
+ "detected_intent_confidence": "high",
"extracted_filters": {
"sort": "period_desc",
- "limit": 20,
- "contract": "19/15",
- "as_of_date": "2020-12-31"
+ "period_from": "2021-04-01",
+ "period_to": "2021-05-12"
},
"missing_required_filters": [],
- "selected_recipe": "address_open_items_by_party_or_contract_v1",
+ "selected_recipe": "address_vat_payable_forecast_v1",
"mcp_call_status_legacy": "matched_non_empty",
"account_scope_mode": "preferred",
"account_scope_fallback_applied": false,
- "anchor_type": "contract",
- "anchor_value_raw": "19/15",
- "anchor_value_resolved": "19/15",
- "resolver_confidence": "medium",
+ "anchor_type": "unknown",
+ "anchor_value_raw": null,
+ "anchor_value_resolved": null,
+ "resolver_confidence": "low",
"ambiguity_count": 0,
"match_failure_stage": "none",
"match_failure_reason": null,
"mcp_call_status": "matched_non_empty",
- "rows_fetched": 1000,
- "raw_rows_received": 1000,
- "rows_after_account_scope": 285,
- "rows_after_recipe_filter": 8,
- "rows_materialized": 285,
- "rows_matched": 8,
+ "rows_fetched": 4,
+ "raw_rows_received": 4,
+ "rows_after_account_scope": 4,
+ "rows_after_recipe_filter": 4,
+ "rows_materialized": 4,
+ "rows_matched": 4,
"raw_row_keys_sample": [
"Период",
"Регистратор",
@@ -439,7 +958,6 @@ trace_id: address-oAwR0ldclP
"Period",
"Registrator",
"AccountDt",
- "AccountKt",
"Amount"
],
"materialization_drop_reason": "none",
@@ -455,79 +973,135 @@ trace_id: address-oAwR0ldclP
"account_scope_drop_reason": "not_applicable",
"runtime_readiness": "LIVE_QUERYABLE_WITH_LIMITS",
"limited_reason_category": null,
- "response_type": "FACTUAL_LIST",
+ "response_type": "FACTUAL_SUMMARY",
"execution_lane": "address_query",
"llm_decomposition_applied": true,
"llm_decomposition_attempted": true,
"llm_provider_used": "local",
- "llm_decomposition_trace_id": "v905zWHzp1kuHh",
- "llm_decomposition_effective_message": "Проверить наличие открытых позиций по договору 19/15 на конец периода 2020-12-31.",
+ "llm_decomposition_trace_id": "8NCX8zXIUg30GK",
+ "llm_decomposition_effective_message": "Прогноз оплаты НДС на 12.05.2021.",
"llm_decomposition_reason": "normalized_fragment_applied",
"llm_canonical_candidate_detected": true,
"llm_predecompose_contract": {
"schema_version": "address_llm_predecompose_contract_v1",
- "source_message": "есть ли открытые позиции по договору 19/15 на 2020-12-31",
- "canonical_message": "Проверить наличие открытых позиций по договору 19/15 на конец периода 2020-12-31.",
+ "source_message": "какойй прогноз оплаты ндс на 12-05-2021",
+ "canonical_message": "Прогноз оплаты НДС на 12.05.2021.",
"mode": "address_query",
"mode_confidence": "medium",
- "query_shape": "VERIFY_FACTUAL",
- "query_shape_confidence": "medium",
- "intent": "open_items_by_counterparty_or_contract",
- "intent_confidence": "medium",
+ "query_shape": "UNKNOWN",
+ "query_shape_confidence": "low",
+ "intent": "vat_payable_forecast",
+ "intent_confidence": "high",
"entities": {
"account": null,
"counterparty": null,
- "contract": "19/15",
+ "contract": null,
"document_type": null,
"document_ref": null,
"organization": null
},
"period": {
- "scope": "as_of",
- "period_from": null,
- "period_to": null,
- "as_of_date": "2020-12-31",
+ "scope": "range",
+ "period_from": "2021-04-01",
+ "period_to": "2021-05-12",
+ "as_of_date": null,
"has_explicit_period": true
},
- "aggregation_profile": "open_items"
+ "aggregation_profile": "management_profile"
},
"fallback_rule_hit": null,
- "sanitized_user_message": "есть ли открытые позиции по договору 19/15 на 2020-12-31",
+ "sanitized_user_message": "какойй прогноз оплаты ндс на 12-05-2021",
"tool_gate_decision": "run_address_lane",
"tool_gate_reason": "address_mode_classifier_detected",
+ "orchestration_contract_v1": {
+ "schema_version": "assistant_orchestration_contract_v1",
+ "hard_meta_mode": null,
+ "address_mode": "address_query",
+ "address_mode_confidence": "medium",
+ "address_intent": "vat_payable_forecast",
+ "address_intent_confidence": "high",
+ "strong_data_signal_detected": true,
+ "data_retrieval_signal_detected": false,
+ "followup_context_detected": true,
+ "unsupported_address_intent_fallback_to_deep": false,
+ "final_decision": {
+ "run_address_lane": true,
+ "tool_gate_decision": "run_address_lane",
+ "tool_gate_reason": "address_mode_classifier_detected",
+ "living_mode": "address_data",
+ "living_reason": "address_lane_triggered"
+ }
+ },
+ "dialog_continuation_contract_v2": {
+ "schema_version": "address_dialog_continuation_contract_v2",
+ "source_message": "какойй прогноз оплаты ндс на 12-05-2021",
+ "effective_message": "Прогноз оплаты НДС на 12.05.2021.",
+ "decision": "continue_previous",
+ "decision_reasons": [
+ "followup_context_detected",
+ "effective_message_rewritten_by_predecompose",
+ "llm_contract_intent_available"
+ ],
+ "followup_context_applied": true,
+ "previous_intent": "vat_payable_forecast",
+ "target_intent": "vat_payable_forecast",
+ "intent_selection_mode": "carry_previous_intent",
+ "anchor_type": "unknown",
+ "anchor_value": null,
+ "implicit_continuation_signal": false
+ },
+ "address_retry_audit": {
+ "attempted": false,
+ "reason": null,
+ "initial_limited_category": null,
+ "retry_message": null,
+ "retry_used_followup_context": false,
+ "retry_result_category": null
+ },
"answer_structure_v11": null,
"investigation_state_snapshot": null,
"normalized": null,
"normalizer_output": {
- "trace_id": "v905zWHzp1kuHh",
+ "trace_id": "8NCX8zXIUg30GK",
"prompt_version": "normalizer_v2_0_2",
"applied": true,
- "effective_message": "Проверить наличие открытых позиций по договору 19/15 на конец периода 2020-12-31."
+ "effective_message": "Прогноз оплаты НДС на 12.05.2021."
}
}
```
-## 7. user
-message_id: msg-VtC7fvCUIt
-created_at: 2026-04-08T08:42:06.001Z
+## 5. user
+message_id: msg-AxjLwOREsf
+created_at: 2026-04-08T20:19:36.793Z
reply_type: n/a
-покажи документы по этому же договору
+какойй прогноз оплаты ндс на 12-05-2019
-## 8. assistant
-message_id: msg-VswRgnV4fb
-created_at: 2026-04-08T08:42:11.635Z
+## 6. assistant
+message_id: msg-fJ6uuAdfeH
+created_at: 2026-04-08T20:19:42.470Z
reply_type: factual
-trace_id: address-zoj3HXDSnN
+trace_id: address-hv_tpa3Ci6
-Собран список документов по договору (live address lane).
-Строк отобрано: 1.
-1. 2021-12-31T12:00:00Z | Поступление товаров и услуг 00000000015 от 31.12.2021 12:00:00 | 19.04 / 76.05 | 233.33
+Собран прогноз НДС к уплате по фактическим проводкам (НДС-субсчета 68.02*/19*).
+Строк агрегата: 4.
+Оборот по кредиту 68*: 0.00.
+Оборот по дебету 68*: 105478.95.
+Нетто НДС (68 Кт - 68 Дт): -105478.95.
+Прогноз НДС к уплате: 0.00.
+Потенциальный перенос/переплата: 105478.95.
+Справочно по 19*: дебет 524.45, кредит 90863.95.
+Период расчета (срез обязательств): 01.04.2019..12.05.2019.
+Налоговый период: 2 кв. 2019.
+Срок сдачи декларации: до 25.07.2019.
+Сроки уплаты: 28.07.2019, 28.08.2019, 28.09.2019.
+Ориентир по долям к уплате: 0.00 / 0.00 / 0.00.
+Важно: даже при нулевой сумме к уплате декларация по НДС подается в установленный срок; переносы по выходным/праздникам сверяйте по календарю ФНС/1С.
### technical_debug_payload_json
```json
{
- "trace_id": "address-zoj3HXDSnN",
+ "trace_id": "address-hv_tpa3Ci6",
"prompt_version": "address_query_runtime_v1",
"schema_version": "address_query_runtime_v1",
"fallback_type": "none",
@@ -550,47 +1124,45 @@ trace_id: address-zoj3HXDSnN
"route_subject_match": true,
"missing_requirements": [],
"reasons": [
- "address_action_detected",
- "address_entity_detected",
- "document_list_signal_detected",
- "documents_by_contract_signal_detected",
- "contract_from_followup_context",
- "address_followup_context_applied"
+ "docs_or_bank_signal_detected",
+ "anchor_like_token_detected",
+ "shape_not_detected",
+ "forecast_tax_signal_detected"
],
"why_included_summary": [],
"selection_reason_summary": []
},
"dropped_intent_segments": [],
"detected_mode": "address_query",
- "detected_mode_confidence": "high",
- "query_shape": "DOCUMENT_LIST",
- "query_shape_confidence": "medium",
- "detected_intent": "list_documents_by_contract",
- "detected_intent_confidence": "medium",
+ "detected_mode_confidence": "medium",
+ "query_shape": "UNKNOWN",
+ "query_shape_confidence": "low",
+ "detected_intent": "vat_payable_forecast",
+ "detected_intent_confidence": "high",
"extracted_filters": {
"sort": "period_desc",
- "limit": 20,
- "contract": "19/15"
+ "period_from": "2019-04-01",
+ "period_to": "2019-05-12"
},
"missing_required_filters": [],
- "selected_recipe": "address_documents_by_contract_v1",
+ "selected_recipe": "address_vat_payable_forecast_v1",
"mcp_call_status_legacy": "matched_non_empty",
"account_scope_mode": "preferred",
"account_scope_fallback_applied": false,
- "anchor_type": "contract",
- "anchor_value_raw": "19/15",
- "anchor_value_resolved": "19/15",
- "resolver_confidence": "medium",
+ "anchor_type": "unknown",
+ "anchor_value_raw": null,
+ "anchor_value_resolved": null,
+ "resolver_confidence": "low",
"ambiguity_count": 0,
"match_failure_stage": "none",
"match_failure_reason": null,
"mcp_call_status": "matched_non_empty",
- "rows_fetched": 1000,
- "raw_rows_received": 1000,
- "rows_after_account_scope": 416,
- "rows_after_recipe_filter": 2,
- "rows_materialized": 416,
- "rows_matched": 1,
+ "rows_fetched": 4,
+ "raw_rows_received": 4,
+ "rows_after_account_scope": 4,
+ "rows_after_recipe_filter": 4,
+ "rows_materialized": 4,
+ "rows_matched": 4,
"raw_row_keys_sample": [
"Период",
"Регистратор",
@@ -600,7 +1172,6 @@ trace_id: address-zoj3HXDSnN
"Period",
"Registrator",
"AccountDt",
- "AccountKt",
"Amount"
],
"materialization_drop_reason": "none",
@@ -616,25 +1187,25 @@ trace_id: address-zoj3HXDSnN
"account_scope_drop_reason": "not_applicable",
"runtime_readiness": "LIVE_QUERYABLE_WITH_LIMITS",
"limited_reason_category": null,
- "response_type": "FACTUAL_LIST",
+ "response_type": "FACTUAL_SUMMARY",
"execution_lane": "address_query",
"llm_decomposition_applied": true,
"llm_decomposition_attempted": true,
"llm_provider_used": "local",
- "llm_decomposition_trace_id": "2owWUuckOkI3Tu",
- "llm_decomposition_effective_message": "Показать документы по указанному договору.",
+ "llm_decomposition_trace_id": "XOntLcpU3VQlJz",
+ "llm_decomposition_effective_message": "прогноз оплаты НДС на 12-05-2019",
"llm_decomposition_reason": "normalized_fragment_applied",
"llm_canonical_candidate_detected": true,
"llm_predecompose_contract": {
"schema_version": "address_llm_predecompose_contract_v1",
- "source_message": "покажи документы по этому же договору",
- "canonical_message": "Показать документы по указанному договору.",
+ "source_message": "какойй прогноз оплаты ндс на 12-05-2019",
+ "canonical_message": "прогноз оплаты НДС на 12-05-2019",
"mode": "address_query",
- "mode_confidence": "high",
- "query_shape": "DOCUMENT_LIST",
- "query_shape_confidence": "medium",
- "intent": "list_documents_by_contract",
- "intent_confidence": "medium",
+ "mode_confidence": "medium",
+ "query_shape": "UNKNOWN",
+ "query_shape_confidence": "low",
+ "intent": "vat_payable_forecast",
+ "intent_confidence": "high",
"entities": {
"account": null,
"counterparty": null,
@@ -644,26 +1215,1350 @@ trace_id: address-zoj3HXDSnN
"organization": null
},
"period": {
- "scope": "unspecified",
- "period_from": null,
- "period_to": null,
+ "scope": "range",
+ "period_from": "2019-04-01",
+ "period_to": "2019-05-12",
"as_of_date": null,
- "has_explicit_period": false
+ "has_explicit_period": true
},
- "aggregation_profile": "list_lookup"
+ "aggregation_profile": "management_profile"
},
"fallback_rule_hit": null,
- "sanitized_user_message": "покажи документы по этому же договору",
+ "sanitized_user_message": "какойй прогноз оплаты ндс на 12-05-2019",
"tool_gate_decision": "run_address_lane",
"tool_gate_reason": "address_mode_classifier_detected",
+ "orchestration_contract_v1": {
+ "schema_version": "assistant_orchestration_contract_v1",
+ "hard_meta_mode": null,
+ "address_mode": "address_query",
+ "address_mode_confidence": "medium",
+ "address_intent": "vat_payable_forecast",
+ "address_intent_confidence": "high",
+ "strong_data_signal_detected": true,
+ "data_retrieval_signal_detected": false,
+ "followup_context_detected": true,
+ "unsupported_address_intent_fallback_to_deep": false,
+ "final_decision": {
+ "run_address_lane": true,
+ "tool_gate_decision": "run_address_lane",
+ "tool_gate_reason": "address_mode_classifier_detected",
+ "living_mode": "address_data",
+ "living_reason": "address_lane_triggered"
+ }
+ },
+ "dialog_continuation_contract_v2": {
+ "schema_version": "address_dialog_continuation_contract_v2",
+ "source_message": "какойй прогноз оплаты ндс на 12-05-2019",
+ "effective_message": "прогноз оплаты НДС на 12-05-2019",
+ "decision": "continue_previous",
+ "decision_reasons": [
+ "followup_context_detected",
+ "effective_message_rewritten_by_predecompose",
+ "llm_contract_intent_available"
+ ],
+ "followup_context_applied": true,
+ "previous_intent": "vat_payable_forecast",
+ "target_intent": "vat_payable_forecast",
+ "intent_selection_mode": "carry_previous_intent",
+ "anchor_type": "unknown",
+ "anchor_value": null,
+ "implicit_continuation_signal": false
+ },
+ "address_retry_audit": {
+ "attempted": false,
+ "reason": null,
+ "initial_limited_category": null,
+ "retry_message": null,
+ "retry_used_followup_context": false,
+ "retry_result_category": null
+ },
"answer_structure_v11": null,
"investigation_state_snapshot": null,
"normalized": null,
"normalizer_output": {
- "trace_id": "2owWUuckOkI3Tu",
+ "trace_id": "XOntLcpU3VQlJz",
"prompt_version": "normalizer_v2_0_2",
"applied": true,
- "effective_message": "Показать документы по указанному договору."
+ "effective_message": "прогноз оплаты НДС на 12-05-2019"
}
}
```
+
+## 7. user
+message_id: msg-SDelbDEP1k
+created_at: 2026-04-08T20:19:55.248Z
+reply_type: n/a
+
+какойй прогноз оплаты ндс на 12-05-2020
+
+## 8. assistant
+message_id: msg-WGV4fMcJdy
+created_at: 2026-04-08T20:20:00.723Z
+reply_type: factual
+trace_id: address-h3RP8c4Pw_
+
+Собран прогноз НДС к уплате по фактическим проводкам (НДС-субсчета 68.02*/19*).
+Строк агрегата: 4.
+Оборот по кредиту 68*: 142500.00.
+Оборот по дебету 68*: 933.32.
+Нетто НДС (68 Кт - 68 Дт): 141566.68.
+Прогноз НДС к уплате: 141566.68.
+Потенциальный перенос/переплата: 0.00.
+Справочно по 19*: дебет 233.33, кредит 933.32.
+Период расчета (срез обязательств): 01.04.2020..12.05.2020.
+Налоговый период: 2 кв. 2020.
+Срок сдачи декларации: до 25.07.2020.
+Сроки уплаты: 28.07.2020, 28.08.2020, 28.09.2020.
+Ориентир по долям к уплате: 47188.89 / 47188.89 / 47188.90.
+Важно: даже при нулевой сумме к уплате декларация по НДС подается в установленный срок; переносы по выходным/праздникам сверяйте по календарю ФНС/1С.
+
+### technical_debug_payload_json
+```json
+{
+ "trace_id": "address-h3RP8c4Pw_",
+ "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": [
+ "account_code_detected",
+ "shape_not_detected",
+ "forecast_tax_signal_detected"
+ ],
+ "why_included_summary": [],
+ "selection_reason_summary": []
+ },
+ "dropped_intent_segments": [],
+ "detected_mode": "address_query",
+ "detected_mode_confidence": "medium",
+ "query_shape": "UNKNOWN",
+ "query_shape_confidence": "low",
+ "detected_intent": "vat_payable_forecast",
+ "detected_intent_confidence": "high",
+ "extracted_filters": {
+ "sort": "period_desc",
+ "period_from": "2020-04-01",
+ "period_to": "2020-05-12"
+ },
+ "missing_required_filters": [],
+ "selected_recipe": "address_vat_payable_forecast_v1",
+ "mcp_call_status_legacy": "matched_non_empty",
+ "account_scope_mode": "preferred",
+ "account_scope_fallback_applied": false,
+ "anchor_type": "unknown",
+ "anchor_value_raw": null,
+ "anchor_value_resolved": null,
+ "resolver_confidence": "low",
+ "ambiguity_count": 0,
+ "match_failure_stage": "none",
+ "match_failure_reason": null,
+ "mcp_call_status": "matched_non_empty",
+ "rows_fetched": 4,
+ "raw_rows_received": 4,
+ "rows_after_account_scope": 4,
+ "rows_after_recipe_filter": 4,
+ "rows_materialized": 4,
+ "rows_matched": 4,
+ "raw_row_keys_sample": [
+ "Период",
+ "Регистратор",
+ "СчетДт",
+ "СчетКт",
+ "Сумма",
+ "Period",
+ "Registrator",
+ "AccountDt",
+ "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_SUMMARY",
+ "execution_lane": "address_query",
+ "llm_decomposition_applied": true,
+ "llm_decomposition_attempted": true,
+ "llm_provider_used": "local",
+ "llm_decomposition_trace_id": "rSyVgErG7TGKky",
+ "llm_decomposition_effective_message": "Прогноз оплаты НДС на 12.05.2020.",
+ "llm_decomposition_reason": "normalized_fragment_applied",
+ "llm_canonical_candidate_detected": true,
+ "llm_predecompose_contract": {
+ "schema_version": "address_llm_predecompose_contract_v1",
+ "source_message": "какойй прогноз оплаты ндс на 12-05-2020",
+ "canonical_message": "Прогноз оплаты НДС на 12.05.2020.",
+ "mode": "address_query",
+ "mode_confidence": "medium",
+ "query_shape": "UNKNOWN",
+ "query_shape_confidence": "low",
+ "intent": "vat_payable_forecast",
+ "intent_confidence": "high",
+ "entities": {
+ "account": null,
+ "counterparty": null,
+ "contract": null,
+ "document_type": null,
+ "document_ref": null,
+ "organization": null
+ },
+ "period": {
+ "scope": "range",
+ "period_from": "2020-04-01",
+ "period_to": "2020-05-12",
+ "as_of_date": null,
+ "has_explicit_period": true
+ },
+ "aggregation_profile": "management_profile"
+ },
+ "fallback_rule_hit": null,
+ "sanitized_user_message": "какойй прогноз оплаты ндс на 12-05-2020",
+ "tool_gate_decision": "run_address_lane",
+ "tool_gate_reason": "address_mode_classifier_detected",
+ "orchestration_contract_v1": {
+ "schema_version": "assistant_orchestration_contract_v1",
+ "hard_meta_mode": null,
+ "address_mode": "address_query",
+ "address_mode_confidence": "medium",
+ "address_intent": "vat_payable_forecast",
+ "address_intent_confidence": "high",
+ "strong_data_signal_detected": true,
+ "data_retrieval_signal_detected": false,
+ "followup_context_detected": true,
+ "unsupported_address_intent_fallback_to_deep": false,
+ "final_decision": {
+ "run_address_lane": true,
+ "tool_gate_decision": "run_address_lane",
+ "tool_gate_reason": "address_mode_classifier_detected",
+ "living_mode": "address_data",
+ "living_reason": "address_lane_triggered"
+ }
+ },
+ "dialog_continuation_contract_v2": {
+ "schema_version": "address_dialog_continuation_contract_v2",
+ "source_message": "какойй прогноз оплаты ндс на 12-05-2020",
+ "effective_message": "Прогноз оплаты НДС на 12.05.2020.",
+ "decision": "continue_previous",
+ "decision_reasons": [
+ "followup_context_detected",
+ "effective_message_rewritten_by_predecompose",
+ "llm_contract_intent_available"
+ ],
+ "followup_context_applied": true,
+ "previous_intent": "vat_payable_forecast",
+ "target_intent": "vat_payable_forecast",
+ "intent_selection_mode": "carry_previous_intent",
+ "anchor_type": "unknown",
+ "anchor_value": null,
+ "implicit_continuation_signal": false
+ },
+ "address_retry_audit": {
+ "attempted": false,
+ "reason": null,
+ "initial_limited_category": null,
+ "retry_message": null,
+ "retry_used_followup_context": false,
+ "retry_result_category": null
+ },
+ "answer_structure_v11": null,
+ "investigation_state_snapshot": null,
+ "normalized": null,
+ "normalizer_output": {
+ "trace_id": "rSyVgErG7TGKky",
+ "prompt_version": "normalizer_v2_0_2",
+ "applied": true,
+ "effective_message": "Прогноз оплаты НДС на 12.05.2020."
+ }
+}
+```
+
+## 9. user
+message_id: msg-85k4A-uX0S
+created_at: 2026-04-08T20:20:40.989Z
+reply_type: n/a
+
+какойй прогноз оплаты ндс на 12-10-2015
+
+## 10. assistant
+message_id: msg-EyVK4W3xVu
+created_at: 2026-04-08T20:20:46.442Z
+reply_type: factual
+trace_id: address-YFJaiRoqJO
+
+Собран прогноз НДС к уплате по фактическим проводкам (НДС-субсчета 68.02*/19*).
+Строк агрегата: 4.
+Оборот по кредиту 68*: 0.00.
+Оборот по дебету 68*: 0.00.
+Нетто НДС (68 Кт - 68 Дт): 0.00.
+Прогноз НДС к уплате: 0.00.
+Потенциальный перенос/переплата: 0.00.
+Справочно по 19*: дебет 0.00, кредит 0.00.
+Период расчета (срез обязательств): 01.10.2015..12.10.2015.
+Налоговый период: 4 кв. 2015.
+Срок сдачи декларации: до 25.01.2016.
+Сроки уплаты: 28.01.2016, 28.02.2016, 28.03.2016.
+Ориентир по долям к уплате: 0.00 / 0.00 / 0.00.
+Важно: даже при нулевой сумме к уплате декларация по НДС подается в установленный срок; переносы по выходным/праздникам сверяйте по календарю ФНС/1С.
+
+### technical_debug_payload_json
+```json
+{
+ "trace_id": "address-YFJaiRoqJO",
+ "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": [
+ "account_code_detected",
+ "shape_not_detected",
+ "forecast_tax_signal_detected"
+ ],
+ "why_included_summary": [],
+ "selection_reason_summary": []
+ },
+ "dropped_intent_segments": [],
+ "detected_mode": "address_query",
+ "detected_mode_confidence": "medium",
+ "query_shape": "UNKNOWN",
+ "query_shape_confidence": "low",
+ "detected_intent": "vat_payable_forecast",
+ "detected_intent_confidence": "high",
+ "extracted_filters": {
+ "sort": "period_desc",
+ "period_from": "2015-10-01",
+ "period_to": "2015-10-12"
+ },
+ "missing_required_filters": [],
+ "selected_recipe": "address_vat_payable_forecast_v1",
+ "mcp_call_status_legacy": "matched_non_empty",
+ "account_scope_mode": "preferred",
+ "account_scope_fallback_applied": false,
+ "anchor_type": "unknown",
+ "anchor_value_raw": null,
+ "anchor_value_resolved": null,
+ "resolver_confidence": "low",
+ "ambiguity_count": 0,
+ "match_failure_stage": "none",
+ "match_failure_reason": null,
+ "mcp_call_status": "matched_non_empty",
+ "rows_fetched": 4,
+ "raw_rows_received": 4,
+ "rows_after_account_scope": 4,
+ "rows_after_recipe_filter": 4,
+ "rows_materialized": 4,
+ "rows_matched": 4,
+ "raw_row_keys_sample": [
+ "Период",
+ "Регистратор",
+ "СчетДт",
+ "СчетКт",
+ "Сумма",
+ "Period",
+ "Registrator",
+ "AccountDt",
+ "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_SUMMARY",
+ "execution_lane": "address_query",
+ "llm_decomposition_applied": true,
+ "llm_decomposition_attempted": true,
+ "llm_provider_used": "local",
+ "llm_decomposition_trace_id": "XrN5WrmG6Ap4sq",
+ "llm_decomposition_effective_message": "прогноз оплаты НДС на 12.10.2015",
+ "llm_decomposition_reason": "normalized_fragment_applied",
+ "llm_canonical_candidate_detected": true,
+ "llm_predecompose_contract": {
+ "schema_version": "address_llm_predecompose_contract_v1",
+ "source_message": "какойй прогноз оплаты ндс на 12-10-2015",
+ "canonical_message": "прогноз оплаты НДС на 12.10.2015",
+ "mode": "address_query",
+ "mode_confidence": "medium",
+ "query_shape": "UNKNOWN",
+ "query_shape_confidence": "low",
+ "intent": "vat_payable_forecast",
+ "intent_confidence": "high",
+ "entities": {
+ "account": null,
+ "counterparty": null,
+ "contract": null,
+ "document_type": null,
+ "document_ref": null,
+ "organization": null
+ },
+ "period": {
+ "scope": "range",
+ "period_from": "2015-10-01",
+ "period_to": "2015-10-12",
+ "as_of_date": null,
+ "has_explicit_period": true
+ },
+ "aggregation_profile": "management_profile"
+ },
+ "fallback_rule_hit": null,
+ "sanitized_user_message": "какойй прогноз оплаты ндс на 12-10-2015",
+ "tool_gate_decision": "run_address_lane",
+ "tool_gate_reason": "address_mode_classifier_detected",
+ "orchestration_contract_v1": {
+ "schema_version": "assistant_orchestration_contract_v1",
+ "hard_meta_mode": null,
+ "address_mode": "address_query",
+ "address_mode_confidence": "medium",
+ "address_intent": "vat_payable_forecast",
+ "address_intent_confidence": "high",
+ "strong_data_signal_detected": true,
+ "data_retrieval_signal_detected": false,
+ "followup_context_detected": true,
+ "unsupported_address_intent_fallback_to_deep": false,
+ "final_decision": {
+ "run_address_lane": true,
+ "tool_gate_decision": "run_address_lane",
+ "tool_gate_reason": "address_mode_classifier_detected",
+ "living_mode": "address_data",
+ "living_reason": "address_lane_triggered"
+ }
+ },
+ "dialog_continuation_contract_v2": {
+ "schema_version": "address_dialog_continuation_contract_v2",
+ "source_message": "какойй прогноз оплаты ндс на 12-10-2015",
+ "effective_message": "прогноз оплаты НДС на 12.10.2015",
+ "decision": "continue_previous",
+ "decision_reasons": [
+ "followup_context_detected",
+ "effective_message_rewritten_by_predecompose",
+ "llm_contract_intent_available"
+ ],
+ "followup_context_applied": true,
+ "previous_intent": "vat_payable_forecast",
+ "target_intent": "vat_payable_forecast",
+ "intent_selection_mode": "carry_previous_intent",
+ "anchor_type": "unknown",
+ "anchor_value": null,
+ "implicit_continuation_signal": false
+ },
+ "address_retry_audit": {
+ "attempted": false,
+ "reason": null,
+ "initial_limited_category": null,
+ "retry_message": null,
+ "retry_used_followup_context": false,
+ "retry_result_category": null
+ },
+ "answer_structure_v11": null,
+ "investigation_state_snapshot": null,
+ "normalized": null,
+ "normalizer_output": {
+ "trace_id": "XrN5WrmG6Ap4sq",
+ "prompt_version": "normalizer_v2_0_2",
+ "applied": true,
+ "effective_message": "прогноз оплаты НДС на 12.10.2015"
+ }
+}
+```
+
+## 11. user
+message_id: msg-OSc86BoIS8
+created_at: 2026-04-08T20:20:57.735Z
+reply_type: n/a
+
+какойй прогноз оплаты ндс на 12-10-2017
+
+## 12. assistant
+message_id: msg-RHSwGfdwpS
+created_at: 2026-04-08T20:21:03.757Z
+reply_type: factual
+trace_id: address-hXW838_NAh
+
+Собран прогноз НДС к уплате по фактическим проводкам (НДС-субсчета 68.02*/19*).
+Строк агрегата: 4.
+Оборот по кредиту 68*: 0.00.
+Оборот по дебету 68*: 0.00.
+Нетто НДС (68 Кт - 68 Дт): 0.00.
+Прогноз НДС к уплате: 0.00.
+Потенциальный перенос/переплата: 0.00.
+Справочно по 19*: дебет 0.00, кредит 0.00.
+Период расчета (срез обязательств): 01.10.2017..12.10.2017.
+Налоговый период: 4 кв. 2017.
+Срок сдачи декларации: до 25.01.2018.
+Сроки уплаты: 28.01.2018, 28.02.2018, 28.03.2018.
+Ориентир по долям к уплате: 0.00 / 0.00 / 0.00.
+Важно: даже при нулевой сумме к уплате декларация по НДС подается в установленный срок; переносы по выходным/праздникам сверяйте по календарю ФНС/1С.
+
+### technical_debug_payload_json
+```json
+{
+ "trace_id": "address-hXW838_NAh",
+ "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": [
+ "account_code_detected",
+ "shape_not_detected",
+ "forecast_tax_signal_detected"
+ ],
+ "why_included_summary": [],
+ "selection_reason_summary": []
+ },
+ "dropped_intent_segments": [],
+ "detected_mode": "address_query",
+ "detected_mode_confidence": "medium",
+ "query_shape": "UNKNOWN",
+ "query_shape_confidence": "low",
+ "detected_intent": "vat_payable_forecast",
+ "detected_intent_confidence": "high",
+ "extracted_filters": {
+ "sort": "period_desc",
+ "period_from": "2017-10-01",
+ "period_to": "2017-10-12"
+ },
+ "missing_required_filters": [],
+ "selected_recipe": "address_vat_payable_forecast_v1",
+ "mcp_call_status_legacy": "matched_non_empty",
+ "account_scope_mode": "preferred",
+ "account_scope_fallback_applied": false,
+ "anchor_type": "unknown",
+ "anchor_value_raw": null,
+ "anchor_value_resolved": null,
+ "resolver_confidence": "low",
+ "ambiguity_count": 0,
+ "match_failure_stage": "none",
+ "match_failure_reason": null,
+ "mcp_call_status": "matched_non_empty",
+ "rows_fetched": 4,
+ "raw_rows_received": 4,
+ "rows_after_account_scope": 4,
+ "rows_after_recipe_filter": 4,
+ "rows_materialized": 4,
+ "rows_matched": 4,
+ "raw_row_keys_sample": [
+ "Период",
+ "Регистратор",
+ "СчетДт",
+ "СчетКт",
+ "Сумма",
+ "Period",
+ "Registrator",
+ "AccountDt",
+ "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_SUMMARY",
+ "execution_lane": "address_query",
+ "llm_decomposition_applied": true,
+ "llm_decomposition_attempted": true,
+ "llm_provider_used": "local",
+ "llm_decomposition_trace_id": "VQM045N4DApsJX",
+ "llm_decomposition_effective_message": "прогноз оплаты НДС на 12.10.2017",
+ "llm_decomposition_reason": "normalized_fragment_applied",
+ "llm_canonical_candidate_detected": true,
+ "llm_predecompose_contract": {
+ "schema_version": "address_llm_predecompose_contract_v1",
+ "source_message": "какойй прогноз оплаты ндс на 12-10-2017",
+ "canonical_message": "прогноз оплаты НДС на 12.10.2017",
+ "mode": "address_query",
+ "mode_confidence": "medium",
+ "query_shape": "UNKNOWN",
+ "query_shape_confidence": "low",
+ "intent": "vat_payable_forecast",
+ "intent_confidence": "high",
+ "entities": {
+ "account": null,
+ "counterparty": null,
+ "contract": null,
+ "document_type": null,
+ "document_ref": null,
+ "organization": null
+ },
+ "period": {
+ "scope": "range",
+ "period_from": "2017-10-01",
+ "period_to": "2017-10-12",
+ "as_of_date": null,
+ "has_explicit_period": true
+ },
+ "aggregation_profile": "management_profile"
+ },
+ "fallback_rule_hit": null,
+ "sanitized_user_message": "какойй прогноз оплаты ндс на 12-10-2017",
+ "tool_gate_decision": "run_address_lane",
+ "tool_gate_reason": "address_mode_classifier_detected",
+ "orchestration_contract_v1": {
+ "schema_version": "assistant_orchestration_contract_v1",
+ "hard_meta_mode": null,
+ "address_mode": "address_query",
+ "address_mode_confidence": "medium",
+ "address_intent": "vat_payable_forecast",
+ "address_intent_confidence": "high",
+ "strong_data_signal_detected": true,
+ "data_retrieval_signal_detected": false,
+ "followup_context_detected": true,
+ "unsupported_address_intent_fallback_to_deep": false,
+ "final_decision": {
+ "run_address_lane": true,
+ "tool_gate_decision": "run_address_lane",
+ "tool_gate_reason": "address_mode_classifier_detected",
+ "living_mode": "address_data",
+ "living_reason": "address_lane_triggered"
+ }
+ },
+ "dialog_continuation_contract_v2": {
+ "schema_version": "address_dialog_continuation_contract_v2",
+ "source_message": "какойй прогноз оплаты ндс на 12-10-2017",
+ "effective_message": "прогноз оплаты НДС на 12.10.2017",
+ "decision": "continue_previous",
+ "decision_reasons": [
+ "followup_context_detected",
+ "effective_message_rewritten_by_predecompose",
+ "llm_contract_intent_available"
+ ],
+ "followup_context_applied": true,
+ "previous_intent": "vat_payable_forecast",
+ "target_intent": "vat_payable_forecast",
+ "intent_selection_mode": "carry_previous_intent",
+ "anchor_type": "unknown",
+ "anchor_value": null,
+ "implicit_continuation_signal": false
+ },
+ "address_retry_audit": {
+ "attempted": false,
+ "reason": null,
+ "initial_limited_category": null,
+ "retry_message": null,
+ "retry_used_followup_context": false,
+ "retry_result_category": null
+ },
+ "answer_structure_v11": null,
+ "investigation_state_snapshot": null,
+ "normalized": null,
+ "normalizer_output": {
+ "trace_id": "VQM045N4DApsJX",
+ "prompt_version": "normalizer_v2_0_2",
+ "applied": true,
+ "effective_message": "прогноз оплаты НДС на 12.10.2017"
+ }
+}
+```
+
+## 13. user
+message_id: msg-i7o9vaMobf
+created_at: 2026-04-08T20:21:10.191Z
+reply_type: n/a
+
+какойй прогноз оплаты ндс на 12-05-2018
+
+## 14. assistant
+message_id: msg-6_O8GYgGIk
+created_at: 2026-04-08T20:21:15.945Z
+reply_type: factual
+trace_id: address-DPUmXReHvD
+
+Собран прогноз НДС к уплате по фактическим проводкам (НДС-субсчета 68.02*/19*).
+Строк агрегата: 4.
+Оборот по кредиту 68*: 35990.85.
+Оборот по дебету 68*: 49567.84.
+Нетто НДС (68 Кт - 68 Дт): -13576.99.
+Прогноз НДС к уплате: 0.00.
+Потенциальный перенос/переплата: 13576.99.
+Справочно по 19*: дебет 216597.79, кредит 3804.84.
+Период расчета (срез обязательств): 01.04.2018..12.05.2018.
+Налоговый период: 2 кв. 2018.
+Срок сдачи декларации: до 25.07.2018.
+Сроки уплаты: 28.07.2018, 28.08.2018, 28.09.2018.
+Ориентир по долям к уплате: 0.00 / 0.00 / 0.00.
+Важно: даже при нулевой сумме к уплате декларация по НДС подается в установленный срок; переносы по выходным/праздникам сверяйте по календарю ФНС/1С.
+
+### technical_debug_payload_json
+```json
+{
+ "trace_id": "address-DPUmXReHvD",
+ "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": [
+ "account_code_detected",
+ "shape_not_detected",
+ "forecast_tax_signal_detected"
+ ],
+ "why_included_summary": [],
+ "selection_reason_summary": []
+ },
+ "dropped_intent_segments": [],
+ "detected_mode": "address_query",
+ "detected_mode_confidence": "medium",
+ "query_shape": "UNKNOWN",
+ "query_shape_confidence": "low",
+ "detected_intent": "vat_payable_forecast",
+ "detected_intent_confidence": "high",
+ "extracted_filters": {
+ "sort": "period_desc",
+ "period_from": "2018-04-01",
+ "period_to": "2018-05-12"
+ },
+ "missing_required_filters": [],
+ "selected_recipe": "address_vat_payable_forecast_v1",
+ "mcp_call_status_legacy": "matched_non_empty",
+ "account_scope_mode": "preferred",
+ "account_scope_fallback_applied": false,
+ "anchor_type": "unknown",
+ "anchor_value_raw": null,
+ "anchor_value_resolved": null,
+ "resolver_confidence": "low",
+ "ambiguity_count": 0,
+ "match_failure_stage": "none",
+ "match_failure_reason": null,
+ "mcp_call_status": "matched_non_empty",
+ "rows_fetched": 4,
+ "raw_rows_received": 4,
+ "rows_after_account_scope": 4,
+ "rows_after_recipe_filter": 4,
+ "rows_materialized": 4,
+ "rows_matched": 4,
+ "raw_row_keys_sample": [
+ "Период",
+ "Регистратор",
+ "СчетДт",
+ "СчетКт",
+ "Сумма",
+ "Period",
+ "Registrator",
+ "AccountDt",
+ "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_SUMMARY",
+ "execution_lane": "address_query",
+ "llm_decomposition_applied": true,
+ "llm_decomposition_attempted": true,
+ "llm_provider_used": "local",
+ "llm_decomposition_trace_id": "1Scqii0nyQaKzN",
+ "llm_decomposition_effective_message": "прогноз оплаты НДС на 12.05.2018",
+ "llm_decomposition_reason": "normalized_fragment_applied",
+ "llm_canonical_candidate_detected": true,
+ "llm_predecompose_contract": {
+ "schema_version": "address_llm_predecompose_contract_v1",
+ "source_message": "какойй прогноз оплаты ндс на 12-05-2018",
+ "canonical_message": "прогноз оплаты НДС на 12.05.2018",
+ "mode": "address_query",
+ "mode_confidence": "medium",
+ "query_shape": "UNKNOWN",
+ "query_shape_confidence": "low",
+ "intent": "vat_payable_forecast",
+ "intent_confidence": "high",
+ "entities": {
+ "account": null,
+ "counterparty": null,
+ "contract": null,
+ "document_type": null,
+ "document_ref": null,
+ "organization": null
+ },
+ "period": {
+ "scope": "range",
+ "period_from": "2018-04-01",
+ "period_to": "2018-05-12",
+ "as_of_date": null,
+ "has_explicit_period": true
+ },
+ "aggregation_profile": "management_profile"
+ },
+ "fallback_rule_hit": null,
+ "sanitized_user_message": "какойй прогноз оплаты ндс на 12-05-2018",
+ "tool_gate_decision": "run_address_lane",
+ "tool_gate_reason": "address_mode_classifier_detected",
+ "orchestration_contract_v1": {
+ "schema_version": "assistant_orchestration_contract_v1",
+ "hard_meta_mode": null,
+ "address_mode": "address_query",
+ "address_mode_confidence": "medium",
+ "address_intent": "vat_payable_forecast",
+ "address_intent_confidence": "high",
+ "strong_data_signal_detected": true,
+ "data_retrieval_signal_detected": false,
+ "followup_context_detected": true,
+ "unsupported_address_intent_fallback_to_deep": false,
+ "final_decision": {
+ "run_address_lane": true,
+ "tool_gate_decision": "run_address_lane",
+ "tool_gate_reason": "address_mode_classifier_detected",
+ "living_mode": "address_data",
+ "living_reason": "address_lane_triggered"
+ }
+ },
+ "dialog_continuation_contract_v2": {
+ "schema_version": "address_dialog_continuation_contract_v2",
+ "source_message": "какойй прогноз оплаты ндс на 12-05-2018",
+ "effective_message": "прогноз оплаты НДС на 12.05.2018",
+ "decision": "continue_previous",
+ "decision_reasons": [
+ "followup_context_detected",
+ "effective_message_rewritten_by_predecompose",
+ "llm_contract_intent_available"
+ ],
+ "followup_context_applied": true,
+ "previous_intent": "vat_payable_forecast",
+ "target_intent": "vat_payable_forecast",
+ "intent_selection_mode": "carry_previous_intent",
+ "anchor_type": "unknown",
+ "anchor_value": null,
+ "implicit_continuation_signal": false
+ },
+ "address_retry_audit": {
+ "attempted": false,
+ "reason": null,
+ "initial_limited_category": null,
+ "retry_message": null,
+ "retry_used_followup_context": false,
+ "retry_result_category": null
+ },
+ "answer_structure_v11": null,
+ "investigation_state_snapshot": null,
+ "normalized": null,
+ "normalizer_output": {
+ "trace_id": "1Scqii0nyQaKzN",
+ "prompt_version": "normalizer_v2_0_2",
+ "applied": true,
+ "effective_message": "прогноз оплаты НДС на 12.05.2018"
+ }
+}
+```
+
+## 15. user
+message_id: msg-T9sGPf0Fr1
+created_at: 2026-04-08T20:21:23.906Z
+reply_type: n/a
+
+какойй прогноз оплаты ндс на 12-05-2019
+
+## 16. assistant
+message_id: msg-QDbA2MId5V
+created_at: 2026-04-08T20:21:30.162Z
+reply_type: factual
+trace_id: address-ZOIvabhFjm
+
+Собран прогноз НДС к уплате по фактическим проводкам (НДС-субсчета 68.02*/19*).
+Строк агрегата: 4.
+Оборот по кредиту 68*: 0.00.
+Оборот по дебету 68*: 105478.95.
+Нетто НДС (68 Кт - 68 Дт): -105478.95.
+Прогноз НДС к уплате: 0.00.
+Потенциальный перенос/переплата: 105478.95.
+Справочно по 19*: дебет 524.45, кредит 90863.95.
+Период расчета (срез обязательств): 01.04.2019..12.05.2019.
+Налоговый период: 2 кв. 2019.
+Срок сдачи декларации: до 25.07.2019.
+Сроки уплаты: 28.07.2019, 28.08.2019, 28.09.2019.
+Ориентир по долям к уплате: 0.00 / 0.00 / 0.00.
+Важно: даже при нулевой сумме к уплате декларация по НДС подается в установленный срок; переносы по выходным/праздникам сверяйте по календарю ФНС/1С.
+
+### technical_debug_payload_json
+```json
+{
+ "trace_id": "address-ZOIvabhFjm",
+ "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": [
+ "account_code_detected",
+ "shape_not_detected",
+ "forecast_tax_signal_detected"
+ ],
+ "why_included_summary": [],
+ "selection_reason_summary": []
+ },
+ "dropped_intent_segments": [],
+ "detected_mode": "address_query",
+ "detected_mode_confidence": "medium",
+ "query_shape": "UNKNOWN",
+ "query_shape_confidence": "low",
+ "detected_intent": "vat_payable_forecast",
+ "detected_intent_confidence": "high",
+ "extracted_filters": {
+ "sort": "period_desc",
+ "period_from": "2019-04-01",
+ "period_to": "2019-05-12"
+ },
+ "missing_required_filters": [],
+ "selected_recipe": "address_vat_payable_forecast_v1",
+ "mcp_call_status_legacy": "matched_non_empty",
+ "account_scope_mode": "preferred",
+ "account_scope_fallback_applied": false,
+ "anchor_type": "unknown",
+ "anchor_value_raw": null,
+ "anchor_value_resolved": null,
+ "resolver_confidence": "low",
+ "ambiguity_count": 0,
+ "match_failure_stage": "none",
+ "match_failure_reason": null,
+ "mcp_call_status": "matched_non_empty",
+ "rows_fetched": 4,
+ "raw_rows_received": 4,
+ "rows_after_account_scope": 4,
+ "rows_after_recipe_filter": 4,
+ "rows_materialized": 4,
+ "rows_matched": 4,
+ "raw_row_keys_sample": [
+ "Период",
+ "Регистратор",
+ "СчетДт",
+ "СчетКт",
+ "Сумма",
+ "Period",
+ "Registrator",
+ "AccountDt",
+ "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_SUMMARY",
+ "execution_lane": "address_query",
+ "llm_decomposition_applied": true,
+ "llm_decomposition_attempted": true,
+ "llm_provider_used": "local",
+ "llm_decomposition_trace_id": "Ct9EhJhN9iA5Ge",
+ "llm_decomposition_effective_message": "прогноз оплаты НДС на 12.05.2019",
+ "llm_decomposition_reason": "normalized_fragment_applied",
+ "llm_canonical_candidate_detected": true,
+ "llm_predecompose_contract": {
+ "schema_version": "address_llm_predecompose_contract_v1",
+ "source_message": "какойй прогноз оплаты ндс на 12-05-2019",
+ "canonical_message": "прогноз оплаты НДС на 12.05.2019",
+ "mode": "address_query",
+ "mode_confidence": "medium",
+ "query_shape": "UNKNOWN",
+ "query_shape_confidence": "low",
+ "intent": "vat_payable_forecast",
+ "intent_confidence": "high",
+ "entities": {
+ "account": null,
+ "counterparty": null,
+ "contract": null,
+ "document_type": null,
+ "document_ref": null,
+ "organization": null
+ },
+ "period": {
+ "scope": "range",
+ "period_from": "2019-04-01",
+ "period_to": "2019-05-12",
+ "as_of_date": null,
+ "has_explicit_period": true
+ },
+ "aggregation_profile": "management_profile"
+ },
+ "fallback_rule_hit": null,
+ "sanitized_user_message": "какойй прогноз оплаты ндс на 12-05-2019",
+ "tool_gate_decision": "run_address_lane",
+ "tool_gate_reason": "address_mode_classifier_detected",
+ "orchestration_contract_v1": {
+ "schema_version": "assistant_orchestration_contract_v1",
+ "hard_meta_mode": null,
+ "address_mode": "address_query",
+ "address_mode_confidence": "medium",
+ "address_intent": "vat_payable_forecast",
+ "address_intent_confidence": "high",
+ "strong_data_signal_detected": true,
+ "data_retrieval_signal_detected": false,
+ "followup_context_detected": true,
+ "unsupported_address_intent_fallback_to_deep": false,
+ "final_decision": {
+ "run_address_lane": true,
+ "tool_gate_decision": "run_address_lane",
+ "tool_gate_reason": "address_mode_classifier_detected",
+ "living_mode": "address_data",
+ "living_reason": "address_lane_triggered"
+ }
+ },
+ "dialog_continuation_contract_v2": {
+ "schema_version": "address_dialog_continuation_contract_v2",
+ "source_message": "какойй прогноз оплаты ндс на 12-05-2019",
+ "effective_message": "прогноз оплаты НДС на 12.05.2019",
+ "decision": "continue_previous",
+ "decision_reasons": [
+ "followup_context_detected",
+ "effective_message_rewritten_by_predecompose",
+ "llm_contract_intent_available"
+ ],
+ "followup_context_applied": true,
+ "previous_intent": "vat_payable_forecast",
+ "target_intent": "vat_payable_forecast",
+ "intent_selection_mode": "carry_previous_intent",
+ "anchor_type": "unknown",
+ "anchor_value": null,
+ "implicit_continuation_signal": false
+ },
+ "address_retry_audit": {
+ "attempted": false,
+ "reason": null,
+ "initial_limited_category": null,
+ "retry_message": null,
+ "retry_used_followup_context": false,
+ "retry_result_category": null
+ },
+ "answer_structure_v11": null,
+ "investigation_state_snapshot": null,
+ "normalized": null,
+ "normalizer_output": {
+ "trace_id": "Ct9EhJhN9iA5Ge",
+ "prompt_version": "normalizer_v2_0_2",
+ "applied": true,
+ "effective_message": "прогноз оплаты НДС на 12.05.2019"
+ }
+}
+```
+
+## 17. user
+message_id: msg-NclQKR1m7U
+created_at: 2026-04-08T20:21:38.024Z
+reply_type: n/a
+
+какойй прогноз оплаты ндс на 12-05-2020
+
+## 18. assistant
+message_id: msg-D_SjqHUvx-
+created_at: 2026-04-08T20:21:43.337Z
+reply_type: factual
+trace_id: address-ZWgFt_h9xS
+
+Собран прогноз НДС к уплате по фактическим проводкам (НДС-субсчета 68.02*/19*).
+Строк агрегата: 4.
+Оборот по кредиту 68*: 142500.00.
+Оборот по дебету 68*: 933.32.
+Нетто НДС (68 Кт - 68 Дт): 141566.68.
+Прогноз НДС к уплате: 141566.68.
+Потенциальный перенос/переплата: 0.00.
+Справочно по 19*: дебет 233.33, кредит 933.32.
+Период расчета (срез обязательств): 01.04.2020..12.05.2020.
+Налоговый период: 2 кв. 2020.
+Срок сдачи декларации: до 25.07.2020.
+Сроки уплаты: 28.07.2020, 28.08.2020, 28.09.2020.
+Ориентир по долям к уплате: 47188.89 / 47188.89 / 47188.90.
+Важно: даже при нулевой сумме к уплате декларация по НДС подается в установленный срок; переносы по выходным/праздникам сверяйте по календарю ФНС/1С.
+
+### technical_debug_payload_json
+```json
+{
+ "trace_id": "address-ZWgFt_h9xS",
+ "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": [
+ "account_code_detected",
+ "shape_not_detected",
+ "forecast_tax_signal_detected"
+ ],
+ "why_included_summary": [],
+ "selection_reason_summary": []
+ },
+ "dropped_intent_segments": [],
+ "detected_mode": "address_query",
+ "detected_mode_confidence": "medium",
+ "query_shape": "UNKNOWN",
+ "query_shape_confidence": "low",
+ "detected_intent": "vat_payable_forecast",
+ "detected_intent_confidence": "high",
+ "extracted_filters": {
+ "sort": "period_desc",
+ "period_from": "2020-04-01",
+ "period_to": "2020-05-12"
+ },
+ "missing_required_filters": [],
+ "selected_recipe": "address_vat_payable_forecast_v1",
+ "mcp_call_status_legacy": "matched_non_empty",
+ "account_scope_mode": "preferred",
+ "account_scope_fallback_applied": false,
+ "anchor_type": "unknown",
+ "anchor_value_raw": null,
+ "anchor_value_resolved": null,
+ "resolver_confidence": "low",
+ "ambiguity_count": 0,
+ "match_failure_stage": "none",
+ "match_failure_reason": null,
+ "mcp_call_status": "matched_non_empty",
+ "rows_fetched": 4,
+ "raw_rows_received": 4,
+ "rows_after_account_scope": 4,
+ "rows_after_recipe_filter": 4,
+ "rows_materialized": 4,
+ "rows_matched": 4,
+ "raw_row_keys_sample": [
+ "Период",
+ "Регистратор",
+ "СчетДт",
+ "СчетКт",
+ "Сумма",
+ "Period",
+ "Registrator",
+ "AccountDt",
+ "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_SUMMARY",
+ "execution_lane": "address_query",
+ "llm_decomposition_applied": true,
+ "llm_decomposition_attempted": true,
+ "llm_provider_used": "local",
+ "llm_decomposition_trace_id": "eSY3i6lobJABIB",
+ "llm_decomposition_effective_message": "Прогноз оплаты НДС на 12.05.2020.",
+ "llm_decomposition_reason": "normalized_fragment_applied",
+ "llm_canonical_candidate_detected": true,
+ "llm_predecompose_contract": {
+ "schema_version": "address_llm_predecompose_contract_v1",
+ "source_message": "какойй прогноз оплаты ндс на 12-05-2020",
+ "canonical_message": "Прогноз оплаты НДС на 12.05.2020.",
+ "mode": "address_query",
+ "mode_confidence": "medium",
+ "query_shape": "UNKNOWN",
+ "query_shape_confidence": "low",
+ "intent": "vat_payable_forecast",
+ "intent_confidence": "high",
+ "entities": {
+ "account": null,
+ "counterparty": null,
+ "contract": null,
+ "document_type": null,
+ "document_ref": null,
+ "organization": null
+ },
+ "period": {
+ "scope": "range",
+ "period_from": "2020-04-01",
+ "period_to": "2020-05-12",
+ "as_of_date": null,
+ "has_explicit_period": true
+ },
+ "aggregation_profile": "management_profile"
+ },
+ "fallback_rule_hit": null,
+ "sanitized_user_message": "какойй прогноз оплаты ндс на 12-05-2020",
+ "tool_gate_decision": "run_address_lane",
+ "tool_gate_reason": "address_mode_classifier_detected",
+ "orchestration_contract_v1": {
+ "schema_version": "assistant_orchestration_contract_v1",
+ "hard_meta_mode": null,
+ "address_mode": "address_query",
+ "address_mode_confidence": "medium",
+ "address_intent": "vat_payable_forecast",
+ "address_intent_confidence": "high",
+ "strong_data_signal_detected": true,
+ "data_retrieval_signal_detected": false,
+ "followup_context_detected": true,
+ "unsupported_address_intent_fallback_to_deep": false,
+ "final_decision": {
+ "run_address_lane": true,
+ "tool_gate_decision": "run_address_lane",
+ "tool_gate_reason": "address_mode_classifier_detected",
+ "living_mode": "address_data",
+ "living_reason": "address_lane_triggered"
+ }
+ },
+ "dialog_continuation_contract_v2": {
+ "schema_version": "address_dialog_continuation_contract_v2",
+ "source_message": "какойй прогноз оплаты ндс на 12-05-2020",
+ "effective_message": "Прогноз оплаты НДС на 12.05.2020.",
+ "decision": "continue_previous",
+ "decision_reasons": [
+ "followup_context_detected",
+ "effective_message_rewritten_by_predecompose",
+ "llm_contract_intent_available"
+ ],
+ "followup_context_applied": true,
+ "previous_intent": "vat_payable_forecast",
+ "target_intent": "vat_payable_forecast",
+ "intent_selection_mode": "carry_previous_intent",
+ "anchor_type": "unknown",
+ "anchor_value": null,
+ "implicit_continuation_signal": false
+ },
+ "address_retry_audit": {
+ "attempted": false,
+ "reason": null,
+ "initial_limited_category": null,
+ "retry_message": null,
+ "retry_used_followup_context": false,
+ "retry_result_category": null
+ },
+ "answer_structure_v11": null,
+ "investigation_state_snapshot": null,
+ "normalized": null,
+ "normalizer_output": {
+ "trace_id": "eSY3i6lobJABIB",
+ "prompt_version": "normalizer_v2_0_2",
+ "applied": true,
+ "effective_message": "Прогноз оплаты НДС на 12.05.2020."
+ }
+}
+```
+
diff --git a/docs/ADDRESS/address_query/README.md b/docs/ADDRESS/address_query/README.md
index cb9269f..42e9828 100644
--- a/docs/ADDRESS/address_query/README.md
+++ b/docs/ADDRESS/address_query/README.md
@@ -29,6 +29,15 @@
- domain live-gate: `docs/ADDRESS/runs/2026-04-08_Address_Batch2_Lifecycle_FullGate_PhaseC_PostFix2/run_summary.json` (`strict 36/36`)
- targeted code gate: `addressQueryRuntimeM23.test.ts + assistantAddressFollowupContext.test.ts = 223/223`, `build=PASS`
- global non-regression: `docs/ADDRESS/runs/2026-04-08_Address_Nightly_Regression_2026-04-08_13-19-24/nightly_summary.json` (`stress 102/102`, `followup 25/25`, comparator PASS)
+- Принят `domain scope freeze`: новые домены временно не расширяем; фокус смещен на Step-5 (архитектура + UX + качество ответов).
+- Step-5 bootstrap (2026-04-08):
+ - UX compose first-line direct-answer patch внедрен;
+ - targeted tests: `220/220 + 3/3`;
+ - live smoke: `docs/ADDRESS/runs/2026-04-08_Address_Step5_UX_Smoke_v2/run_summary.json` (`strict 6/6`).
+- Step-5 living router increment (2026-04-08):
+ - added `FEATURE_ASSISTANT_LIVING_CHAT_ROUTER_V1` (default `true`);
+ - added chat mode (`chat`) for non-data conversational messages with safe fallback to deep pipeline;
+ - added OpenAI-compatible `chat()` in `OpenAIResponsesClient` (with local `/responses` -> `/chat/completions` fallback).
- Финальный stress-pack: `102/102`
`docs/ADDRESS/runs/2026-04-02_Address_Slang_Live_Stress_2026-04-02_12-57-27/run_summary.json`
- Финальный follow-up pack: `25/25`
@@ -37,7 +46,7 @@
`docs/ADDRESS/runs/2026-04-08_Address_Nightly_Regression_2026-04-08_13-19-24/nightly_summary.json`
- Task Scheduler: `NDC_ADDRESS_Nightly_Regression` временно `Disabled` (ручной режим до стабилизации infra-канала).
- Текущий production-контур: `question_mode=address_query`, live-first через MCP.
-- Следующий этап: `Step-4` domain expansion по рельсовой модели Step-0.
+- Следующий этап: `Step-5` Architecture + UX Quality (LLM-first валидация входа, улучшение пользовательского ответа, без расширения domain scope).
## Что реально реализовано в коде (срез 2026-04-08)
@@ -83,6 +92,7 @@
- `step0_preprod_rail_plan_v1.md` - обязательный pre-prod рельсовый этап перед массовым расширением доменов.
- `step0_closeout_2026-04-02.md` - факт закрытия Step-0 с артефактами и gate-подтверждением.
- `domain_expansion_implementation_plan_v1.md` - план `Step-4`.
+- `step5_architecture_ux_quality_plan_v1_2026-04-08.md` - план `Step-5` (LLM-first input validation, UX, качество ответов).
- `general_domain_questions_analysis_plan_v1_2026-04-02.md` - глубокий разбор общего домена (40 вопросов), route-модель и batch-план внедрения.
- `management_route_probe_report_g1_2026-04-02.md` - live Batch-0 probe по первой группе общего домена (Q1–Q5) с route-верификацией через MCP/1С.
- `complex_questions_status_and_reuse_map_2026-04-02.md` - сверка кода/доков по "сложным вопросам": что реализовано, что detection-only, и как переиспользовать в продуктовом плане.
@@ -133,3 +143,11 @@
- `docs/ADDRESS/runs/2026-04-02_Address_Followup_Context_Chains_2026-04-02_19-15-Run5/`
- `docs/ADDRESS/runs/2026-04-02_Address_Domain_ContractsOpenItems_Reference_Acceptance_2026-04-02_17-00-22/`
- `docs/ADDRESS/runs/2026-04-02_Address_Nightly_Regression_2026-04-02_17-35-00/`
+- Step-5 follow-up hardening (2026-04-08):
+ - `address_dialog_continuation_contract_v2` for context continuation/switching.
+ - Safe retry on retryable limited outcomes (`missing_anchor` / `empty_match`) using raw user message + preserved context.
+ - Regression confirmation: `246/246` PASS (`assistantAddressFollowupContext`, `addressQueryRuntimeM23`, `assistantAddressLlmPredecompose`).
+- Step-5 living router validation (2026-04-08):
+ - `assistantLivingRouter.test.ts`: `4/4` PASS
+ - `assistantLivingChatMode.test.ts`: `1/1` PASS
+ - build: PASS
diff --git a/docs/ADDRESS/address_query/domain_expansion_implementation_plan_v1.md b/docs/ADDRESS/address_query/domain_expansion_implementation_plan_v1.md
index 01412d8..48bf2f2 100644
--- a/docs/ADDRESS/address_query/domain_expansion_implementation_plan_v1.md
+++ b/docs/ADDRESS/address_query/domain_expansion_implementation_plan_v1.md
@@ -4,6 +4,17 @@
Контур: `question_mode=address_query` (live-first, MCP)
Стартовая база качества: закрытый этап стабилизации (`102/102` stress, `25/25` follow-up).
+## 0. Status Update (2026-04-08)
+
+План Step-4 остается референсом, но на текущем этапе введен `domain scope freeze`:
+
+- новые домены временно не расширяем;
+- текущий охват (Batch-1/Batch-2/Batch-3) зафиксирован как рабочий baseline;
+- приоритет смещен на Step-5: архитектура, LLM-first валидация пользовательских запросов и качество ответов.
+
+Рабочий документ следующего этапа:
+`docs/ADDRESS/address_query/step5_architecture_ux_quality_plan_v1_2026-04-08.md`.
+
## 1. Цель и рамки
Цель этапа: расширять покрытие доменов/интентов без деградации уже стабильного ядра.
diff --git a/docs/ADDRESS/address_query/project_status_rails_graph_2026-04-08.md b/docs/ADDRESS/address_query/project_status_rails_graph_2026-04-08.md
index 19e1cf7..2d9d61c 100644
--- a/docs/ADDRESS/address_query/project_status_rails_graph_2026-04-08.md
+++ b/docs/ADDRESS/address_query/project_status_rails_graph_2026-04-08.md
@@ -14,8 +14,8 @@ flowchart LR
E["Auto-nightly Incident
2026-04-08 09:52
MCP fetch failed"]
F["Targeted Fix
S019 predecompose anchor drift"]
G["Manual Control Nightly Recheck
2026-04-08 13:19
102/102 + 25/25 PASS"]
- H["CURRENT STATE
BATCH2_PHASE_C_CLOSED"]
- I["NEXT STEP
Batch-4/5 domain scoping
Debt/Aging/Risk"]
+ H["CURRENT STATE
STEP5_PHASE_B_BOOTSTRAP_ACTIVE"]
+ I["NEXT STEP
Step-5 core hardening
LLM-first validation + clarification UX"]
K["Batch-2 Gate Closed
36/36 + Global PASS"]
J["Operational Rail
Scheduler: Disabled
Manual nightly only"]
@@ -37,6 +37,10 @@ flowchart LR
`docs/ADDRESS/runs/2026-04-08_Address_Batch2_Lifecycle_FullGate_PhaseC_PostFix2/run_summary.json`
- Master checker (entry to Batch-2):
`docs/ADDRESS/address_query/step4_wave1_batch1_master_checker_v1.md`
+- Step-5 plan (current focus):
+ `docs/ADDRESS/address_query/step5_architecture_ux_quality_plan_v1_2026-04-08.md`
+- Step-5 UX smoke (bootstrap):
+ `docs/ADDRESS/runs/2026-04-08_Address_Step5_UX_Smoke_v2/run_summary.json`
- Batch-2 Phase-A artifacts:
- `docs/ADDRESS/address_query/domain_general_batch2_lifecycle_card_v1.md`
- `docs/ADDRESS/question_sets/domain_general_batch2_lifecycle_acceptance_2026-04-08_phaseA.json`
@@ -50,4 +54,14 @@ flowchart LR
- Функциональные гейты закрыты.
- Точечный flake `S019` закрыт.
- Batch-2 lifecycle закрыт до `Phase C`: domain gate `36/36`, global regression `102/102 + 25/25`, comparator PASS.
-- Следующий практический шаг: стартовать scoping Batch-4/Batch-5 (`debt/aging/risk`) как отдельную доменную волну.
+- Step-5 запущен: UX bootstrap-патч в `composeStage` и targeted smoke `6/6`.
+- Следующий практический шаг: core hardening Step-5 (LLM-first validation + clarification UX), при domain scope freeze.
+
+## Step-5 Increment Update (2026-04-08)
+
+- Added `address_dialog_continuation_contract_v2` in runtime follow-up flow (`new_topic` / `continue_previous` / `switch_to_suggested`).
+- Added safe retry for retryable limited answers (`limited_reason_category: missing_anchor | empty_match`) with retry on raw user message and preserved follow-up context.
+- Added low-quality pseudo-anchor guard for referential follow-ups (`кроме этого ...`) with stable anchor carryover.
+- Added debug/log audit fields: `dialog_continuation_contract_v2`, `address_retry_audit`.
+- Targeted regression after hardening: `246/246` PASS (`assistantAddressFollowupContext`, `addressQueryRuntimeM23`, `assistantAddressLlmPredecompose`).
+- Living router increment: conversational `chat` mode added for non-data messages with safe fallback to deep pipeline (`assistantLivingRouter 4/4`, `assistantLivingChatMode 1/1`, build PASS).
diff --git a/docs/ADDRESS/address_query/step4_wave1_batch1_master_checker_v1.md b/docs/ADDRESS/address_query/step4_wave1_batch1_master_checker_v1.md
index 81d631d..1128f6e 100644
--- a/docs/ADDRESS/address_query/step4_wave1_batch1_master_checker_v1.md
+++ b/docs/ADDRESS/address_query/step4_wave1_batch1_master_checker_v1.md
@@ -51,7 +51,7 @@ Batch-1 можно переводить в runtime только после за
- [x] Phase B.1 (Batch-1 prep): реализованы `period_coverage_profile`, `document_type_and_account_section_profile`, `counterparty_population_and_roles`, `contract_usage_overview` (unit/build green).
- [x] Phase B.2 (Batch-3 value prep): реализованы `customer_revenue_and_payments`, `supplier_payouts_profile`, `contract_usage_and_value`; стандарт ранжирования `top-20`; routing усилен для сленга/опечаток.
- [x] Targeted live-check Batch-1 next pack (`Q6/Q7/Q28`) выполнен: `strict factual 9/9`.
-- [x] Targeted code gate по расширенному management/value слою: `addressQueryRuntimeM23.test.ts = 200/200`, `assistantAddressLlmPredecompose.test.ts = PASS`, `build=PASS`.
+- [x] Targeted code gate по расширенному management/value слою: `addressQueryRuntimeM23.test.ts + assistantAddressFollowupContext.test.ts = 223/223`, `build=PASS`.
- [x] Phase C (Batch-1 domain pack) прогнан: `domain_general_batch1_acceptance_2026-04-02_phaseA.json` -> `strict 28/28`
run: `docs/ADDRESS/runs/2026-04-03_Address_Domain_General_B1_PhaseC_LiveGate_R3/`
- [x] Batch-3 value live-gate прогнан: `temp_batch3_value_top20_2026-04-02.json` -> `strict 33/33`
@@ -59,8 +59,8 @@ Batch-1 можно переводить в runtime только после за
- [x] Global non-regression + comparator к baseline: `PASS`
run: `docs/ADDRESS/runs/2026-04-03_Address_Nightly_Regression_Post_AnchorHardening_R6/nightly_summary.json`
детали: `overall_ok=true`, `stress_102=102/102`, `followup_25=25/25`, comparator PASS.
-- [x] Контрольный nightly recheck (`2026-04-08 10:51`) закрыт в `PASS`
- run: `docs/ADDRESS/runs/2026-04-08_Address_Nightly_Regression_2026-04-08_10-51-20/nightly_summary.json`
+- [x] Контрольный nightly recheck (`2026-04-08 13:19`) закрыт в `PASS`
+ run: `docs/ADDRESS/runs/2026-04-08_Address_Nightly_Regression_2026-04-08_13-19-24/nightly_summary.json`
детали: `stress_102=102/102`, `followup_25=25/25`, comparator PASS.
- [x] Batch-2 lifecycle Phase A стартован:
- `domain_general_batch2_lifecycle_card_v1.md`
@@ -69,13 +69,13 @@ Batch-1 можно переводить в runtime только после за
- `step4_wave1_batch2_phaseA_backlog_v1.md`
- [x] Batch-2 Phase-B progress:
- resolver drift по `Q12/Q13/Q26/Q27/Q31/Q32` закрыт
- - `addressQueryRuntimeM23.test.ts = 210/210`, `build=PASS`
+ - `addressQueryRuntimeM23.test.ts + assistantAddressFollowupContext.test.ts = 223/223`, `build=PASS`
- live hotpass `wave1_batch2_phaseB_resolver_hotpass_2026-04-08.md` (`route match 6/6`)
- [x] Phase B закрыт.
- [x] Phase C закрыт.
## 4. Решение на сейчас
-1. Начинать можно, но строго по фазам выше.
-2. Прямое включение Batch-1 intents в production-path без Phase B/C — запрещено.
-3. Точка входа в работу: Batch-2 уже в `Phase A active`; следующий шаг — выполнить Batch-2 Phase B и закрыть domain gate/глобальный comparator.
+1. Wave-1 domain-расширение (Batch-1/Batch-2/Batch-3) зафиксировано как закрытое.
+2. Новые домены временно не запускаем (`domain scope freeze`).
+3. Следующая точка входа в работу: Step-5 (`Architecture + UX + Answer Quality`), с приоритетом LLM-first валидации пользовательского запроса и качества ответа.
diff --git a/docs/ADDRESS/address_query/step4_wave1_batch2_phaseA_backlog_v1.md b/docs/ADDRESS/address_query/step4_wave1_batch2_phaseA_backlog_v1.md
index 19d06eb..da9aaf8 100644
--- a/docs/ADDRESS/address_query/step4_wave1_batch2_phaseA_backlog_v1.md
+++ b/docs/ADDRESS/address_query/step4_wave1_batch2_phaseA_backlog_v1.md
@@ -79,8 +79,9 @@ Scope: `Q8..Q13 + Q26 + Q27 + Q31 + Q32`
## 4. Текущий приоритет (следующий кодовый шаг)
1. Перевести Batch-2 из wave backlog в master checker как `closed`.
-2. Подготовить Batch-4/Batch-5 scope (debt/aging/risk) отдельной доменной карточкой.
-3. Держать nightly regression в green-контуре как release guardrail.
+2. Ввести `domain scope freeze` на текущем объеме доменов.
+3. Перейти к Step-5 (`Architecture + UX + Answer Quality`) без запуска новых доменных волн.
+4. Держать nightly regression в green-контуре как release guardrail.
## 5. Фактические артефакты закрытия
diff --git a/docs/ADDRESS/address_query/step5_architecture_ux_quality_plan_v1_2026-04-08.md b/docs/ADDRESS/address_query/step5_architecture_ux_quality_plan_v1_2026-04-08.md
new file mode 100644
index 0000000..fbb96ea
--- /dev/null
+++ b/docs/ADDRESS/address_query/step5_architecture_ux_quality_plan_v1_2026-04-08.md
@@ -0,0 +1,198 @@
+# Step-5 Architecture + UX Quality Plan V1
+
+Дата: 2026-04-08
+Статус: `active` (Phase B bootstrap started)
+Контур: `question_mode=address_query` (live-first, MCP)
+
+## 1. Что фиксируем сейчас
+
+1. Текущий объем доменов фиксируем (`domain scope freeze`).
+2. Новые домены временно не добавляем.
+3. Фокус переносим на качество архитектуры и пользовательского ответа.
+
+## 2. Цель Step-5 (простыми словами)
+
+Сделать ассистента менее “механическим” и более полезным:
+
+1. меньше ручной словарной рутины;
+2. больше смысловой обработки через LLM;
+3. ответ по делу, в человеческом формате, с четкой конкретикой и аккуратными рекомендациями.
+
+## 3. Принципы этапа
+
+1. LLM отвечает за понимание смысла и валидацию формулировки пользователя.
+2. Runtime отвечает за безопасность маршрута (`intent -> filters -> recipe -> MCP`).
+3. LLM не генерирует свободные запросы к 1С/БД.
+4. Company-specific словари/хардкод не добавляем.
+
+## 4. Целевая схема (LLM-first, но безопасно)
+
+1. `LLM Semantic Contract`:
+ - на входе формируем каноническое представление запроса;
+ - фиксируем `intent`, `entities`, `period`, `user_goal`, `confidence`, `ambiguity`.
+2. `Route Validator`:
+ - проверяем, что intent поддержан и фильтры допустимы;
+ - при конфликте/нехватке данных выдаем корректный clarification, а не псевдо-factual.
+3. `Execution`:
+ - только whitelist recipes и текущий MCP-контур.
+4. `Answer Composer`:
+ - сначала прямой ответ на вопрос;
+ - затем короткое обоснование (по данным);
+ - затем опционально рекомендации (если уместно и подтверждено данными).
+
+## 5. Рабочие пакеты
+
+### WP-1. Уход от словарной рутины
+
+1. Зафиксировать “словарный freeze”: новые slang-слова не добавляем как основной путь.
+2. Расширять покрытие через LLM-contract + regression-набор перефразировок.
+3. Ввести метрику доли запросов, обработанных через LLM canonical path без словарного патча.
+
+### WP-2. LLM-first валидация запроса
+
+1. Добавить обязательную проверку качества canonical-контракта:
+ - intent consistency;
+ - entity consistency;
+ - period consistency.
+2. Если confidence низкий, задавать 1 целевой уточняющий вопрос (без “простыни”).
+3. Убрать токсичный UX fallback вида “что сломано / ограничения” там, где вопрос обычный и решаемый.
+
+### WP-3. Качество ответа для пользователя
+
+1. Формат ответа по умолчанию:
+ - `короткий прямой ответ`;
+ - `ключевые цифры/факты`;
+ - `опционально: что это значит для бизнеса`.
+2. Для ranking/summary ответов:
+ - не смешивать несколько разных вопросов в один ответ;
+ - явно писать период и сущность (контрагент/договор/документ).
+3. Для рекомендаций:
+ - только мягкие и grounded (на основе текущих данных);
+ - без выдуманных советов “из воздуха”.
+
+### WP-4. UX и follow-up
+
+1. Стабилизировать follow-up цепочки (короткие реплики, местоимения, “а теперь за 21”).
+2. Держать контекст предыдущего успешного address-ответа.
+3. Не терять контекст после промежуточного clarification.
+
+## 6. Gate и метрики Step-5
+
+Обязательные:
+
+1. не просесть ниже baseline:
+ - stress `102/102`;
+ - follow-up `25/25`;
+ - comparator `PASS`.
+2. `false_factual_rate = 0`.
+3. `execution_error_rate = 0`.
+
+Новые UX-метрики:
+
+1. `clarification_rate` на целевом UX-наборе снижается.
+2. `direct_answer_rate` растет (ответ начинается с сути, а не с шаблонного fallback).
+3. `mixed_answer_rate` (слипание ответов на разные вопросы) снижается к 0.
+
+## 7. Фазы выполнения
+
+1. **Phase A — Contract/UX Design**
+ описать v2 контракт и формат “человеческого” ответа.
+2. **Phase B — Runtime Implementation**
+ внедрить LLM-first validation + composer improvements.
+3. **Phase C — Live UX Gate**
+ прогон targeted UX-набора + контроль global non-regression.
+
+## 8. Критерий завершения Step-5
+
+Step-5 считается закрытым, когда одновременно:
+
+1. baseline quality gates остаются зелеными;
+2. на UX-наборе заметно меньше ненужных clarification/fallback ответов;
+3. ответы стабильно конкретные, по вопросу, без механического шаблона;
+4. новые domain-расширения можно запускать уже на более зрелой архитектуре.
+
+## 9. Прогресс на 2026-04-08
+
+Сделано (первый рабочий инкремент):
+
+1. UX-ответы в `composeStage` переведены в формат “суть первой строкой”:
+ - для management/ranking/counterparty/document/bank intents первая строка теперь дает прямой ответ (количество/итог/top-заголовок);
+ - служебные строки вида “собран профиль…” оставлены как supporting block.
+2. Исправлен role-focus для фраз типа `скока поставщиков в базе`:
+ - при явном supplier/customer/mixed-сигнале приоритет отдается роли, даже если LLM-канонизация добавила общий `контрагент` контекст.
+3. Regression tests green:
+ - `addressQueryRuntimeM23.test.ts` -> `220/220`;
+ - `assistantAddressFollowupContext.test.ts` -> `3/3`.
+4. Live UX smoke (targeted) green:
+ - набор: `docs/ADDRESS/question_sets/step5_ux_smoke_2026-04-08.json`;
+ - run v2: `docs/ADDRESS/runs/2026-04-08_Address_Step5_UX_Smoke_v2/run_summary.json`;
+ - результат: `strict 6/6`.
+
+Наблюдение:
+
+1. На targeted smoke ответы стали короче и конкретнее в первой строке (например: `Поставщиков ...: 79`, `Активные заказчики в 2020 году: 13`, `Найдено документов ...: 12`).
+
+## 10. Increment 2026-04-08 (Anchor Clarification UX)
+
+1. Hardened `partial_coverage` for anchor mismatch in `addressQueryService`.
+2. If mismatch reason is `counterparty_anchor_not_matched` or `contract_anchor_not_matched`, runtime now returns targeted clarification (anchor-first), instead of generic period-first suggestion.
+3. For counterparty/contract mismatch, next step keeps recognized period window and asks for exact entity anchor.
+4. Added regression assertion in `addressQueryRuntimeM23.test.ts` for counterparty mismatch clarification wording.
+5. Validation:
+ - `addressQueryRuntimeM23.test.ts` -> PASS (220/220)
+ - `assistantAddressFollowupContext.test.ts` -> PASS (3/3)
+ - `npm run build` -> PASS.
+
+## 11. Increment 2026-04-08 (Dialog Continuation v2 + Safe Retry)
+
+1. В `assistantService` добавлен `address_dialog_continuation_contract_v2`:
+ - решения `new_topic` / `continue_previous` / `switch_to_suggested`;
+ - явная фиксация `previous_intent`, `target_intent`, `reasons`.
+2. Расширены follow-up сигналы для референсных реплик:
+ - формы вида `этого/этом/эту/эти...`, `кроме этого...`, `помимо этого...`, `есть еще...`.
+3. Добавлен `safe retry` для address-lane:
+ - если первый ответ ограниченный (`limited_reason_category in missing_anchor|empty_match`) и есть риск деградации после rewrite;
+ - runtime делает повторный проход по raw user message с сохранением контекста предыдущего успешного ответа.
+4. Для follow-up запросов типа `кроме этого документа...` добавлена защита от псевдо-якорей:
+ - low-quality anchor заменяется на устойчивый якорь из follow-up контекста.
+5. В debug/log добавлены поля аудита:
+ - `dialog_continuation_contract_v2`;
+ - `address_retry_audit`.
+
+Validation (targeted regression, 2026-04-08):
+
+1. `assistantAddressFollowupContext.test.ts` -> PASS (`7/7`)
+2. `addressQueryRuntimeM23.test.ts` -> PASS (`223/223`)
+3. `assistantAddressLlmPredecompose.test.ts` -> PASS (`16/16`)
+4. Total targeted pack -> PASS (`246/246`)
+
+## 12. Increment 2026-04-08 (Living Assistant Router v1)
+
+1. Added a lightweight living router in `assistantService`:
+ - `address_data` when address lane is triggered;
+ - `chat` for non-data conversational messages;
+ - `deep_analysis` fallback for data-like or uncertain cases.
+2. Added new runtime flag:
+ - `FEATURE_ASSISTANT_LIVING_CHAT_ROUTER_V1` (default: `true`).
+3. Added live chat generation path via `OpenAIResponsesClient.chat(...)`:
+ - OpenAI-compatible local provider support (`/responses` with fallback to `/chat/completions`);
+ - safe fallback to existing deep pipeline if chat call fails.
+4. Safety behavior:
+ - in `useMock=true` mode, router keeps deep pipeline (no network in tests);
+ - no changes to address rails and follow-up context logic.
+5. Validation:
+ - `assistantLivingRouter.test.ts` -> PASS (`4/4`);
+ - `assistantLivingChatMode.test.ts` -> PASS (`1/1`);
+ - targeted regression pack with address follow-up/predecompose tests -> PASS (`28/28`);
+ - `npm run build` -> PASS.
+
+## 13. Increment 2026-04-08 (ToolGate hardening for casual chat)
+
+1. Fixed address tool gate over-trigger:
+ - `llm_canonical_candidate_detected` no longer auto-runs address lane when predecompose contract is `mode=unsupported` or `intent=unknown` with low confidence.
+2. Result:
+ - short casual messages (`йо`, `привет`) no longer fall into clarification-heavy deep/accounting pipeline.
+ - they are handled by living chat mode.
+3. Validation:
+ - extended `assistantLivingChatMode.test.ts` with repro-case `йо` + rewritten canonical candidate.
+ - PASS (`2/2`) with assertion that `addressQueryService.tryHandle` is not called.
diff --git a/docs/ADDRESS/question_sets/step5_ux_smoke_2026-04-08.json b/docs/ADDRESS/question_sets/step5_ux_smoke_2026-04-08.json
new file mode 100644
index 0000000..3fcfc9c
--- /dev/null
+++ b/docs/ADDRESS/question_sets/step5_ux_smoke_2026-04-08.json
@@ -0,0 +1,8 @@
+[
+ {"id":"UX001","text":"какие клиенты самые доходные"},
+ {"id":"UX002","text":"скока поставщиков в базе"},
+ {"id":"UX003","text":"какие заказчики работали с нами в 2020 году"},
+ {"id":"UX004","text":"какие договоры давно не использовались"},
+ {"id":"UX005","text":"покажи документы по нортону"},
+ {"id":"UX006","text":"покажи операции по договору 19/15"}
+]
diff --git a/docs/ADDRESS/runs/2026-04-08_Address_Step5_UX_Smoke_v1/README.md b/docs/ADDRESS/runs/2026-04-08_Address_Step5_UX_Smoke_v1/README.md
new file mode 100644
index 0000000..277cd69
--- /dev/null
+++ b/docs/ADDRESS/runs/2026-04-08_Address_Step5_UX_Smoke_v1/README.md
@@ -0,0 +1,33 @@
+# 2026-04-08_Address_Step5_UX_Smoke_v1
+
+Generated at: 2026-04-08T13:53:34
+Questions file: X:\1C\NDC_1C\docs\ADDRESS\question_sets\step5_ux_smoke_2026-04-08.json
+Backend URL: http://127.0.0.1:8787/api/assistant/message
+LLM: local / qwen2.5-14b-instruct-1m @ http://127.0.0.1:1234
+Strict policy: route
+
+## Totals
+- questions_total: 6
+- ok_200_count: 6
+- semantic_pass_count: 6
+- semantic_pass_rate: 1.0
+- route_pass_count: 6
+- route_pass_rate: 1.0
+- strict_pass_count: 6
+- strict_pass_rate: 1.0
+- factual_count: 6
+- partial_coverage_count: 0
+- clarification_required_count: 0
+- http_error_count: 0
+- llm_decomposition_attempted_count: 6
+- llm_decomposition_applied_count: 4
+- llm_fallback_count: 0
+- llm_fallback_rate: 0.0
+- tool_gate_blocked_count: 0
+- tool_gate_blocked_rate: 0.0
+- avg_elapsed_ms: 5562.2
+
+## Files
+- run_summary.json
+- full_live_results.json
+- failures_only.json
diff --git a/docs/ADDRESS/runs/2026-04-08_Address_Step5_UX_Smoke_v1/failures_only.json b/docs/ADDRESS/runs/2026-04-08_Address_Step5_UX_Smoke_v1/failures_only.json
new file mode 100644
index 0000000..fe51488
--- /dev/null
+++ b/docs/ADDRESS/runs/2026-04-08_Address_Step5_UX_Smoke_v1/failures_only.json
@@ -0,0 +1 @@
+[]
diff --git a/docs/ADDRESS/runs/2026-04-08_Address_Step5_UX_Smoke_v1/full_live_results.json b/docs/ADDRESS/runs/2026-04-08_Address_Step5_UX_Smoke_v1/full_live_results.json
new file mode 100644
index 0000000..d895617
--- /dev/null
+++ b/docs/ADDRESS/runs/2026-04-08_Address_Step5_UX_Smoke_v1/full_live_results.json
@@ -0,0 +1,1234 @@
+{
+ "run_id": "2026-04-08_Address_Step5_UX_Smoke_v1",
+ "generated_at": "2026-04-08T13:53:34",
+ "summary": {
+ "run_id": "2026-04-08_Address_Step5_UX_Smoke_v1",
+ "generated_at": "2026-04-08T13:53:34",
+ "source_questions_file": "X:\\1C\\NDC_1C\\docs\\ADDRESS\\question_sets\\step5_ux_smoke_2026-04-08.json",
+ "backend_url": "http://127.0.0.1:8787/api/assistant/message",
+ "llm_provider": "local",
+ "llm_model": "qwen2.5-14b-instruct-1m",
+ "llm_base_url": "http://127.0.0.1:1234",
+ "strict_policy": "route",
+ "totals": {
+ "questions_total": 6,
+ "ok_200_count": 6,
+ "semantic_pass_count": 6,
+ "semantic_pass_rate": 1.0,
+ "route_pass_count": 6,
+ "route_pass_rate": 1.0,
+ "strict_pass_count": 6,
+ "strict_pass_rate": 1.0,
+ "factual_count": 6,
+ "partial_coverage_count": 0,
+ "clarification_required_count": 0,
+ "http_error_count": 0,
+ "llm_decomposition_attempted_count": 6,
+ "llm_decomposition_applied_count": 4,
+ "llm_fallback_count": 0,
+ "llm_fallback_rate": 0.0,
+ "tool_gate_blocked_count": 0,
+ "tool_gate_blocked_rate": 0.0,
+ "avg_elapsed_ms": 5562.2
+ },
+ "distributions": {
+ "reply_type": {
+ "factual": 6
+ },
+ "actual_intent": {
+ "customer_revenue_and_payments": 1,
+ "counterparty_population_and_roles": 1,
+ "counterparty_activity_lifecycle": 1,
+ "contract_usage_overview": 1,
+ "list_documents_by_counterparty": 1,
+ "bank_operations_by_contract": 1
+ },
+ "actual_mode": {
+ "address_query": 6
+ },
+ "mcp_call_status": {
+ "matched_non_empty": 6
+ },
+ "limited_reason_category": {},
+ "route_health": {
+ "ok_or_factual": 6
+ },
+ "tool_gate_decision": {
+ "run_address_lane": 6
+ },
+ "tool_gate_reason": {
+ "address_mode_classifier_detected": 6
+ }
+ },
+ "address_llm_predecompose_metrics": {
+ "overall": {
+ "llm_attempted": 6,
+ "llm_applied": 4,
+ "fallback_used": 0,
+ "fallback_rate": 0.0,
+ "tool_gate_blocked": 0,
+ "gate_block_rate": 0.0
+ },
+ "by_intent": {
+ "customer_revenue_and_payments": {
+ "total": 1,
+ "llm_attempted": 1,
+ "llm_applied": 0,
+ "fallback_used": 0,
+ "tool_gate_blocked": 0,
+ "fallback_rate": 0.0,
+ "gate_block_rate": 0.0
+ },
+ "counterparty_population_and_roles": {
+ "total": 1,
+ "llm_attempted": 1,
+ "llm_applied": 1,
+ "fallback_used": 0,
+ "tool_gate_blocked": 0,
+ "fallback_rate": 0.0,
+ "gate_block_rate": 0.0
+ },
+ "counterparty_activity_lifecycle": {
+ "total": 1,
+ "llm_attempted": 1,
+ "llm_applied": 1,
+ "fallback_used": 0,
+ "tool_gate_blocked": 0,
+ "fallback_rate": 0.0,
+ "gate_block_rate": 0.0
+ },
+ "contract_usage_overview": {
+ "total": 1,
+ "llm_attempted": 1,
+ "llm_applied": 0,
+ "fallback_used": 0,
+ "tool_gate_blocked": 0,
+ "fallback_rate": 0.0,
+ "gate_block_rate": 0.0
+ },
+ "list_documents_by_counterparty": {
+ "total": 1,
+ "llm_attempted": 1,
+ "llm_applied": 1,
+ "fallback_used": 0,
+ "tool_gate_blocked": 0,
+ "fallback_rate": 0.0,
+ "gate_block_rate": 0.0
+ },
+ "bank_operations_by_contract": {
+ "total": 1,
+ "llm_attempted": 1,
+ "llm_applied": 1,
+ "fallback_used": 0,
+ "tool_gate_blocked": 0,
+ "fallback_rate": 0.0,
+ "gate_block_rate": 0.0
+ }
+ }
+ }
+ },
+ "rows": [
+ {
+ "index": 1,
+ "id": "UX001",
+ "question": "какие клиенты самые доходные",
+ "session": null,
+ "session_id": "asst-2026-04-08_Address_Step5_UX_Smoke_v1-ux001",
+ "status_code": 200,
+ "ok": true,
+ "elapsed_ms": 5771,
+ "reply_type": "factual",
+ "trace_id": "address-iqKYeNHeoa",
+ "assistant_reply": "Топ-5 заказчиков по сумме поступлений:\nСобран профиль поступлений от заказчиков (bank-doc value aggregate).\nСтрок источника: 20.\nУникальных контрагентов: 5.\n1. Группа | сумма: 12224925 | операций: 16 | средний чек: 764057.81 | макс: 3248300\n2. НОРТОН | сумма: 2254115 | операций: 1 | средний чек: 2254115.00 | макс: 2254115\n3. МНН | сумма: 1699940 | операций: 1 | средний чек: 1699940.00 | макс: 1699940\n4. СервисКонсалт, ООО | сумма: 1200000 | операций: 1 | средний чек: 1200000.00 | макс: 1200000\n5. Чепурнов П.Д. | сумма: 890660 | операций: 1 | средний чек: 890660.00 | макс: 890660",
+ "assistant_reply_first_line": "Топ-5 заказчиков по сумме поступлений:",
+ "expected_intent": null,
+ "actual_intent": "customer_revenue_and_payments",
+ "intent_match": true,
+ "expected_mode": "address_query",
+ "actual_mode": "address_query",
+ "mode_match": true,
+ "expected_reply_type": null,
+ "reply_match": true,
+ "semantic_pass": true,
+ "route_pass": true,
+ "route_health": "ok_or_factual",
+ "strict_policy": "route",
+ "strict_pass": true,
+ "selected_recipe": "address_customer_revenue_and_payments_v1",
+ "missing_required_filters": [],
+ "match_failure_stage": "none",
+ "match_failure_reason": null,
+ "rows_fetched": 20,
+ "rows_matched": 20,
+ "mcp_call_status": "matched_non_empty",
+ "limited_reason_category": null,
+ "llm_decomposition_attempted": true,
+ "llm_decomposition_applied": false,
+ "llm_decomposition_reason": "normalized_fragment_same",
+ "llm_canonical_candidate_detected": true,
+ "fallback_rule_hit": null,
+ "tool_gate_decision": "run_address_lane",
+ "tool_gate_reason": "address_mode_classifier_detected",
+ "predecompose_contract_intent": "customer_revenue_and_payments",
+ "predecompose_contract_aggregation_profile": "management_profile",
+ "predecompose_contract_period_scope": "unspecified",
+ "debug_payload": {
+ "trace_id": "address-iqKYeNHeoa",
+ "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",
+ "shape_not_detected",
+ "customer_revenue_and_payments_signal_detected"
+ ],
+ "why_included_summary": [],
+ "selection_reason_summary": []
+ },
+ "dropped_intent_segments": [],
+ "detected_mode": "address_query",
+ "detected_mode_confidence": "high",
+ "query_shape": "UNKNOWN",
+ "query_shape_confidence": "low",
+ "detected_intent": "customer_revenue_and_payments",
+ "detected_intent_confidence": "high",
+ "extracted_filters": {
+ "sort": "period_desc",
+ "period_to": "2026-04-08"
+ },
+ "missing_required_filters": [],
+ "selected_recipe": "address_customer_revenue_and_payments_v1",
+ "mcp_call_status_legacy": "matched_non_empty",
+ "account_scope_mode": "preferred",
+ "account_scope_fallback_applied": false,
+ "anchor_type": "unknown",
+ "anchor_value_raw": null,
+ "anchor_value_resolved": null,
+ "resolver_confidence": "low",
+ "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": 20,
+ "rows_after_recipe_filter": 20,
+ "rows_materialized": 20,
+ "rows_matched": 20,
+ "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",
+ "execution_lane": "address_query",
+ "llm_decomposition_applied": false,
+ "llm_decomposition_attempted": true,
+ "llm_provider_used": "local",
+ "llm_decomposition_trace_id": "l5sj07jfmYNrU7",
+ "llm_decomposition_effective_message": "какие клиенты самые доходные",
+ "llm_decomposition_reason": "normalized_fragment_same",
+ "llm_canonical_candidate_detected": true,
+ "llm_predecompose_contract": {
+ "schema_version": "address_llm_predecompose_contract_v1",
+ "source_message": "какие клиенты самые доходные",
+ "canonical_message": "какие клиенты самые доходные",
+ "mode": "address_query",
+ "mode_confidence": "high",
+ "query_shape": "UNKNOWN",
+ "query_shape_confidence": "low",
+ "intent": "customer_revenue_and_payments",
+ "intent_confidence": "high",
+ "entities": {
+ "account": null,
+ "counterparty": null,
+ "contract": null,
+ "document_type": null,
+ "document_ref": null,
+ "organization": null
+ },
+ "period": {
+ "scope": "unspecified",
+ "period_from": null,
+ "period_to": "2026-04-08",
+ "as_of_date": null,
+ "has_explicit_period": true
+ },
+ "aggregation_profile": "management_profile"
+ },
+ "fallback_rule_hit": null,
+ "sanitized_user_message": "какие клиенты самые доходные",
+ "tool_gate_decision": "run_address_lane",
+ "tool_gate_reason": "address_mode_classifier_detected",
+ "answer_structure_v11": null,
+ "investigation_state_snapshot": null,
+ "normalized": null,
+ "normalizer_output": {
+ "trace_id": "l5sj07jfmYNrU7",
+ "prompt_version": "normalizer_v2_0_2",
+ "applied": false,
+ "effective_message": "какие клиенты самые доходные"
+ }
+ },
+ "error_code": null,
+ "error_message": null
+ },
+ {
+ "index": 2,
+ "id": "UX002",
+ "question": "скока поставщиков в базе",
+ "session": null,
+ "session_id": "asst-2026-04-08_Address_Step5_UX_Smoke_v1-ux002",
+ "status_code": 200,
+ "ok": true,
+ "elapsed_ms": 5024,
+ "reply_type": "factual",
+ "trace_id": "address-jzDixCBTgb",
+ "assistant_reply": "Всего уникальных контрагентов в базе: 139.\nПрофиль контрагентов собран (catalog + bank-doc activity aggregate).\nСтрок агрегата: 5.\nВсего уникальных контрагентов в базе: 139.\nРоли контрагентов по активности:\n1. Заказчики (только customer-роль): 14.\n2. Поставщики (только supplier-роль): 79.\n3. Смешанные (и покупатель, и поставщик): 32.\n4. Активные контрагенты (union ролей): 125.\n5. Прочие/неактивные в выбранном окне: 14.",
+ "assistant_reply_first_line": "Всего уникальных контрагентов в базе: 139.",
+ "expected_intent": null,
+ "actual_intent": "counterparty_population_and_roles",
+ "intent_match": true,
+ "expected_mode": "address_query",
+ "actual_mode": "address_query",
+ "mode_match": true,
+ "expected_reply_type": null,
+ "reply_match": true,
+ "semantic_pass": true,
+ "route_pass": true,
+ "route_health": "ok_or_factual",
+ "strict_policy": "route",
+ "strict_pass": true,
+ "selected_recipe": "address_counterparty_population_roles_v1",
+ "missing_required_filters": [],
+ "match_failure_stage": "none",
+ "match_failure_reason": null,
+ "rows_fetched": 5,
+ "rows_matched": 5,
+ "mcp_call_status": "matched_non_empty",
+ "limited_reason_category": null,
+ "llm_decomposition_attempted": true,
+ "llm_decomposition_applied": true,
+ "llm_decomposition_reason": "normalized_fragment_applied",
+ "llm_canonical_candidate_detected": true,
+ "fallback_rule_hit": null,
+ "tool_gate_decision": "run_address_lane",
+ "tool_gate_reason": "address_mode_classifier_detected",
+ "predecompose_contract_intent": "counterparty_population_and_roles",
+ "predecompose_contract_aggregation_profile": "management_profile",
+ "predecompose_contract_period_scope": "unspecified",
+ "debug_payload": {
+ "trace_id": "address-jzDixCBTgb",
+ "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",
+ "counterparty_population_and_roles_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": "counterparty_population_and_roles",
+ "detected_intent_confidence": "high",
+ "extracted_filters": {
+ "sort": "period_desc",
+ "period_to": "2026-04-08"
+ },
+ "missing_required_filters": [],
+ "selected_recipe": "address_counterparty_population_roles_v1",
+ "mcp_call_status_legacy": "matched_non_empty",
+ "account_scope_mode": "preferred",
+ "account_scope_fallback_applied": false,
+ "anchor_type": "unknown",
+ "anchor_value_raw": null,
+ "anchor_value_resolved": null,
+ "resolver_confidence": "low",
+ "ambiguity_count": 0,
+ "match_failure_stage": "none",
+ "match_failure_reason": null,
+ "mcp_call_status": "matched_non_empty",
+ "rows_fetched": 5,
+ "raw_rows_received": 5,
+ "rows_after_account_scope": 5,
+ "rows_after_recipe_filter": 5,
+ "rows_materialized": 5,
+ "rows_matched": 5,
+ "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_SUMMARY",
+ "execution_lane": "address_query",
+ "llm_decomposition_applied": true,
+ "llm_decomposition_attempted": true,
+ "llm_provider_used": "local",
+ "llm_decomposition_trace_id": "OAQ5xM0snMX5hR",
+ "llm_decomposition_effective_message": "сколько контрагентов (поставщиков) в базе данных",
+ "llm_decomposition_reason": "normalized_fragment_applied",
+ "llm_canonical_candidate_detected": true,
+ "llm_predecompose_contract": {
+ "schema_version": "address_llm_predecompose_contract_v1",
+ "source_message": "скока поставщиков в базе",
+ "canonical_message": "сколько контрагентов (поставщиков) в базе данных",
+ "mode": "address_query",
+ "mode_confidence": "high",
+ "query_shape": "OBJECT_LOOKUP",
+ "query_shape_confidence": "medium",
+ "intent": "counterparty_population_and_roles",
+ "intent_confidence": "high",
+ "entities": {
+ "account": null,
+ "counterparty": null,
+ "contract": null,
+ "document_type": null,
+ "document_ref": null,
+ "organization": null
+ },
+ "period": {
+ "scope": "unspecified",
+ "period_from": null,
+ "period_to": "2026-04-08",
+ "as_of_date": null,
+ "has_explicit_period": true
+ },
+ "aggregation_profile": "management_profile"
+ },
+ "fallback_rule_hit": null,
+ "sanitized_user_message": "скока поставщиков в базе",
+ "tool_gate_decision": "run_address_lane",
+ "tool_gate_reason": "address_mode_classifier_detected",
+ "answer_structure_v11": null,
+ "investigation_state_snapshot": null,
+ "normalized": null,
+ "normalizer_output": {
+ "trace_id": "OAQ5xM0snMX5hR",
+ "prompt_version": "normalizer_v2_0_2",
+ "applied": true,
+ "effective_message": "сколько контрагентов (поставщиков) в базе данных"
+ }
+ },
+ "error_code": null,
+ "error_message": null
+ },
+ {
+ "index": 3,
+ "id": "UX003",
+ "question": "какие заказчики работали с нами в 2020 году",
+ "session": null,
+ "session_id": "asst-2026-04-08_Address_Step5_UX_Smoke_v1-ux003",
+ "status_code": 200,
+ "ok": true,
+ "elapsed_ms": 6088,
+ "reply_type": "factual",
+ "trace_id": "address-zBwJ0hDm4p",
+ "assistant_reply": "Активные заказчики в 2020 году: 13.\nСобран профиль активности заказчиков (bank-doc activity aggregate).\nСтрок агрегата: 13.\n1. Группа | операций: 13 | последняя активность: 2020-12-30T12:00:00Z\n2. СБЕРБАНК, ПАО | операций: 9 | последняя активность: 2020-03-02T12:00:04Z\n3. ЭталонМебель | операций: 4 | последняя активность: 2020-04-27T12:00:00Z\n4. Гамма-мебель, ООО | операций: 3 | последняя активность: 2020-08-11T13:15:30Z\n5. ЗАО Ремонтно-строительная фирма «Ремстройсервис» | операций: 3 | последняя активность: 2020-06-03T00:00:02Z\n6. НОРТОН | операций: 3 | последняя активность: 2020-03-19T12:00:00Z\n8. ИП Калинин Н.М. | операций: 2 | последняя активность: 2020-03-02T12:00:03Z\n9. МНН | операций: 1 | последняя активность: 2020-08-11T13:16:37Z\n10. «Олимпстрой» | операций: 1 | последняя активность: 2020-07-13T12:00:00Z\n11. ПрофТренд,ООО | операций: 1 | последняя активность: 2020-05-15T12:00:02Z\n12. Лайсвуд, ООО | операций: 1 | последняя активность: 2020-03-02T12:00:02Z\n13. Смарт | операций: 1 | последняя активность: 2020-02-07T12:00:03Z",
+ "assistant_reply_first_line": "Активные заказчики в 2020 году: 13.",
+ "expected_intent": null,
+ "actual_intent": "counterparty_activity_lifecycle",
+ "intent_match": true,
+ "expected_mode": "address_query",
+ "actual_mode": "address_query",
+ "mode_match": true,
+ "expected_reply_type": null,
+ "reply_match": true,
+ "semantic_pass": true,
+ "route_pass": true,
+ "route_health": "ok_or_factual",
+ "strict_policy": "route",
+ "strict_pass": true,
+ "selected_recipe": "address_counterparty_activity_lifecycle_v1",
+ "missing_required_filters": [],
+ "match_failure_stage": "none",
+ "match_failure_reason": null,
+ "rows_fetched": 13,
+ "rows_matched": 13,
+ "mcp_call_status": "matched_non_empty",
+ "limited_reason_category": null,
+ "llm_decomposition_attempted": true,
+ "llm_decomposition_applied": true,
+ "llm_decomposition_reason": "normalized_fragment_applied",
+ "llm_canonical_candidate_detected": true,
+ "fallback_rule_hit": null,
+ "tool_gate_decision": "run_address_lane",
+ "tool_gate_reason": "address_mode_classifier_detected",
+ "predecompose_contract_intent": "counterparty_activity_lifecycle",
+ "predecompose_contract_aggregation_profile": "management_profile",
+ "predecompose_contract_period_scope": "year",
+ "debug_payload": {
+ "trace_id": "address-zBwJ0hDm4p",
+ "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",
+ "counterparty_activity_lifecycle_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": "counterparty_activity_lifecycle",
+ "detected_intent_confidence": "high",
+ "extracted_filters": {
+ "sort": "period_desc",
+ "period_from": "2020-01-01",
+ "period_to": "2020-12-31"
+ },
+ "missing_required_filters": [],
+ "selected_recipe": "address_counterparty_activity_lifecycle_v1",
+ "mcp_call_status_legacy": "matched_non_empty",
+ "account_scope_mode": "preferred",
+ "account_scope_fallback_applied": false,
+ "anchor_type": "unknown",
+ "anchor_value_raw": null,
+ "anchor_value_resolved": null,
+ "resolver_confidence": "low",
+ "ambiguity_count": 0,
+ "match_failure_stage": "none",
+ "match_failure_reason": null,
+ "mcp_call_status": "matched_non_empty",
+ "rows_fetched": 13,
+ "raw_rows_received": 13,
+ "rows_after_account_scope": 13,
+ "rows_after_recipe_filter": 13,
+ "rows_materialized": 13,
+ "rows_matched": 13,
+ "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",
+ "execution_lane": "address_query",
+ "llm_decomposition_applied": true,
+ "llm_decomposition_attempted": true,
+ "llm_provider_used": "local",
+ "llm_decomposition_trace_id": "PUlDeGcgeSZOQ_",
+ "llm_decomposition_effective_message": "какие контрагенты (заказчики) имели взаимодействия с нашей организацией в 2020 году?",
+ "llm_decomposition_reason": "normalized_fragment_applied",
+ "llm_canonical_candidate_detected": true,
+ "llm_predecompose_contract": {
+ "schema_version": "address_llm_predecompose_contract_v1",
+ "source_message": "какие заказчики работали с нами в 2020 году",
+ "canonical_message": "какие контрагенты (заказчики) имели взаимодействия с нашей организацией в 2020 году?",
+ "mode": "address_query",
+ "mode_confidence": "high",
+ "query_shape": "OBJECT_LOOKUP",
+ "query_shape_confidence": "medium",
+ "intent": "counterparty_activity_lifecycle",
+ "intent_confidence": "high",
+ "entities": {
+ "account": null,
+ "counterparty": null,
+ "contract": null,
+ "document_type": null,
+ "document_ref": null,
+ "organization": null
+ },
+ "period": {
+ "scope": "year",
+ "period_from": "2020-01-01",
+ "period_to": "2020-12-31",
+ "as_of_date": null,
+ "has_explicit_period": true
+ },
+ "aggregation_profile": "management_profile"
+ },
+ "fallback_rule_hit": null,
+ "sanitized_user_message": "какие заказчики работали с нами в 2020 году",
+ "tool_gate_decision": "run_address_lane",
+ "tool_gate_reason": "address_mode_classifier_detected",
+ "answer_structure_v11": null,
+ "investigation_state_snapshot": null,
+ "normalized": null,
+ "normalizer_output": {
+ "trace_id": "PUlDeGcgeSZOQ_",
+ "prompt_version": "normalizer_v2_0_2",
+ "applied": true,
+ "effective_message": "какие контрагенты (заказчики) имели взаимодействия с нашей организацией в 2020 году?"
+ }
+ },
+ "error_code": null,
+ "error_message": null
+ },
+ {
+ "index": 4,
+ "id": "UX004",
+ "question": "какие договоры давно не использовались",
+ "session": null,
+ "session_id": "asst-2026-04-08_Address_Step5_UX_Smoke_v1-ux004",
+ "status_code": 200,
+ "ok": true,
+ "elapsed_ms": 5089,
+ "reply_type": "factual",
+ "trace_id": "address-IWeXD3-LmW",
+ "assistant_reply": "Использованных договоров: 291 из 394 (73.9%).\nПрофиль договорной базы собран (catalog + usage aggregate).\nСтрок агрегата: 2.\nВсего договоров в базе: 394.\nИспользованных договоров (есть factual связь с операциями): 291.\nНеиспользуемых договоров: 103.\nДоля используемых договоров: 73.9%.",
+ "assistant_reply_first_line": "Использованных договоров: 291 из 394 (73.9%).",
+ "expected_intent": null,
+ "actual_intent": "contract_usage_overview",
+ "intent_match": true,
+ "expected_mode": "address_query",
+ "actual_mode": "address_query",
+ "mode_match": true,
+ "expected_reply_type": null,
+ "reply_match": true,
+ "semantic_pass": true,
+ "route_pass": true,
+ "route_health": "ok_or_factual",
+ "strict_policy": "route",
+ "strict_pass": true,
+ "selected_recipe": "address_contract_usage_overview_v1",
+ "missing_required_filters": [],
+ "match_failure_stage": "none",
+ "match_failure_reason": null,
+ "rows_fetched": 2,
+ "rows_matched": 2,
+ "mcp_call_status": "matched_non_empty",
+ "limited_reason_category": null,
+ "llm_decomposition_attempted": true,
+ "llm_decomposition_applied": false,
+ "llm_decomposition_reason": "normalized_fragment_same",
+ "llm_canonical_candidate_detected": true,
+ "fallback_rule_hit": null,
+ "tool_gate_decision": "run_address_lane",
+ "tool_gate_reason": "address_mode_classifier_detected",
+ "predecompose_contract_intent": "contract_usage_overview",
+ "predecompose_contract_aggregation_profile": "management_profile",
+ "predecompose_contract_period_scope": "unspecified",
+ "debug_payload": {
+ "trace_id": "address-IWeXD3-LmW",
+ "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",
+ "contract_usage_overview_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": "contract_usage_overview",
+ "detected_intent_confidence": "high",
+ "extracted_filters": {
+ "sort": "period_desc",
+ "period_to": "2026-04-08"
+ },
+ "missing_required_filters": [],
+ "selected_recipe": "address_contract_usage_overview_v1",
+ "mcp_call_status_legacy": "matched_non_empty",
+ "account_scope_mode": "preferred",
+ "account_scope_fallback_applied": false,
+ "anchor_type": "unknown",
+ "anchor_value_raw": null,
+ "anchor_value_resolved": null,
+ "resolver_confidence": "low",
+ "ambiguity_count": 0,
+ "match_failure_stage": "none",
+ "match_failure_reason": null,
+ "mcp_call_status": "matched_non_empty",
+ "rows_fetched": 2,
+ "raw_rows_received": 2,
+ "rows_after_account_scope": 2,
+ "rows_after_recipe_filter": 2,
+ "rows_materialized": 2,
+ "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_SUMMARY",
+ "execution_lane": "address_query",
+ "llm_decomposition_applied": false,
+ "llm_decomposition_attempted": true,
+ "llm_provider_used": "local",
+ "llm_decomposition_trace_id": "pLVzOnGRefi7lY",
+ "llm_decomposition_effective_message": "какие договоры давно не использовались",
+ "llm_decomposition_reason": "normalized_fragment_same",
+ "llm_canonical_candidate_detected": true,
+ "llm_predecompose_contract": {
+ "schema_version": "address_llm_predecompose_contract_v1",
+ "source_message": "какие договоры давно не использовались",
+ "canonical_message": "какие договоры давно не использовались",
+ "mode": "address_query",
+ "mode_confidence": "high",
+ "query_shape": "OBJECT_LOOKUP",
+ "query_shape_confidence": "medium",
+ "intent": "contract_usage_overview",
+ "intent_confidence": "high",
+ "entities": {
+ "account": null,
+ "counterparty": null,
+ "contract": null,
+ "document_type": null,
+ "document_ref": null,
+ "organization": null
+ },
+ "period": {
+ "scope": "unspecified",
+ "period_from": null,
+ "period_to": "2026-04-08",
+ "as_of_date": null,
+ "has_explicit_period": true
+ },
+ "aggregation_profile": "management_profile"
+ },
+ "fallback_rule_hit": null,
+ "sanitized_user_message": "какие договоры давно не использовались",
+ "tool_gate_decision": "run_address_lane",
+ "tool_gate_reason": "address_mode_classifier_detected",
+ "answer_structure_v11": null,
+ "investigation_state_snapshot": null,
+ "normalized": null,
+ "normalizer_output": {
+ "trace_id": "pLVzOnGRefi7lY",
+ "prompt_version": "normalizer_v2_0_2",
+ "applied": false,
+ "effective_message": "какие договоры давно не использовались"
+ }
+ },
+ "error_code": null,
+ "error_message": null
+ },
+ {
+ "index": 5,
+ "id": "UX005",
+ "question": "покажи документы по нортону",
+ "session": null,
+ "session_id": "asst-2026-04-08_Address_Step5_UX_Smoke_v1-ux005",
+ "status_code": 200,
+ "ok": true,
+ "elapsed_ms": 6404,
+ "reply_type": "factual",
+ "trace_id": "address-pxYCCCW3gY",
+ "assistant_reply": "Найдено документов по контрагенту: 12.\nСобран список документов по контрагенту (live address lane).\nСтрок отобрано: 12.\n1. 2022-01-20T12:00:05Z | Поступление на расчетный счет 00000000003 от 20.01.2022 12:00:05 | 0 / 0 | 2254115 | аналитика: НОРТОН; договор Н-04 от 02.07.2018\n2. 2020-03-19T12:00:00Z | Поступление на расчетный счет 00000000018 от 19.03.2020 12:00:00 | 0 / 0 | 2500000 | аналитика: НОРТОН; 0\n3. 2020-03-02T00:00:00Z | Поступление на расчетный счет 00000000014 от 02.03.2020 0:00:00 | 0 / 0 | 1920000 | аналитика: НОРТОН; договор Н-04 от 02.07.2018\n4. 2020-02-21T12:00:02Z | Поступление на расчетный счет 00000000011 от 21.02.2020 12:00:02 | 0 / 0 | 400000 | аналитика: НОРТОН; договор Н-04 от 02.07.2018\n5. 2019-12-24T14:52:21Z | Поступление на расчетный счет 00000000071 от 24.12.2019 14:52:21 | 0 / 0 | 400000 | аналитика: НОРТОН; договор Н-04 от 02.07.2018\n6. 2019-11-20T12:00:00Z | Поступление на расчетный счет 00000000066 от 20.11.2019 12:00:00 | 0 / 0 | 155000 | аналитика: НОРТОН; договор Н-04 от 02.07.2018\n7. 2019-11-18T12:00:00Z | Поступление на расчетный счет 00000000065 от 18.11.2019 12:00:00 | 0 / 0 | 10000 | аналитика: НОРТОН; договор Н-04 от 02.07.2018\n8. 2019-11-14T00:00:00Z | Поступление на расчетный счет 00000000064 от 14.11.2019 0:00:00 | 0 / 0 | 250000 | аналитика: НОРТОН; договор Н-04 от 02.07.2018\n9. 2019-10-22T12:00:00Z | Поступление на расчетный счет 00000000056 от 22.10.2019 12:00:00 | 0 / 0 | 765000 | аналитика: НОРТОН; договор Н-04 от 02.07.2018\n10. 2019-10-08T12:00:00Z | Поступление на расчетный счет 00000000051 от 08.10.2019 12:00:00 | 0 / 0 | 289101 | аналитика: НОРТОН; договор Н-04 от 02.07.2018\n11. 2019-08-13T12:00:00Z | Поступление на расчетный счет 00000000044 от 13.08.2019 12:00:00 | 0 / 0 | 1250000 | аналитика: НОРТОН; договор Н-04 от 02.07.2018\n12. 2019-07-26T12:00:03Z | Поступление на расчетный счет 00000000038 от 26.07.2019 12:00:03 | 0 / 0 | 2300000 | аналитика: НОРТОН; договор Н-04 от 02.07.2018",
+ "assistant_reply_first_line": "Найдено документов по контрагенту: 12.",
+ "expected_intent": null,
+ "actual_intent": "list_documents_by_counterparty",
+ "intent_match": true,
+ "expected_mode": "address_query",
+ "actual_mode": "address_query",
+ "mode_match": true,
+ "expected_reply_type": null,
+ "reply_match": true,
+ "semantic_pass": true,
+ "route_pass": true,
+ "route_health": "ok_or_factual",
+ "strict_policy": "route",
+ "strict_pass": true,
+ "selected_recipe": "address_documents_by_counterparty_v1",
+ "missing_required_filters": [],
+ "match_failure_stage": "none",
+ "match_failure_reason": null,
+ "rows_fetched": 1000,
+ "rows_matched": 12,
+ "mcp_call_status": "matched_non_empty",
+ "limited_reason_category": null,
+ "llm_decomposition_attempted": true,
+ "llm_decomposition_applied": true,
+ "llm_decomposition_reason": "normalized_fragment_applied",
+ "llm_canonical_candidate_detected": true,
+ "fallback_rule_hit": null,
+ "tool_gate_decision": "run_address_lane",
+ "tool_gate_reason": "address_mode_classifier_detected",
+ "predecompose_contract_intent": "list_documents_by_counterparty",
+ "predecompose_contract_aggregation_profile": "list_lookup",
+ "predecompose_contract_period_scope": "unspecified",
+ "debug_payload": {
+ "trace_id": "address-pxYCCCW3gY",
+ "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",
+ "document_list_signal_detected",
+ "documents_by_counterparty_signal_detected"
+ ],
+ "why_included_summary": [],
+ "selection_reason_summary": []
+ },
+ "dropped_intent_segments": [],
+ "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": "high",
+ "ambiguity_count": 0,
+ "match_failure_stage": "none",
+ "match_failure_reason": null,
+ "mcp_call_status": "matched_non_empty",
+ "rows_fetched": 1000,
+ "raw_rows_received": 1000,
+ "rows_after_account_scope": 124,
+ "rows_after_recipe_filter": 12,
+ "rows_materialized": 124,
+ "rows_matched": 12,
+ "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",
+ "execution_lane": "address_query",
+ "llm_decomposition_applied": true,
+ "llm_decomposition_attempted": true,
+ "llm_provider_used": "local",
+ "llm_decomposition_trace_id": "EDUSbTyaDA1F7j",
+ "llm_decomposition_effective_message": "показать документы контрагента Нортон",
+ "llm_decomposition_reason": "normalized_fragment_applied",
+ "llm_canonical_candidate_detected": true,
+ "llm_predecompose_contract": {
+ "schema_version": "address_llm_predecompose_contract_v1",
+ "source_message": "покажи документы по нортону",
+ "canonical_message": "показать документы контрагента Нортон",
+ "mode": "address_query",
+ "mode_confidence": "high",
+ "query_shape": "DOCUMENT_LIST",
+ "query_shape_confidence": "medium",
+ "intent": "list_documents_by_counterparty",
+ "intent_confidence": "medium",
+ "entities": {
+ "account": null,
+ "counterparty": "Нортон",
+ "contract": null,
+ "document_type": null,
+ "document_ref": null,
+ "organization": null
+ },
+ "period": {
+ "scope": "unspecified",
+ "period_from": null,
+ "period_to": null,
+ "as_of_date": null,
+ "has_explicit_period": false
+ },
+ "aggregation_profile": "list_lookup"
+ },
+ "fallback_rule_hit": null,
+ "sanitized_user_message": "покажи документы по нортону",
+ "tool_gate_decision": "run_address_lane",
+ "tool_gate_reason": "address_mode_classifier_detected",
+ "answer_structure_v11": null,
+ "investigation_state_snapshot": null,
+ "normalized": null,
+ "normalizer_output": {
+ "trace_id": "EDUSbTyaDA1F7j",
+ "prompt_version": "normalizer_v2_0_2",
+ "applied": true,
+ "effective_message": "показать документы контрагента Нортон"
+ }
+ },
+ "error_code": null,
+ "error_message": null
+ },
+ {
+ "index": 6,
+ "id": "UX006",
+ "question": "покажи операции по договору 19/15",
+ "session": null,
+ "session_id": "asst-2026-04-08_Address_Step5_UX_Smoke_v1-ux006",
+ "status_code": 200,
+ "ok": true,
+ "elapsed_ms": 4997,
+ "reply_type": "factual",
+ "trace_id": "address-kSGTfQJEXa",
+ "assistant_reply": "Найдено банковских операций по договору: 1.\nСобран список банковских операций по договору (live address lane).\nСтрок отобрано: 1.\n1. 2020-10-15T13:34:19Z | Списание с расчетного счета 00000000245 от 15.10.2020 13:34:19 | 66.01 / 51 | 134206.53",
+ "assistant_reply_first_line": "Найдено банковских операций по договору: 1.",
+ "expected_intent": null,
+ "actual_intent": "bank_operations_by_contract",
+ "intent_match": true,
+ "expected_mode": "address_query",
+ "actual_mode": "address_query",
+ "mode_match": true,
+ "expected_reply_type": null,
+ "reply_match": true,
+ "semantic_pass": true,
+ "route_pass": true,
+ "route_health": "ok_or_factual",
+ "strict_policy": "route",
+ "strict_pass": true,
+ "selected_recipe": "address_bank_operations_by_contract_v1",
+ "missing_required_filters": [],
+ "match_failure_stage": "none",
+ "match_failure_reason": null,
+ "rows_fetched": 1000,
+ "rows_matched": 1,
+ "mcp_call_status": "matched_non_empty",
+ "limited_reason_category": null,
+ "llm_decomposition_attempted": true,
+ "llm_decomposition_applied": true,
+ "llm_decomposition_reason": "normalized_fragment_applied",
+ "llm_canonical_candidate_detected": true,
+ "fallback_rule_hit": null,
+ "tool_gate_decision": "run_address_lane",
+ "tool_gate_reason": "address_mode_classifier_detected",
+ "predecompose_contract_intent": "bank_operations_by_contract",
+ "predecompose_contract_aggregation_profile": "list_lookup",
+ "predecompose_contract_period_scope": "unspecified",
+ "debug_payload": {
+ "trace_id": "address-kSGTfQJEXa",
+ "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_contract_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_contract",
+ "detected_intent_confidence": "medium",
+ "extracted_filters": {
+ "sort": "period_desc",
+ "limit": 20,
+ "contract": "19/15"
+ },
+ "missing_required_filters": [],
+ "selected_recipe": "address_bank_operations_by_contract_v1",
+ "mcp_call_status_legacy": "matched_non_empty",
+ "account_scope_mode": "preferred",
+ "account_scope_fallback_applied": false,
+ "anchor_type": "contract",
+ "anchor_value_raw": "19/15",
+ "anchor_value_resolved": "19/15",
+ "resolver_confidence": "medium",
+ "ambiguity_count": 0,
+ "match_failure_stage": "none",
+ "match_failure_reason": null,
+ "mcp_call_status": "matched_non_empty",
+ "rows_fetched": 1000,
+ "raw_rows_received": 1000,
+ "rows_after_account_scope": 341,
+ "rows_after_recipe_filter": 1,
+ "rows_materialized": 341,
+ "rows_matched": 1,
+ "raw_row_keys_sample": [
+ "Период",
+ "Регистратор",
+ "СчетДт",
+ "СчетКт",
+ "Сумма",
+ "Period",
+ "Registrator",
+ "AccountDt",
+ "AccountKt",
+ "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",
+ "execution_lane": "address_query",
+ "llm_decomposition_applied": true,
+ "llm_decomposition_attempted": true,
+ "llm_provider_used": "local",
+ "llm_decomposition_trace_id": "0yb6ENCkAUD5bW",
+ "llm_decomposition_effective_message": "показать операции по договору 19/15",
+ "llm_decomposition_reason": "normalized_fragment_applied",
+ "llm_canonical_candidate_detected": true,
+ "llm_predecompose_contract": {
+ "schema_version": "address_llm_predecompose_contract_v1",
+ "source_message": "покажи операции по договору 19/15",
+ "canonical_message": "показать операции по договору 19/15",
+ "mode": "address_query",
+ "mode_confidence": "high",
+ "query_shape": "OBJECT_LOOKUP",
+ "query_shape_confidence": "medium",
+ "intent": "bank_operations_by_contract",
+ "intent_confidence": "medium",
+ "entities": {
+ "account": null,
+ "counterparty": null,
+ "contract": "19/15",
+ "document_type": null,
+ "document_ref": null,
+ "organization": null
+ },
+ "period": {
+ "scope": "unspecified",
+ "period_from": null,
+ "period_to": null,
+ "as_of_date": null,
+ "has_explicit_period": false
+ },
+ "aggregation_profile": "list_lookup"
+ },
+ "fallback_rule_hit": null,
+ "sanitized_user_message": "покажи операции по договору 19/15",
+ "tool_gate_decision": "run_address_lane",
+ "tool_gate_reason": "address_mode_classifier_detected",
+ "answer_structure_v11": null,
+ "investigation_state_snapshot": null,
+ "normalized": null,
+ "normalizer_output": {
+ "trace_id": "0yb6ENCkAUD5bW",
+ "prompt_version": "normalizer_v2_0_2",
+ "applied": true,
+ "effective_message": "показать операции по договору 19/15"
+ }
+ },
+ "error_code": null,
+ "error_message": null
+ }
+ ]
+}
diff --git a/docs/ADDRESS/runs/2026-04-08_Address_Step5_UX_Smoke_v1/response_audit.md b/docs/ADDRESS/runs/2026-04-08_Address_Step5_UX_Smoke_v1/response_audit.md
new file mode 100644
index 0000000..05deedb
--- /dev/null
+++ b/docs/ADDRESS/runs/2026-04-08_Address_Step5_UX_Smoke_v1/response_audit.md
@@ -0,0 +1,10 @@
+# Response Audit: 2026-04-08_Address_Step5_UX_Smoke_v1
+
+| id | strict | route_health | reply_type | intent | limited_reason | question | assistant_first_line |
+|---|---|---|---|---|---|---|---|
+| UX001 | True | ok_or_factual | factual | customer_revenue_and_payments | None | какие клиенты самые доходные | Топ-5 заказчиков по сумме поступлений: |
+| UX002 | True | ok_or_factual | factual | counterparty_population_and_roles | None | скока поставщиков в базе | Всего уникальных контрагентов в базе: 139. |
+| UX003 | True | ok_or_factual | factual | counterparty_activity_lifecycle | None | какие заказчики работали с нами в 2020 году | Активные заказчики в 2020 году: 13. |
+| UX004 | True | ok_or_factual | factual | contract_usage_overview | None | какие договоры давно не использовались | Использованных договоров: 291 из 394 (73.9%). |
+| UX005 | True | ok_or_factual | factual | list_documents_by_counterparty | None | покажи документы по нортону | Найдено документов по контрагенту: 12. |
+| UX006 | True | ok_or_factual | factual | bank_operations_by_contract | None | покажи операции по договору 19/15 | Найдено банковских операций по договору: 1. |
diff --git a/docs/ADDRESS/runs/2026-04-08_Address_Step5_UX_Smoke_v1/run_summary.json b/docs/ADDRESS/runs/2026-04-08_Address_Step5_UX_Smoke_v1/run_summary.json
new file mode 100644
index 0000000..f8442b7
--- /dev/null
+++ b/docs/ADDRESS/runs/2026-04-08_Address_Step5_UX_Smoke_v1/run_summary.json
@@ -0,0 +1,126 @@
+{
+ "run_id": "2026-04-08_Address_Step5_UX_Smoke_v1",
+ "generated_at": "2026-04-08T13:53:34",
+ "source_questions_file": "X:\\1C\\NDC_1C\\docs\\ADDRESS\\question_sets\\step5_ux_smoke_2026-04-08.json",
+ "backend_url": "http://127.0.0.1:8787/api/assistant/message",
+ "llm_provider": "local",
+ "llm_model": "qwen2.5-14b-instruct-1m",
+ "llm_base_url": "http://127.0.0.1:1234",
+ "strict_policy": "route",
+ "totals": {
+ "questions_total": 6,
+ "ok_200_count": 6,
+ "semantic_pass_count": 6,
+ "semantic_pass_rate": 1.0,
+ "route_pass_count": 6,
+ "route_pass_rate": 1.0,
+ "strict_pass_count": 6,
+ "strict_pass_rate": 1.0,
+ "factual_count": 6,
+ "partial_coverage_count": 0,
+ "clarification_required_count": 0,
+ "http_error_count": 0,
+ "llm_decomposition_attempted_count": 6,
+ "llm_decomposition_applied_count": 4,
+ "llm_fallback_count": 0,
+ "llm_fallback_rate": 0.0,
+ "tool_gate_blocked_count": 0,
+ "tool_gate_blocked_rate": 0.0,
+ "avg_elapsed_ms": 5562.2
+ },
+ "distributions": {
+ "reply_type": {
+ "factual": 6
+ },
+ "actual_intent": {
+ "customer_revenue_and_payments": 1,
+ "counterparty_population_and_roles": 1,
+ "counterparty_activity_lifecycle": 1,
+ "contract_usage_overview": 1,
+ "list_documents_by_counterparty": 1,
+ "bank_operations_by_contract": 1
+ },
+ "actual_mode": {
+ "address_query": 6
+ },
+ "mcp_call_status": {
+ "matched_non_empty": 6
+ },
+ "limited_reason_category": {},
+ "route_health": {
+ "ok_or_factual": 6
+ },
+ "tool_gate_decision": {
+ "run_address_lane": 6
+ },
+ "tool_gate_reason": {
+ "address_mode_classifier_detected": 6
+ }
+ },
+ "address_llm_predecompose_metrics": {
+ "overall": {
+ "llm_attempted": 6,
+ "llm_applied": 4,
+ "fallback_used": 0,
+ "fallback_rate": 0.0,
+ "tool_gate_blocked": 0,
+ "gate_block_rate": 0.0
+ },
+ "by_intent": {
+ "customer_revenue_and_payments": {
+ "total": 1,
+ "llm_attempted": 1,
+ "llm_applied": 0,
+ "fallback_used": 0,
+ "tool_gate_blocked": 0,
+ "fallback_rate": 0.0,
+ "gate_block_rate": 0.0
+ },
+ "counterparty_population_and_roles": {
+ "total": 1,
+ "llm_attempted": 1,
+ "llm_applied": 1,
+ "fallback_used": 0,
+ "tool_gate_blocked": 0,
+ "fallback_rate": 0.0,
+ "gate_block_rate": 0.0
+ },
+ "counterparty_activity_lifecycle": {
+ "total": 1,
+ "llm_attempted": 1,
+ "llm_applied": 1,
+ "fallback_used": 0,
+ "tool_gate_blocked": 0,
+ "fallback_rate": 0.0,
+ "gate_block_rate": 0.0
+ },
+ "contract_usage_overview": {
+ "total": 1,
+ "llm_attempted": 1,
+ "llm_applied": 0,
+ "fallback_used": 0,
+ "tool_gate_blocked": 0,
+ "fallback_rate": 0.0,
+ "gate_block_rate": 0.0
+ },
+ "list_documents_by_counterparty": {
+ "total": 1,
+ "llm_attempted": 1,
+ "llm_applied": 1,
+ "fallback_used": 0,
+ "tool_gate_blocked": 0,
+ "fallback_rate": 0.0,
+ "gate_block_rate": 0.0
+ },
+ "bank_operations_by_contract": {
+ "total": 1,
+ "llm_attempted": 1,
+ "llm_applied": 1,
+ "fallback_used": 0,
+ "tool_gate_blocked": 0,
+ "fallback_rate": 0.0,
+ "gate_block_rate": 0.0
+ }
+ }
+ }
+}
diff --git a/docs/ADDRESS/runs/2026-04-08_Address_Step5_UX_Smoke_v2/README.md b/docs/ADDRESS/runs/2026-04-08_Address_Step5_UX_Smoke_v2/README.md
new file mode 100644
index 0000000..bd1f5de
--- /dev/null
+++ b/docs/ADDRESS/runs/2026-04-08_Address_Step5_UX_Smoke_v2/README.md
@@ -0,0 +1,33 @@
+# 2026-04-08_Address_Step5_UX_Smoke_v2
+
+Generated at: 2026-04-08T13:55:47
+Questions file: X:\1C\NDC_1C\docs\ADDRESS\question_sets\step5_ux_smoke_2026-04-08.json
+Backend URL: http://127.0.0.1:8787/api/assistant/message
+LLM: local / qwen2.5-14b-instruct-1m @ http://127.0.0.1:1234
+Strict policy: route
+
+## Totals
+- questions_total: 6
+- ok_200_count: 6
+- semantic_pass_count: 6
+- semantic_pass_rate: 1.0
+- route_pass_count: 6
+- route_pass_rate: 1.0
+- strict_pass_count: 6
+- strict_pass_rate: 1.0
+- factual_count: 6
+- partial_coverage_count: 0
+- clarification_required_count: 0
+- http_error_count: 0
+- llm_decomposition_attempted_count: 6
+- llm_decomposition_applied_count: 4
+- llm_fallback_count: 0
+- llm_fallback_rate: 0.0
+- tool_gate_blocked_count: 0
+- tool_gate_blocked_rate: 0.0
+- avg_elapsed_ms: 5551.5
+
+## Files
+- run_summary.json
+- full_live_results.json
+- failures_only.json
diff --git a/docs/ADDRESS/runs/2026-04-08_Address_Step5_UX_Smoke_v2/failures_only.json b/docs/ADDRESS/runs/2026-04-08_Address_Step5_UX_Smoke_v2/failures_only.json
new file mode 100644
index 0000000..fe51488
--- /dev/null
+++ b/docs/ADDRESS/runs/2026-04-08_Address_Step5_UX_Smoke_v2/failures_only.json
@@ -0,0 +1 @@
+[]
diff --git a/docs/ADDRESS/runs/2026-04-08_Address_Step5_UX_Smoke_v2/full_live_results.json b/docs/ADDRESS/runs/2026-04-08_Address_Step5_UX_Smoke_v2/full_live_results.json
new file mode 100644
index 0000000..3647058
--- /dev/null
+++ b/docs/ADDRESS/runs/2026-04-08_Address_Step5_UX_Smoke_v2/full_live_results.json
@@ -0,0 +1,1234 @@
+{
+ "run_id": "2026-04-08_Address_Step5_UX_Smoke_v2",
+ "generated_at": "2026-04-08T13:55:47",
+ "summary": {
+ "run_id": "2026-04-08_Address_Step5_UX_Smoke_v2",
+ "generated_at": "2026-04-08T13:55:47",
+ "source_questions_file": "X:\\1C\\NDC_1C\\docs\\ADDRESS\\question_sets\\step5_ux_smoke_2026-04-08.json",
+ "backend_url": "http://127.0.0.1:8787/api/assistant/message",
+ "llm_provider": "local",
+ "llm_model": "qwen2.5-14b-instruct-1m",
+ "llm_base_url": "http://127.0.0.1:1234",
+ "strict_policy": "route",
+ "totals": {
+ "questions_total": 6,
+ "ok_200_count": 6,
+ "semantic_pass_count": 6,
+ "semantic_pass_rate": 1.0,
+ "route_pass_count": 6,
+ "route_pass_rate": 1.0,
+ "strict_pass_count": 6,
+ "strict_pass_rate": 1.0,
+ "factual_count": 6,
+ "partial_coverage_count": 0,
+ "clarification_required_count": 0,
+ "http_error_count": 0,
+ "llm_decomposition_attempted_count": 6,
+ "llm_decomposition_applied_count": 4,
+ "llm_fallback_count": 0,
+ "llm_fallback_rate": 0.0,
+ "tool_gate_blocked_count": 0,
+ "tool_gate_blocked_rate": 0.0,
+ "avg_elapsed_ms": 5551.5
+ },
+ "distributions": {
+ "reply_type": {
+ "factual": 6
+ },
+ "actual_intent": {
+ "customer_revenue_and_payments": 1,
+ "counterparty_population_and_roles": 1,
+ "counterparty_activity_lifecycle": 1,
+ "contract_usage_overview": 1,
+ "list_documents_by_counterparty": 1,
+ "bank_operations_by_contract": 1
+ },
+ "actual_mode": {
+ "address_query": 6
+ },
+ "mcp_call_status": {
+ "matched_non_empty": 6
+ },
+ "limited_reason_category": {},
+ "route_health": {
+ "ok_or_factual": 6
+ },
+ "tool_gate_decision": {
+ "run_address_lane": 6
+ },
+ "tool_gate_reason": {
+ "address_mode_classifier_detected": 6
+ }
+ },
+ "address_llm_predecompose_metrics": {
+ "overall": {
+ "llm_attempted": 6,
+ "llm_applied": 4,
+ "fallback_used": 0,
+ "fallback_rate": 0.0,
+ "tool_gate_blocked": 0,
+ "gate_block_rate": 0.0
+ },
+ "by_intent": {
+ "customer_revenue_and_payments": {
+ "total": 1,
+ "llm_attempted": 1,
+ "llm_applied": 0,
+ "fallback_used": 0,
+ "tool_gate_blocked": 0,
+ "fallback_rate": 0.0,
+ "gate_block_rate": 0.0
+ },
+ "counterparty_population_and_roles": {
+ "total": 1,
+ "llm_attempted": 1,
+ "llm_applied": 1,
+ "fallback_used": 0,
+ "tool_gate_blocked": 0,
+ "fallback_rate": 0.0,
+ "gate_block_rate": 0.0
+ },
+ "counterparty_activity_lifecycle": {
+ "total": 1,
+ "llm_attempted": 1,
+ "llm_applied": 1,
+ "fallback_used": 0,
+ "tool_gate_blocked": 0,
+ "fallback_rate": 0.0,
+ "gate_block_rate": 0.0
+ },
+ "contract_usage_overview": {
+ "total": 1,
+ "llm_attempted": 1,
+ "llm_applied": 0,
+ "fallback_used": 0,
+ "tool_gate_blocked": 0,
+ "fallback_rate": 0.0,
+ "gate_block_rate": 0.0
+ },
+ "list_documents_by_counterparty": {
+ "total": 1,
+ "llm_attempted": 1,
+ "llm_applied": 1,
+ "fallback_used": 0,
+ "tool_gate_blocked": 0,
+ "fallback_rate": 0.0,
+ "gate_block_rate": 0.0
+ },
+ "bank_operations_by_contract": {
+ "total": 1,
+ "llm_attempted": 1,
+ "llm_applied": 1,
+ "fallback_used": 0,
+ "tool_gate_blocked": 0,
+ "fallback_rate": 0.0,
+ "gate_block_rate": 0.0
+ }
+ }
+ }
+ },
+ "rows": [
+ {
+ "index": 1,
+ "id": "UX001",
+ "question": "какие клиенты самые доходные",
+ "session": null,
+ "session_id": "asst-2026-04-08_Address_Step5_UX_Smoke_v2-ux001",
+ "status_code": 200,
+ "ok": true,
+ "elapsed_ms": 5688,
+ "reply_type": "factual",
+ "trace_id": "address-VOJFP7VFJQ",
+ "assistant_reply": "Топ-5 заказчиков по сумме поступлений:\nСобран профиль поступлений от заказчиков (bank-doc value aggregate).\nСтрок источника: 20.\nУникальных контрагентов: 5.\n1. Группа | сумма: 12224925 | операций: 16 | средний чек: 764057.81 | макс: 3248300\n2. НОРТОН | сумма: 2254115 | операций: 1 | средний чек: 2254115.00 | макс: 2254115\n3. МНН | сумма: 1699940 | операций: 1 | средний чек: 1699940.00 | макс: 1699940\n4. СервисКонсалт, ООО | сумма: 1200000 | операций: 1 | средний чек: 1200000.00 | макс: 1200000\n5. Чепурнов П.Д. | сумма: 890660 | операций: 1 | средний чек: 890660.00 | макс: 890660",
+ "assistant_reply_first_line": "Топ-5 заказчиков по сумме поступлений:",
+ "expected_intent": null,
+ "actual_intent": "customer_revenue_and_payments",
+ "intent_match": true,
+ "expected_mode": "address_query",
+ "actual_mode": "address_query",
+ "mode_match": true,
+ "expected_reply_type": null,
+ "reply_match": true,
+ "semantic_pass": true,
+ "route_pass": true,
+ "route_health": "ok_or_factual",
+ "strict_policy": "route",
+ "strict_pass": true,
+ "selected_recipe": "address_customer_revenue_and_payments_v1",
+ "missing_required_filters": [],
+ "match_failure_stage": "none",
+ "match_failure_reason": null,
+ "rows_fetched": 20,
+ "rows_matched": 20,
+ "mcp_call_status": "matched_non_empty",
+ "limited_reason_category": null,
+ "llm_decomposition_attempted": true,
+ "llm_decomposition_applied": false,
+ "llm_decomposition_reason": "normalized_fragment_same",
+ "llm_canonical_candidate_detected": true,
+ "fallback_rule_hit": null,
+ "tool_gate_decision": "run_address_lane",
+ "tool_gate_reason": "address_mode_classifier_detected",
+ "predecompose_contract_intent": "customer_revenue_and_payments",
+ "predecompose_contract_aggregation_profile": "management_profile",
+ "predecompose_contract_period_scope": "unspecified",
+ "debug_payload": {
+ "trace_id": "address-VOJFP7VFJQ",
+ "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",
+ "shape_not_detected",
+ "customer_revenue_and_payments_signal_detected"
+ ],
+ "why_included_summary": [],
+ "selection_reason_summary": []
+ },
+ "dropped_intent_segments": [],
+ "detected_mode": "address_query",
+ "detected_mode_confidence": "high",
+ "query_shape": "UNKNOWN",
+ "query_shape_confidence": "low",
+ "detected_intent": "customer_revenue_and_payments",
+ "detected_intent_confidence": "high",
+ "extracted_filters": {
+ "sort": "period_desc",
+ "period_to": "2026-04-08"
+ },
+ "missing_required_filters": [],
+ "selected_recipe": "address_customer_revenue_and_payments_v1",
+ "mcp_call_status_legacy": "matched_non_empty",
+ "account_scope_mode": "preferred",
+ "account_scope_fallback_applied": false,
+ "anchor_type": "unknown",
+ "anchor_value_raw": null,
+ "anchor_value_resolved": null,
+ "resolver_confidence": "low",
+ "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": 20,
+ "rows_after_recipe_filter": 20,
+ "rows_materialized": 20,
+ "rows_matched": 20,
+ "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",
+ "execution_lane": "address_query",
+ "llm_decomposition_applied": false,
+ "llm_decomposition_attempted": true,
+ "llm_provider_used": "local",
+ "llm_decomposition_trace_id": "pSVYFNNepzkDId",
+ "llm_decomposition_effective_message": "какие клиенты самые доходные",
+ "llm_decomposition_reason": "normalized_fragment_same",
+ "llm_canonical_candidate_detected": true,
+ "llm_predecompose_contract": {
+ "schema_version": "address_llm_predecompose_contract_v1",
+ "source_message": "какие клиенты самые доходные",
+ "canonical_message": "какие клиенты самые доходные",
+ "mode": "address_query",
+ "mode_confidence": "high",
+ "query_shape": "UNKNOWN",
+ "query_shape_confidence": "low",
+ "intent": "customer_revenue_and_payments",
+ "intent_confidence": "high",
+ "entities": {
+ "account": null,
+ "counterparty": null,
+ "contract": null,
+ "document_type": null,
+ "document_ref": null,
+ "organization": null
+ },
+ "period": {
+ "scope": "unspecified",
+ "period_from": null,
+ "period_to": "2026-04-08",
+ "as_of_date": null,
+ "has_explicit_period": true
+ },
+ "aggregation_profile": "management_profile"
+ },
+ "fallback_rule_hit": null,
+ "sanitized_user_message": "какие клиенты самые доходные",
+ "tool_gate_decision": "run_address_lane",
+ "tool_gate_reason": "address_mode_classifier_detected",
+ "answer_structure_v11": null,
+ "investigation_state_snapshot": null,
+ "normalized": null,
+ "normalizer_output": {
+ "trace_id": "pSVYFNNepzkDId",
+ "prompt_version": "normalizer_v2_0_2",
+ "applied": false,
+ "effective_message": "какие клиенты самые доходные"
+ }
+ },
+ "error_code": null,
+ "error_message": null
+ },
+ {
+ "index": 2,
+ "id": "UX002",
+ "question": "скока поставщиков в базе",
+ "session": null,
+ "session_id": "asst-2026-04-08_Address_Step5_UX_Smoke_v2-ux002",
+ "status_code": 200,
+ "ok": true,
+ "elapsed_ms": 5087,
+ "reply_type": "factual",
+ "trace_id": "address-bxW-jlDxYY",
+ "assistant_reply": "Поставщиков (только supplier-роль): 79.\nПрофиль контрагентов собран (catalog + bank-doc activity aggregate).\nСтрок агрегата: 5.\nПоставщиков (только supplier-роль): 79.",
+ "assistant_reply_first_line": "Поставщиков (только supplier-роль): 79.",
+ "expected_intent": null,
+ "actual_intent": "counterparty_population_and_roles",
+ "intent_match": true,
+ "expected_mode": "address_query",
+ "actual_mode": "address_query",
+ "mode_match": true,
+ "expected_reply_type": null,
+ "reply_match": true,
+ "semantic_pass": true,
+ "route_pass": true,
+ "route_health": "ok_or_factual",
+ "strict_policy": "route",
+ "strict_pass": true,
+ "selected_recipe": "address_counterparty_population_roles_v1",
+ "missing_required_filters": [],
+ "match_failure_stage": "none",
+ "match_failure_reason": null,
+ "rows_fetched": 5,
+ "rows_matched": 5,
+ "mcp_call_status": "matched_non_empty",
+ "limited_reason_category": null,
+ "llm_decomposition_attempted": true,
+ "llm_decomposition_applied": true,
+ "llm_decomposition_reason": "normalized_fragment_applied",
+ "llm_canonical_candidate_detected": true,
+ "fallback_rule_hit": null,
+ "tool_gate_decision": "run_address_lane",
+ "tool_gate_reason": "address_mode_classifier_detected",
+ "predecompose_contract_intent": "counterparty_population_and_roles",
+ "predecompose_contract_aggregation_profile": "management_profile",
+ "predecompose_contract_period_scope": "unspecified",
+ "debug_payload": {
+ "trace_id": "address-bxW-jlDxYY",
+ "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",
+ "counterparty_population_and_roles_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": "counterparty_population_and_roles",
+ "detected_intent_confidence": "high",
+ "extracted_filters": {
+ "sort": "period_desc",
+ "period_to": "2026-04-08"
+ },
+ "missing_required_filters": [],
+ "selected_recipe": "address_counterparty_population_roles_v1",
+ "mcp_call_status_legacy": "matched_non_empty",
+ "account_scope_mode": "preferred",
+ "account_scope_fallback_applied": false,
+ "anchor_type": "unknown",
+ "anchor_value_raw": null,
+ "anchor_value_resolved": null,
+ "resolver_confidence": "low",
+ "ambiguity_count": 0,
+ "match_failure_stage": "none",
+ "match_failure_reason": null,
+ "mcp_call_status": "matched_non_empty",
+ "rows_fetched": 5,
+ "raw_rows_received": 5,
+ "rows_after_account_scope": 5,
+ "rows_after_recipe_filter": 5,
+ "rows_materialized": 5,
+ "rows_matched": 5,
+ "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_SUMMARY",
+ "execution_lane": "address_query",
+ "llm_decomposition_applied": true,
+ "llm_decomposition_attempted": true,
+ "llm_provider_used": "local",
+ "llm_decomposition_trace_id": "8yz5LnBjs99viF",
+ "llm_decomposition_effective_message": "сколько контрагентов (поставщиков) в базе данных",
+ "llm_decomposition_reason": "normalized_fragment_applied",
+ "llm_canonical_candidate_detected": true,
+ "llm_predecompose_contract": {
+ "schema_version": "address_llm_predecompose_contract_v1",
+ "source_message": "скока поставщиков в базе",
+ "canonical_message": "сколько контрагентов (поставщиков) в базе данных",
+ "mode": "address_query",
+ "mode_confidence": "high",
+ "query_shape": "OBJECT_LOOKUP",
+ "query_shape_confidence": "medium",
+ "intent": "counterparty_population_and_roles",
+ "intent_confidence": "high",
+ "entities": {
+ "account": null,
+ "counterparty": null,
+ "contract": null,
+ "document_type": null,
+ "document_ref": null,
+ "organization": null
+ },
+ "period": {
+ "scope": "unspecified",
+ "period_from": null,
+ "period_to": "2026-04-08",
+ "as_of_date": null,
+ "has_explicit_period": true
+ },
+ "aggregation_profile": "management_profile"
+ },
+ "fallback_rule_hit": null,
+ "sanitized_user_message": "скока поставщиков в базе",
+ "tool_gate_decision": "run_address_lane",
+ "tool_gate_reason": "address_mode_classifier_detected",
+ "answer_structure_v11": null,
+ "investigation_state_snapshot": null,
+ "normalized": null,
+ "normalizer_output": {
+ "trace_id": "8yz5LnBjs99viF",
+ "prompt_version": "normalizer_v2_0_2",
+ "applied": true,
+ "effective_message": "сколько контрагентов (поставщиков) в базе данных"
+ }
+ },
+ "error_code": null,
+ "error_message": null
+ },
+ {
+ "index": 3,
+ "id": "UX003",
+ "question": "какие заказчики работали с нами в 2020 году",
+ "session": null,
+ "session_id": "asst-2026-04-08_Address_Step5_UX_Smoke_v2-ux003",
+ "status_code": 200,
+ "ok": true,
+ "elapsed_ms": 6116,
+ "reply_type": "factual",
+ "trace_id": "address-4CJfHsZhmr",
+ "assistant_reply": "Активные заказчики в 2020 году: 13.\nСобран профиль активности заказчиков (bank-doc activity aggregate).\nСтрок агрегата: 13.\n1. Группа | операций: 13 | последняя активность: 2020-12-30T12:00:00Z\n2. СБЕРБАНК, ПАО | операций: 9 | последняя активность: 2020-03-02T12:00:04Z\n3. ЭталонМебель | операций: 4 | последняя активность: 2020-04-27T12:00:00Z\n4. Гамма-мебель, ООО | операций: 3 | последняя активность: 2020-08-11T13:15:30Z\n5. ЗАО Ремонтно-строительная фирма «Ремстройсервис» | операций: 3 | последняя активность: 2020-06-03T00:00:02Z\n6. НОРТОН | операций: 3 | последняя активность: 2020-03-19T12:00:00Z\n8. ИП Калинин Н.М. | операций: 2 | последняя активность: 2020-03-02T12:00:03Z\n9. МНН | операций: 1 | последняя активность: 2020-08-11T13:16:37Z\n10. «Олимпстрой» | операций: 1 | последняя активность: 2020-07-13T12:00:00Z\n11. ПрофТренд,ООО | операций: 1 | последняя активность: 2020-05-15T12:00:02Z\n12. Лайсвуд, ООО | операций: 1 | последняя активность: 2020-03-02T12:00:02Z\n13. Смарт | операций: 1 | последняя активность: 2020-02-07T12:00:03Z",
+ "assistant_reply_first_line": "Активные заказчики в 2020 году: 13.",
+ "expected_intent": null,
+ "actual_intent": "counterparty_activity_lifecycle",
+ "intent_match": true,
+ "expected_mode": "address_query",
+ "actual_mode": "address_query",
+ "mode_match": true,
+ "expected_reply_type": null,
+ "reply_match": true,
+ "semantic_pass": true,
+ "route_pass": true,
+ "route_health": "ok_or_factual",
+ "strict_policy": "route",
+ "strict_pass": true,
+ "selected_recipe": "address_counterparty_activity_lifecycle_v1",
+ "missing_required_filters": [],
+ "match_failure_stage": "none",
+ "match_failure_reason": null,
+ "rows_fetched": 13,
+ "rows_matched": 13,
+ "mcp_call_status": "matched_non_empty",
+ "limited_reason_category": null,
+ "llm_decomposition_attempted": true,
+ "llm_decomposition_applied": true,
+ "llm_decomposition_reason": "normalized_fragment_applied",
+ "llm_canonical_candidate_detected": true,
+ "fallback_rule_hit": null,
+ "tool_gate_decision": "run_address_lane",
+ "tool_gate_reason": "address_mode_classifier_detected",
+ "predecompose_contract_intent": "counterparty_activity_lifecycle",
+ "predecompose_contract_aggregation_profile": "management_profile",
+ "predecompose_contract_period_scope": "year",
+ "debug_payload": {
+ "trace_id": "address-4CJfHsZhmr",
+ "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",
+ "counterparty_activity_lifecycle_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": "counterparty_activity_lifecycle",
+ "detected_intent_confidence": "high",
+ "extracted_filters": {
+ "sort": "period_desc",
+ "period_from": "2020-01-01",
+ "period_to": "2020-12-31"
+ },
+ "missing_required_filters": [],
+ "selected_recipe": "address_counterparty_activity_lifecycle_v1",
+ "mcp_call_status_legacy": "matched_non_empty",
+ "account_scope_mode": "preferred",
+ "account_scope_fallback_applied": false,
+ "anchor_type": "unknown",
+ "anchor_value_raw": null,
+ "anchor_value_resolved": null,
+ "resolver_confidence": "low",
+ "ambiguity_count": 0,
+ "match_failure_stage": "none",
+ "match_failure_reason": null,
+ "mcp_call_status": "matched_non_empty",
+ "rows_fetched": 13,
+ "raw_rows_received": 13,
+ "rows_after_account_scope": 13,
+ "rows_after_recipe_filter": 13,
+ "rows_materialized": 13,
+ "rows_matched": 13,
+ "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",
+ "execution_lane": "address_query",
+ "llm_decomposition_applied": true,
+ "llm_decomposition_attempted": true,
+ "llm_provider_used": "local",
+ "llm_decomposition_trace_id": "Xo5UbR1JO3-xbg",
+ "llm_decomposition_effective_message": "какие контрагенты (заказчики) имели взаимодействия с нашей организацией в 2020 году?",
+ "llm_decomposition_reason": "normalized_fragment_applied",
+ "llm_canonical_candidate_detected": true,
+ "llm_predecompose_contract": {
+ "schema_version": "address_llm_predecompose_contract_v1",
+ "source_message": "какие заказчики работали с нами в 2020 году",
+ "canonical_message": "какие контрагенты (заказчики) имели взаимодействия с нашей организацией в 2020 году?",
+ "mode": "address_query",
+ "mode_confidence": "high",
+ "query_shape": "OBJECT_LOOKUP",
+ "query_shape_confidence": "medium",
+ "intent": "counterparty_activity_lifecycle",
+ "intent_confidence": "high",
+ "entities": {
+ "account": null,
+ "counterparty": null,
+ "contract": null,
+ "document_type": null,
+ "document_ref": null,
+ "organization": null
+ },
+ "period": {
+ "scope": "year",
+ "period_from": "2020-01-01",
+ "period_to": "2020-12-31",
+ "as_of_date": null,
+ "has_explicit_period": true
+ },
+ "aggregation_profile": "management_profile"
+ },
+ "fallback_rule_hit": null,
+ "sanitized_user_message": "какие заказчики работали с нами в 2020 году",
+ "tool_gate_decision": "run_address_lane",
+ "tool_gate_reason": "address_mode_classifier_detected",
+ "answer_structure_v11": null,
+ "investigation_state_snapshot": null,
+ "normalized": null,
+ "normalizer_output": {
+ "trace_id": "Xo5UbR1JO3-xbg",
+ "prompt_version": "normalizer_v2_0_2",
+ "applied": true,
+ "effective_message": "какие контрагенты (заказчики) имели взаимодействия с нашей организацией в 2020 году?"
+ }
+ },
+ "error_code": null,
+ "error_message": null
+ },
+ {
+ "index": 4,
+ "id": "UX004",
+ "question": "какие договоры давно не использовались",
+ "session": null,
+ "session_id": "asst-2026-04-08_Address_Step5_UX_Smoke_v2-ux004",
+ "status_code": 200,
+ "ok": true,
+ "elapsed_ms": 5044,
+ "reply_type": "factual",
+ "trace_id": "address-kUOcY4wKHe",
+ "assistant_reply": "Использованных договоров: 291 из 394 (73.9%).\nПрофиль договорной базы собран (catalog + usage aggregate).\nСтрок агрегата: 2.\nВсего договоров в базе: 394.\nИспользованных договоров (есть factual связь с операциями): 291.\nНеиспользуемых договоров: 103.\nДоля используемых договоров: 73.9%.",
+ "assistant_reply_first_line": "Использованных договоров: 291 из 394 (73.9%).",
+ "expected_intent": null,
+ "actual_intent": "contract_usage_overview",
+ "intent_match": true,
+ "expected_mode": "address_query",
+ "actual_mode": "address_query",
+ "mode_match": true,
+ "expected_reply_type": null,
+ "reply_match": true,
+ "semantic_pass": true,
+ "route_pass": true,
+ "route_health": "ok_or_factual",
+ "strict_policy": "route",
+ "strict_pass": true,
+ "selected_recipe": "address_contract_usage_overview_v1",
+ "missing_required_filters": [],
+ "match_failure_stage": "none",
+ "match_failure_reason": null,
+ "rows_fetched": 2,
+ "rows_matched": 2,
+ "mcp_call_status": "matched_non_empty",
+ "limited_reason_category": null,
+ "llm_decomposition_attempted": true,
+ "llm_decomposition_applied": false,
+ "llm_decomposition_reason": "normalized_fragment_same",
+ "llm_canonical_candidate_detected": true,
+ "fallback_rule_hit": null,
+ "tool_gate_decision": "run_address_lane",
+ "tool_gate_reason": "address_mode_classifier_detected",
+ "predecompose_contract_intent": "contract_usage_overview",
+ "predecompose_contract_aggregation_profile": "management_profile",
+ "predecompose_contract_period_scope": "unspecified",
+ "debug_payload": {
+ "trace_id": "address-kUOcY4wKHe",
+ "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",
+ "contract_usage_overview_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": "contract_usage_overview",
+ "detected_intent_confidence": "high",
+ "extracted_filters": {
+ "sort": "period_desc",
+ "period_to": "2026-04-08"
+ },
+ "missing_required_filters": [],
+ "selected_recipe": "address_contract_usage_overview_v1",
+ "mcp_call_status_legacy": "matched_non_empty",
+ "account_scope_mode": "preferred",
+ "account_scope_fallback_applied": false,
+ "anchor_type": "unknown",
+ "anchor_value_raw": null,
+ "anchor_value_resolved": null,
+ "resolver_confidence": "low",
+ "ambiguity_count": 0,
+ "match_failure_stage": "none",
+ "match_failure_reason": null,
+ "mcp_call_status": "matched_non_empty",
+ "rows_fetched": 2,
+ "raw_rows_received": 2,
+ "rows_after_account_scope": 2,
+ "rows_after_recipe_filter": 2,
+ "rows_materialized": 2,
+ "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_SUMMARY",
+ "execution_lane": "address_query",
+ "llm_decomposition_applied": false,
+ "llm_decomposition_attempted": true,
+ "llm_provider_used": "local",
+ "llm_decomposition_trace_id": "AaSkQ_3C85jkf4",
+ "llm_decomposition_effective_message": "какие договоры давно не использовались",
+ "llm_decomposition_reason": "normalized_fragment_same",
+ "llm_canonical_candidate_detected": true,
+ "llm_predecompose_contract": {
+ "schema_version": "address_llm_predecompose_contract_v1",
+ "source_message": "какие договоры давно не использовались",
+ "canonical_message": "какие договоры давно не использовались",
+ "mode": "address_query",
+ "mode_confidence": "high",
+ "query_shape": "OBJECT_LOOKUP",
+ "query_shape_confidence": "medium",
+ "intent": "contract_usage_overview",
+ "intent_confidence": "high",
+ "entities": {
+ "account": null,
+ "counterparty": null,
+ "contract": null,
+ "document_type": null,
+ "document_ref": null,
+ "organization": null
+ },
+ "period": {
+ "scope": "unspecified",
+ "period_from": null,
+ "period_to": "2026-04-08",
+ "as_of_date": null,
+ "has_explicit_period": true
+ },
+ "aggregation_profile": "management_profile"
+ },
+ "fallback_rule_hit": null,
+ "sanitized_user_message": "какие договоры давно не использовались",
+ "tool_gate_decision": "run_address_lane",
+ "tool_gate_reason": "address_mode_classifier_detected",
+ "answer_structure_v11": null,
+ "investigation_state_snapshot": null,
+ "normalized": null,
+ "normalizer_output": {
+ "trace_id": "AaSkQ_3C85jkf4",
+ "prompt_version": "normalizer_v2_0_2",
+ "applied": false,
+ "effective_message": "какие договоры давно не использовались"
+ }
+ },
+ "error_code": null,
+ "error_message": null
+ },
+ {
+ "index": 5,
+ "id": "UX005",
+ "question": "покажи документы по нортону",
+ "session": null,
+ "session_id": "asst-2026-04-08_Address_Step5_UX_Smoke_v2-ux005",
+ "status_code": 200,
+ "ok": true,
+ "elapsed_ms": 6376,
+ "reply_type": "factual",
+ "trace_id": "address-U6mICiwCrJ",
+ "assistant_reply": "Найдено документов по контрагенту: 12.\nСобран список документов по контрагенту (live address lane).\nСтрок отобрано: 12.\n1. 2022-01-20T12:00:05Z | Поступление на расчетный счет 00000000003 от 20.01.2022 12:00:05 | 0 / 0 | 2254115 | аналитика: НОРТОН; договор Н-04 от 02.07.2018\n2. 2020-03-19T12:00:00Z | Поступление на расчетный счет 00000000018 от 19.03.2020 12:00:00 | 0 / 0 | 2500000 | аналитика: НОРТОН; 0\n3. 2020-03-02T00:00:00Z | Поступление на расчетный счет 00000000014 от 02.03.2020 0:00:00 | 0 / 0 | 1920000 | аналитика: НОРТОН; договор Н-04 от 02.07.2018\n4. 2020-02-21T12:00:02Z | Поступление на расчетный счет 00000000011 от 21.02.2020 12:00:02 | 0 / 0 | 400000 | аналитика: НОРТОН; договор Н-04 от 02.07.2018\n5. 2019-12-24T14:52:21Z | Поступление на расчетный счет 00000000071 от 24.12.2019 14:52:21 | 0 / 0 | 400000 | аналитика: НОРТОН; договор Н-04 от 02.07.2018\n6. 2019-11-20T12:00:00Z | Поступление на расчетный счет 00000000066 от 20.11.2019 12:00:00 | 0 / 0 | 155000 | аналитика: НОРТОН; договор Н-04 от 02.07.2018\n7. 2019-11-18T12:00:00Z | Поступление на расчетный счет 00000000065 от 18.11.2019 12:00:00 | 0 / 0 | 10000 | аналитика: НОРТОН; договор Н-04 от 02.07.2018\n8. 2019-11-14T00:00:00Z | Поступление на расчетный счет 00000000064 от 14.11.2019 0:00:00 | 0 / 0 | 250000 | аналитика: НОРТОН; договор Н-04 от 02.07.2018\n9. 2019-10-22T12:00:00Z | Поступление на расчетный счет 00000000056 от 22.10.2019 12:00:00 | 0 / 0 | 765000 | аналитика: НОРТОН; договор Н-04 от 02.07.2018\n10. 2019-10-08T12:00:00Z | Поступление на расчетный счет 00000000051 от 08.10.2019 12:00:00 | 0 / 0 | 289101 | аналитика: НОРТОН; договор Н-04 от 02.07.2018\n11. 2019-08-13T12:00:00Z | Поступление на расчетный счет 00000000044 от 13.08.2019 12:00:00 | 0 / 0 | 1250000 | аналитика: НОРТОН; договор Н-04 от 02.07.2018\n12. 2019-07-26T12:00:03Z | Поступление на расчетный счет 00000000038 от 26.07.2019 12:00:03 | 0 / 0 | 2300000 | аналитика: НОРТОН; договор Н-04 от 02.07.2018",
+ "assistant_reply_first_line": "Найдено документов по контрагенту: 12.",
+ "expected_intent": null,
+ "actual_intent": "list_documents_by_counterparty",
+ "intent_match": true,
+ "expected_mode": "address_query",
+ "actual_mode": "address_query",
+ "mode_match": true,
+ "expected_reply_type": null,
+ "reply_match": true,
+ "semantic_pass": true,
+ "route_pass": true,
+ "route_health": "ok_or_factual",
+ "strict_policy": "route",
+ "strict_pass": true,
+ "selected_recipe": "address_documents_by_counterparty_v1",
+ "missing_required_filters": [],
+ "match_failure_stage": "none",
+ "match_failure_reason": null,
+ "rows_fetched": 1000,
+ "rows_matched": 12,
+ "mcp_call_status": "matched_non_empty",
+ "limited_reason_category": null,
+ "llm_decomposition_attempted": true,
+ "llm_decomposition_applied": true,
+ "llm_decomposition_reason": "normalized_fragment_applied",
+ "llm_canonical_candidate_detected": true,
+ "fallback_rule_hit": null,
+ "tool_gate_decision": "run_address_lane",
+ "tool_gate_reason": "address_mode_classifier_detected",
+ "predecompose_contract_intent": "list_documents_by_counterparty",
+ "predecompose_contract_aggregation_profile": "list_lookup",
+ "predecompose_contract_period_scope": "unspecified",
+ "debug_payload": {
+ "trace_id": "address-U6mICiwCrJ",
+ "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",
+ "document_list_signal_detected",
+ "documents_by_counterparty_signal_detected"
+ ],
+ "why_included_summary": [],
+ "selection_reason_summary": []
+ },
+ "dropped_intent_segments": [],
+ "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": "high",
+ "ambiguity_count": 0,
+ "match_failure_stage": "none",
+ "match_failure_reason": null,
+ "mcp_call_status": "matched_non_empty",
+ "rows_fetched": 1000,
+ "raw_rows_received": 1000,
+ "rows_after_account_scope": 124,
+ "rows_after_recipe_filter": 12,
+ "rows_materialized": 124,
+ "rows_matched": 12,
+ "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",
+ "execution_lane": "address_query",
+ "llm_decomposition_applied": true,
+ "llm_decomposition_attempted": true,
+ "llm_provider_used": "local",
+ "llm_decomposition_trace_id": "M37mDDcGCAiWtQ",
+ "llm_decomposition_effective_message": "показать документы контрагента Нортон",
+ "llm_decomposition_reason": "normalized_fragment_applied",
+ "llm_canonical_candidate_detected": true,
+ "llm_predecompose_contract": {
+ "schema_version": "address_llm_predecompose_contract_v1",
+ "source_message": "покажи документы по нортону",
+ "canonical_message": "показать документы контрагента Нортон",
+ "mode": "address_query",
+ "mode_confidence": "high",
+ "query_shape": "DOCUMENT_LIST",
+ "query_shape_confidence": "medium",
+ "intent": "list_documents_by_counterparty",
+ "intent_confidence": "medium",
+ "entities": {
+ "account": null,
+ "counterparty": "Нортон",
+ "contract": null,
+ "document_type": null,
+ "document_ref": null,
+ "organization": null
+ },
+ "period": {
+ "scope": "unspecified",
+ "period_from": null,
+ "period_to": null,
+ "as_of_date": null,
+ "has_explicit_period": false
+ },
+ "aggregation_profile": "list_lookup"
+ },
+ "fallback_rule_hit": null,
+ "sanitized_user_message": "покажи документы по нортону",
+ "tool_gate_decision": "run_address_lane",
+ "tool_gate_reason": "address_mode_classifier_detected",
+ "answer_structure_v11": null,
+ "investigation_state_snapshot": null,
+ "normalized": null,
+ "normalizer_output": {
+ "trace_id": "M37mDDcGCAiWtQ",
+ "prompt_version": "normalizer_v2_0_2",
+ "applied": true,
+ "effective_message": "показать документы контрагента Нортон"
+ }
+ },
+ "error_code": null,
+ "error_message": null
+ },
+ {
+ "index": 6,
+ "id": "UX006",
+ "question": "покажи операции по договору 19/15",
+ "session": null,
+ "session_id": "asst-2026-04-08_Address_Step5_UX_Smoke_v2-ux006",
+ "status_code": 200,
+ "ok": true,
+ "elapsed_ms": 4998,
+ "reply_type": "factual",
+ "trace_id": "address-Pfvqhz4UvP",
+ "assistant_reply": "Найдено банковских операций по договору: 1.\nСобран список банковских операций по договору (live address lane).\nСтрок отобрано: 1.\n1. 2020-10-15T13:34:19Z | Списание с расчетного счета 00000000245 от 15.10.2020 13:34:19 | 66.01 / 51 | 134206.53",
+ "assistant_reply_first_line": "Найдено банковских операций по договору: 1.",
+ "expected_intent": null,
+ "actual_intent": "bank_operations_by_contract",
+ "intent_match": true,
+ "expected_mode": "address_query",
+ "actual_mode": "address_query",
+ "mode_match": true,
+ "expected_reply_type": null,
+ "reply_match": true,
+ "semantic_pass": true,
+ "route_pass": true,
+ "route_health": "ok_or_factual",
+ "strict_policy": "route",
+ "strict_pass": true,
+ "selected_recipe": "address_bank_operations_by_contract_v1",
+ "missing_required_filters": [],
+ "match_failure_stage": "none",
+ "match_failure_reason": null,
+ "rows_fetched": 1000,
+ "rows_matched": 1,
+ "mcp_call_status": "matched_non_empty",
+ "limited_reason_category": null,
+ "llm_decomposition_attempted": true,
+ "llm_decomposition_applied": true,
+ "llm_decomposition_reason": "normalized_fragment_applied",
+ "llm_canonical_candidate_detected": true,
+ "fallback_rule_hit": null,
+ "tool_gate_decision": "run_address_lane",
+ "tool_gate_reason": "address_mode_classifier_detected",
+ "predecompose_contract_intent": "bank_operations_by_contract",
+ "predecompose_contract_aggregation_profile": "list_lookup",
+ "predecompose_contract_period_scope": "unspecified",
+ "debug_payload": {
+ "trace_id": "address-Pfvqhz4UvP",
+ "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_contract_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_contract",
+ "detected_intent_confidence": "medium",
+ "extracted_filters": {
+ "sort": "period_desc",
+ "limit": 20,
+ "contract": "19/15"
+ },
+ "missing_required_filters": [],
+ "selected_recipe": "address_bank_operations_by_contract_v1",
+ "mcp_call_status_legacy": "matched_non_empty",
+ "account_scope_mode": "preferred",
+ "account_scope_fallback_applied": false,
+ "anchor_type": "contract",
+ "anchor_value_raw": "19/15",
+ "anchor_value_resolved": "19/15",
+ "resolver_confidence": "medium",
+ "ambiguity_count": 0,
+ "match_failure_stage": "none",
+ "match_failure_reason": null,
+ "mcp_call_status": "matched_non_empty",
+ "rows_fetched": 1000,
+ "raw_rows_received": 1000,
+ "rows_after_account_scope": 341,
+ "rows_after_recipe_filter": 1,
+ "rows_materialized": 341,
+ "rows_matched": 1,
+ "raw_row_keys_sample": [
+ "Период",
+ "Регистратор",
+ "СчетДт",
+ "СчетКт",
+ "Сумма",
+ "Period",
+ "Registrator",
+ "AccountDt",
+ "AccountKt",
+ "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",
+ "execution_lane": "address_query",
+ "llm_decomposition_applied": true,
+ "llm_decomposition_attempted": true,
+ "llm_provider_used": "local",
+ "llm_decomposition_trace_id": "_i5kv3dBHYRyF0",
+ "llm_decomposition_effective_message": "показать операции по договору 19/15",
+ "llm_decomposition_reason": "normalized_fragment_applied",
+ "llm_canonical_candidate_detected": true,
+ "llm_predecompose_contract": {
+ "schema_version": "address_llm_predecompose_contract_v1",
+ "source_message": "покажи операции по договору 19/15",
+ "canonical_message": "показать операции по договору 19/15",
+ "mode": "address_query",
+ "mode_confidence": "high",
+ "query_shape": "OBJECT_LOOKUP",
+ "query_shape_confidence": "medium",
+ "intent": "bank_operations_by_contract",
+ "intent_confidence": "medium",
+ "entities": {
+ "account": null,
+ "counterparty": null,
+ "contract": "19/15",
+ "document_type": null,
+ "document_ref": null,
+ "organization": null
+ },
+ "period": {
+ "scope": "unspecified",
+ "period_from": null,
+ "period_to": null,
+ "as_of_date": null,
+ "has_explicit_period": false
+ },
+ "aggregation_profile": "list_lookup"
+ },
+ "fallback_rule_hit": null,
+ "sanitized_user_message": "покажи операции по договору 19/15",
+ "tool_gate_decision": "run_address_lane",
+ "tool_gate_reason": "address_mode_classifier_detected",
+ "answer_structure_v11": null,
+ "investigation_state_snapshot": null,
+ "normalized": null,
+ "normalizer_output": {
+ "trace_id": "_i5kv3dBHYRyF0",
+ "prompt_version": "normalizer_v2_0_2",
+ "applied": true,
+ "effective_message": "показать операции по договору 19/15"
+ }
+ },
+ "error_code": null,
+ "error_message": null
+ }
+ ]
+}
diff --git a/docs/ADDRESS/runs/2026-04-08_Address_Step5_UX_Smoke_v2/response_audit.md b/docs/ADDRESS/runs/2026-04-08_Address_Step5_UX_Smoke_v2/response_audit.md
new file mode 100644
index 0000000..90c7be4
--- /dev/null
+++ b/docs/ADDRESS/runs/2026-04-08_Address_Step5_UX_Smoke_v2/response_audit.md
@@ -0,0 +1,10 @@
+# Response Audit: 2026-04-08_Address_Step5_UX_Smoke_v2
+
+| id | strict | route_health | reply_type | intent | limited_reason | question | assistant_first_line |
+|---|---|---|---|---|---|---|---|
+| UX001 | True | ok_or_factual | factual | customer_revenue_and_payments | None | какие клиенты самые доходные | Топ-5 заказчиков по сумме поступлений: |
+| UX002 | True | ok_or_factual | factual | counterparty_population_and_roles | None | скока поставщиков в базе | Поставщиков (только supplier-роль): 79. |
+| UX003 | True | ok_or_factual | factual | counterparty_activity_lifecycle | None | какие заказчики работали с нами в 2020 году | Активные заказчики в 2020 году: 13. |
+| UX004 | True | ok_or_factual | factual | contract_usage_overview | None | какие договоры давно не использовались | Использованных договоров: 291 из 394 (73.9%). |
+| UX005 | True | ok_or_factual | factual | list_documents_by_counterparty | None | покажи документы по нортону | Найдено документов по контрагенту: 12. |
+| UX006 | True | ok_or_factual | factual | bank_operations_by_contract | None | покажи операции по договору 19/15 | Найдено банковских операций по договору: 1. |
diff --git a/docs/ADDRESS/runs/2026-04-08_Address_Step5_UX_Smoke_v2/run_summary.json b/docs/ADDRESS/runs/2026-04-08_Address_Step5_UX_Smoke_v2/run_summary.json
new file mode 100644
index 0000000..8011c8c
--- /dev/null
+++ b/docs/ADDRESS/runs/2026-04-08_Address_Step5_UX_Smoke_v2/run_summary.json
@@ -0,0 +1,126 @@
+{
+ "run_id": "2026-04-08_Address_Step5_UX_Smoke_v2",
+ "generated_at": "2026-04-08T13:55:47",
+ "source_questions_file": "X:\\1C\\NDC_1C\\docs\\ADDRESS\\question_sets\\step5_ux_smoke_2026-04-08.json",
+ "backend_url": "http://127.0.0.1:8787/api/assistant/message",
+ "llm_provider": "local",
+ "llm_model": "qwen2.5-14b-instruct-1m",
+ "llm_base_url": "http://127.0.0.1:1234",
+ "strict_policy": "route",
+ "totals": {
+ "questions_total": 6,
+ "ok_200_count": 6,
+ "semantic_pass_count": 6,
+ "semantic_pass_rate": 1.0,
+ "route_pass_count": 6,
+ "route_pass_rate": 1.0,
+ "strict_pass_count": 6,
+ "strict_pass_rate": 1.0,
+ "factual_count": 6,
+ "partial_coverage_count": 0,
+ "clarification_required_count": 0,
+ "http_error_count": 0,
+ "llm_decomposition_attempted_count": 6,
+ "llm_decomposition_applied_count": 4,
+ "llm_fallback_count": 0,
+ "llm_fallback_rate": 0.0,
+ "tool_gate_blocked_count": 0,
+ "tool_gate_blocked_rate": 0.0,
+ "avg_elapsed_ms": 5551.5
+ },
+ "distributions": {
+ "reply_type": {
+ "factual": 6
+ },
+ "actual_intent": {
+ "customer_revenue_and_payments": 1,
+ "counterparty_population_and_roles": 1,
+ "counterparty_activity_lifecycle": 1,
+ "contract_usage_overview": 1,
+ "list_documents_by_counterparty": 1,
+ "bank_operations_by_contract": 1
+ },
+ "actual_mode": {
+ "address_query": 6
+ },
+ "mcp_call_status": {
+ "matched_non_empty": 6
+ },
+ "limited_reason_category": {},
+ "route_health": {
+ "ok_or_factual": 6
+ },
+ "tool_gate_decision": {
+ "run_address_lane": 6
+ },
+ "tool_gate_reason": {
+ "address_mode_classifier_detected": 6
+ }
+ },
+ "address_llm_predecompose_metrics": {
+ "overall": {
+ "llm_attempted": 6,
+ "llm_applied": 4,
+ "fallback_used": 0,
+ "fallback_rate": 0.0,
+ "tool_gate_blocked": 0,
+ "gate_block_rate": 0.0
+ },
+ "by_intent": {
+ "customer_revenue_and_payments": {
+ "total": 1,
+ "llm_attempted": 1,
+ "llm_applied": 0,
+ "fallback_used": 0,
+ "tool_gate_blocked": 0,
+ "fallback_rate": 0.0,
+ "gate_block_rate": 0.0
+ },
+ "counterparty_population_and_roles": {
+ "total": 1,
+ "llm_attempted": 1,
+ "llm_applied": 1,
+ "fallback_used": 0,
+ "tool_gate_blocked": 0,
+ "fallback_rate": 0.0,
+ "gate_block_rate": 0.0
+ },
+ "counterparty_activity_lifecycle": {
+ "total": 1,
+ "llm_attempted": 1,
+ "llm_applied": 1,
+ "fallback_used": 0,
+ "tool_gate_blocked": 0,
+ "fallback_rate": 0.0,
+ "gate_block_rate": 0.0
+ },
+ "contract_usage_overview": {
+ "total": 1,
+ "llm_attempted": 1,
+ "llm_applied": 0,
+ "fallback_used": 0,
+ "tool_gate_blocked": 0,
+ "fallback_rate": 0.0,
+ "gate_block_rate": 0.0
+ },
+ "list_documents_by_counterparty": {
+ "total": 1,
+ "llm_attempted": 1,
+ "llm_applied": 1,
+ "fallback_used": 0,
+ "tool_gate_blocked": 0,
+ "fallback_rate": 0.0,
+ "gate_block_rate": 0.0
+ },
+ "bank_operations_by_contract": {
+ "total": 1,
+ "llm_attempted": 1,
+ "llm_applied": 1,
+ "fallback_used": 0,
+ "tool_gate_blocked": 0,
+ "fallback_rate": 0.0,
+ "gate_block_rate": 0.0
+ }
+ }
+ }
+}
diff --git a/llm_normalizer/backend/dist/config.js b/llm_normalizer/backend/dist/config.js
index 6172551..9064784 100644
--- a/llm_normalizer/backend/dist/config.js
+++ b/llm_normalizer/backend/dist/config.js
@@ -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_LLM_PREDECOMPOSE_V1 = exports.FEATURE_ASSISTANT_ADDRESS_QUERY_V1 = exports.FEATURE_ASSISTANT_MCP_RUNTIME_V1 = exports.FEATURE_ASSISTANT_GRAPH_RUNTIME_V1 = exports.FEATURE_ASSISTANT_LIFECYCLE_ANSWER_V1 = exports.FEATURE_ASSISTANT_LIFECYCLE_RUNTIME_V1 = exports.FEATURE_ASSISTANT_STAGE2_EVAL_V1 = exports.FEATURE_ASSISTANT_PROBLEM_UNIT_CONTINUITY_V1 = exports.FEATURE_ASSISTANT_PROBLEM_CENTRIC_ANSWER_V1 = exports.FEATURE_ASSISTANT_PROBLEM_UNITS_V1 = exports.FEATURE_ASSISTANT_ACCOUNTANT_EVAL_V1 = exports.FEATURE_ASSISTANT_ANSWER_POLICY_V11 = exports.FEATURE_ASSISTANT_ANTI_GENERIC_RANKING_GUARD_V1 = exports.FEATURE_ASSISTANT_MIN_EVIDENCE_GATE_V1 = exports.FEATURE_ASSISTANT_BROAD_GUARD_V1 = exports.FEATURE_ASSISTANT_EVIDENCE_ENRICHMENT_V1 = exports.FEATURE_ASSISTANT_STATE_FOLLOWUP_BINDING_V1 = exports.FEATURE_ASSISTANT_CONTRACTS_V11 = exports.FEATURE_ASSISTANT_INVESTIGATION_STATE_V1 = exports.DEFAULT_PROMPT_VERSION = exports.DEFAULT_MAX_OUTPUT_TOKENS = exports.DEFAULT_TEMPERATURE = exports.DEFAULT_MODEL = exports.DEFAULT_OPENAI_BASE_URL = exports.TIMEZONE = exports.PORT = exports.MODULE_ROOT = exports.BACKEND_ROOT = void 0;
+exports.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_LIVING_CHAT_ROUTER_V1 = exports.FEATURE_ASSISTANT_ADDRESS_QUERY_LIVE_V1 = exports.FEATURE_ASSISTANT_ADDRESS_QUERY_LLM_PREDECOMPOSE_V1 = exports.FEATURE_ASSISTANT_ADDRESS_QUERY_V1 = exports.FEATURE_ASSISTANT_MCP_RUNTIME_V1 = exports.FEATURE_ASSISTANT_GRAPH_RUNTIME_V1 = exports.FEATURE_ASSISTANT_LIFECYCLE_ANSWER_V1 = exports.FEATURE_ASSISTANT_LIFECYCLE_RUNTIME_V1 = exports.FEATURE_ASSISTANT_STAGE2_EVAL_V1 = exports.FEATURE_ASSISTANT_PROBLEM_UNIT_CONTINUITY_V1 = exports.FEATURE_ASSISTANT_PROBLEM_CENTRIC_ANSWER_V1 = exports.FEATURE_ASSISTANT_PROBLEM_UNITS_V1 = exports.FEATURE_ASSISTANT_ACCOUNTANT_EVAL_V1 = exports.FEATURE_ASSISTANT_ANSWER_POLICY_V11 = exports.FEATURE_ASSISTANT_ANTI_GENERIC_RANKING_GUARD_V1 = exports.FEATURE_ASSISTANT_MIN_EVIDENCE_GATE_V1 = exports.FEATURE_ASSISTANT_BROAD_GUARD_V1 = exports.FEATURE_ASSISTANT_EVIDENCE_ENRICHMENT_V1 = exports.FEATURE_ASSISTANT_STATE_FOLLOWUP_BINDING_V1 = exports.FEATURE_ASSISTANT_CONTRACTS_V11 = exports.FEATURE_ASSISTANT_INVESTIGATION_STATE_V1 = exports.DEFAULT_PROMPT_VERSION = exports.DEFAULT_MAX_OUTPUT_TOKENS = exports.DEFAULT_TEMPERATURE = exports.DEFAULT_MODEL = exports.DEFAULT_OPENAI_BASE_URL = exports.TIMEZONE = exports.PORT = exports.MODULE_ROOT = exports.BACKEND_ROOT = void 0;
const path_1 = __importDefault(require("path"));
exports.BACKEND_ROOT = path_1.default.resolve(__dirname, "..");
exports.MODULE_ROOT = path_1.default.resolve(exports.BACKEND_ROOT, "..");
@@ -48,6 +48,7 @@ exports.FEATURE_ASSISTANT_MCP_RUNTIME_V1 = toBooleanFlag(process.env.FEATURE_ASS
exports.FEATURE_ASSISTANT_ADDRESS_QUERY_V1 = toBooleanFlag(process.env.FEATURE_ASSISTANT_ADDRESS_QUERY_V1, true);
exports.FEATURE_ASSISTANT_ADDRESS_QUERY_LLM_PREDECOMPOSE_V1 = toBooleanFlag(process.env.FEATURE_ASSISTANT_ADDRESS_QUERY_LLM_PREDECOMPOSE_V1, true);
exports.FEATURE_ASSISTANT_ADDRESS_QUERY_LIVE_V1 = toBooleanFlag(process.env.FEATURE_ASSISTANT_ADDRESS_QUERY_LIVE_V1, true);
+exports.FEATURE_ASSISTANT_LIVING_CHAT_ROUTER_V1 = toBooleanFlag(process.env.FEATURE_ASSISTANT_LIVING_CHAT_ROUTER_V1, true);
exports.ASSISTANT_MCP_PROXY_URL = (process.env.ASSISTANT_MCP_PROXY_URL ?? "http://127.0.0.1:6003").replace(/\/+$/, "");
exports.ASSISTANT_MCP_CHANNEL = process.env.ASSISTANT_MCP_CHANNEL ?? "default";
exports.ASSISTANT_MCP_TIMEOUT_MS = toNumberFlag(process.env.ASSISTANT_MCP_TIMEOUT_MS, 6000);
diff --git a/llm_normalizer/backend/dist/services/addressFilterExtractor.js b/llm_normalizer/backend/dist/services/addressFilterExtractor.js
index 39cc3f6..d345a79 100644
--- a/llm_normalizer/backend/dist/services/addressFilterExtractor.js
+++ b/llm_normalizer/backend/dist/services/addressFilterExtractor.js
@@ -8,7 +8,7 @@ const iconv_lite_1 = __importDefault(require("iconv-lite"));
const ACCOUNT_PATTERN = /(?:сч[её]т|счет|account)[^0-9]{0,12}(\d{2}(?:[.,]\d{1,2})?)/i;
const LIMIT_PATTERN = /(?:\btop\b|\blimit\b|первые|топ)[\s\-–—_:№#]*?(\d{1,3})/iu;
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 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;
@@ -298,47 +298,46 @@ function extractYearRangePeriod(text) {
};
}
function cleanupAnchorValue(value) {
- const normalized = String(value ?? "").trim();
- if (!normalized) {
+ const stripOuterQuotes = (text) => String(text ?? "")
+ .replace(/^['"«»“”„`’‘]+|['"«»“”„`’‘]+$/gu, "")
+ .trim();
+ let cleaned = stripOuterQuotes(String(value ?? "").trim());
+ if (!cleaned) {
return "";
}
// Remove trailing as-of qualifiers often captured by broad contract/counterparty regexes:
// " на 2020-07-31", " на дату 31.07.2020", " as of 2020-07-31".
const asOfTailPattern = /\s+(?:на\s+(?:дат[ауеы]\s+)?\d{1,4}[./-]\d{1,2}(?:[./-]\d{1,4})?|as\s+of\s+\d{1,4}[./-]\d{1,2}(?:[./-]\d{1,4})?)(?:\s+|$)[\s\S]*$/iu;
- if (asOfTailPattern.test(normalized)) {
- return normalized.replace(asOfTailPattern, "").trim();
- }
const asOfTruncatedTailPattern = /\s+на\s+дат[ауеы]\s+\d{1,2}(?:\s+|$)[\s\S]*$/iu;
- if (asOfTruncatedTailPattern.test(normalized)) {
- return normalized.replace(asOfTruncatedTailPattern, "").trim();
- }
+ const asOfReportDateTailPattern = /\s+на\s+дат[ауеы]\s+(?:отчетност[ьи]|отч[её]тн(?:ую|ой)?\s+дат[ауеы]|конец(?:\s+период[а-яё]*)?)\s+\d{1,4}[./-]\d{1,2}(?:[./-]\d{1,4})?(?:\s+|$)[\s\S]*$/iu;
const periodEndTailPattern = /\s+на\s+конец(?:\s+период[а-яё]*)?\s+(?:\d{1,4}[./-]\d{1,2}(?:[./-]\d{1,4})?|\d{4}|[a-zа-яё]+\s+\d{4})(?:\s+|$)[\s\S]*$/iu;
- if (periodEndTailPattern.test(normalized)) {
- return normalized.replace(periodEndTailPattern, "").trim();
- }
// Remove trailing period qualifiers that can be swallowed by broad anchor regexes:
// " с 2020-07-01 по 2020-07-31", " from 2020-07-01 to 2020-07-31"
const periodTailPattern = /\s+(?:с\s+\d{1,4}[./-]\d{1,2}[./-]\d{1,4}|from\s+\d{1,4}[./-]\d{1,2}[./-]\d{1,4}|between\s+\d{1,4}[./-]\d{1,2}[./-]\d{1,4}|за\s+период)(?:\s+|$)[\s\S]*$/iu;
- if (periodTailPattern.test(normalized)) {
- return normalized.replace(periodTailPattern, "").trim();
- }
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|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();
+ for (const tailPattern of [
+ asOfTailPattern,
+ asOfTruncatedTailPattern,
+ asOfReportDateTailPattern,
+ periodEndTailPattern,
+ periodTailPattern,
+ allTimeTailPattern,
+ allTimeTailPatternEn
+ ]) {
+ if (tailPattern.test(cleaned)) {
+ cleaned = stripOuterQuotes(cleaned.replace(tailPattern, "").trim());
+ }
}
const trailingYearTailPattern = /\s+(?:year\s+)?(20\d{2})(?:\s*(?:г(?:од|ода)?\.?|year))?(?:\s+|$)[\s\S]*$/iu;
- let cleaned = normalized;
- if (trailingYearTailPattern.test(normalized)) {
- cleaned = normalized.replace(trailingYearTailPattern, "").trim();
+ if (trailingYearTailPattern.test(cleaned)) {
+ cleaned = stripOuterQuotes(cleaned.replace(trailingYearTailPattern, "").trim());
}
- return cleaned
+ cleaned = cleaned
.replace(/\s+(?:from|to|between|and)(?:\s+|$)[\s\S]*$/iu, "")
.replace(/\s+(?:с|по|за)(?:\s+|$)[\s\S]*$/iu, "")
.trim();
+ return stripOuterQuotes(cleaned);
}
function cleanupContractAnchorValue(value) {
let normalized = cleanupAnchorValue(value);
@@ -395,6 +394,8 @@ function extractLooseByAnchorValue(text) {
"партнера",
"договору",
"договора",
+ "контракту",
+ "контракта",
"счету",
"счёту",
"дате",
@@ -442,6 +443,7 @@ function extractLooseByAnchorValue(text) {
"linked",
"нему",
"ней",
+ "нее",
"ним",
"этому",
"тому",
@@ -502,10 +504,38 @@ function isLikelyCounterpartyToken(rawToken) {
"каких",
"какому",
"какую",
+ "кто",
+ "что",
+ "чего",
+ "где",
+ "когда",
+ "почему",
+ "зачем",
+ "сколько",
"чьи",
"чья",
"чей",
"чью",
+ "самый",
+ "самая",
+ "самое",
+ "самые",
+ "крупный",
+ "крупная",
+ "крупное",
+ "крупные",
+ "жирный",
+ "жирная",
+ "жирное",
+ "жирные",
+ "больше",
+ "меньше",
+ "платит",
+ "платят",
+ "денег",
+ "деньги",
+ "объем",
+ "объём",
"док",
"доки",
"документ",
@@ -634,6 +664,13 @@ function isLowQualityCounterpartyAnchorValue(rawValue) {
if (tokens.length === 0) {
return true;
}
+ const questionCue = /(?:кто|что|какой|какая|какие|какого|сколько|где|когда|почему|зачем|which|who|what|how\s+many)/iu.test(value) ||
+ /[?]/u.test(String(rawValue ?? ""));
+ const rankingCue = /(?:больше|меньше|сам(?:ый|ая|ое|ые)|крупн|жирн|максим|миним)/iu.test(value);
+ const paymentCue = /(?:плат(?:ит|ят|еж|ёж|ежн|ежей|ежа)|денег|деньг|money|payment)/iu.test(value);
+ if (questionCue && (rankingCue || paymentCue)) {
+ return true;
+ }
const meaningfulTokens = tokens.filter((token) => isLikelyCounterpartyToken(token));
return meaningfulTokens.length === 0;
}
@@ -671,7 +708,9 @@ function isLowQualityContractAnchorValue(rawValue) {
"период",
"периоду",
"договор",
- "договору"
+ "договору",
+ "контракт",
+ "контракту"
]);
const meaningfulTokens = tokens.filter((token) => !lowQualityTokens.has(token));
return meaningfulTokens.length === 0;
diff --git a/llm_normalizer/backend/dist/services/addressIntentResolver.js b/llm_normalizer/backend/dist/services/addressIntentResolver.js
index 0581f89..13a7bbb 100644
--- a/llm_normalizer/backend/dist/services/addressIntentResolver.js
+++ b/llm_normalizer/backend/dist/services/addressIntentResolver.js
@@ -54,7 +54,8 @@ const OPEN_CONTRACTS_HINTS = [
"незакрыт",
"не закрыт",
"открыт",
- "договор"
+ "договор",
+ "контракт"
];
const OPEN_ITEMS_HINTS = [
"open items",
@@ -124,7 +125,10 @@ const DOCUMENTS_BY_CONTRACT_HINTS = [
"доки по договору",
"док по договору",
"документы договор",
- "договор"
+ "договор",
+ "документы по контракту",
+ "доки по контракту",
+ "контракт"
];
const BANK_OPERATIONS_BY_CONTRACT_HINTS = [
"bank operations by contract",
@@ -134,7 +138,10 @@ const BANK_OPERATIONS_BY_CONTRACT_HINTS = [
"bank ops by contract",
"банковские операции по договору",
"платежи по договору",
- "выписка по договору"
+ "выписка по договору",
+ "банковские операции по контракту",
+ "платежи по контракту",
+ "выписка по контракту"
];
const BANK_OPERATION_CORE_HINTS = [
"банк",
@@ -257,6 +264,14 @@ const COUNTERPARTY_ACTIVITY_LIFECYCLE_HINTS = [
"регулярные поставщики",
"эпизодические поставщики",
"давно не использовались поставщики",
+ "всех заков",
+ "кто был активен",
+ "потом отвалился",
+ "ровно один раз",
+ "и пропал",
+ "самые старые по сотрудничеству",
+ "разбей поставщиков на регуляр и разовые",
+ "кто новые в этом году",
"active customers",
"customer activity list",
"counterparty lifecycle"
@@ -314,14 +329,22 @@ const CONTRACT_USAGE_AND_VALUE_HINTS = [
"договоры по обороту",
"договоры по сумме оборота",
"топ договоров по обороту",
+ "контракты по обороту",
+ "контракты по сумме оборота",
+ "топ контрактов по обороту",
"договоры с минимальным бюджетом",
"договоры с самым маленьким бюджетом",
+ "контракты с минимальным бюджетом",
+ "контракты с самым маленьким бюджетом",
"активные договоры по бюджету",
+ "активные контракты по бюджету",
"контрагенты с несколькими договорами",
"несколько договоров у контрагента",
"мультидоговорные контрагенты",
"какие договоры активны",
+ "какие контракты активны",
"рабочие договоры",
+ "рабочие контракты",
"contracts by turnover",
"contracts by budget"
];
@@ -331,6 +354,10 @@ const CONTRACT_LIST_BY_COUNTERPARTY_HINTS = [
"список договоров по",
"покажи договоры по",
"выведи договоры по",
+ "контракты по",
+ "список контрактов по",
+ "покажи контракты по",
+ "выведи контракты по",
"contracts by counterparty",
"list contracts by counterparty",
"show contracts by counterparty"
@@ -550,10 +577,10 @@ function hasCounterpartyPopulationAndRolesSignal(text) {
return false;
}
function hasLifecycleSegmentationSignal(text) {
- return /(?:вперв|нов(?:ые|ых|ые\s+контрагент|ые\s+клиент|ые\s+заказчик)|исчез|ушед|ушл|только\s+один\s+раз|однораз|дольше\s+всех|долгожив|регулярн|эпизодич|давно\s+не\s+использ|неиспольз|потом\s+перестал)/iu.test(text);
+ return /(?:вперв|нов(?:ые|ых|ые\s+контрагент|ые\s+клиент|ые\s+заказчик)|исчез|ушед|ушл|пропал|отвал|только\s+один\s+раз|ровно\s+один\s+раз|однораз|дольше\s+всех|долгожив|самые\s+старые|старые\s+по\s+сотрудничеству|регуляр|эпизодич|разов(?:ые|ой|ые\s+поставщик)|давно\s+не\s+использ|неиспольз|потом\s+перестал)/iu.test(text);
}
function hasCounterpartyActivityLifecycleSignal(text) {
- if (hasDocumentSignal(text) || hasBankOperationSignal(text)) {
+ if ((hasDocumentSignal(text) || hasBankOperationSignal(text)) && !hasLifecycleSegmentationSignal(text)) {
return false;
}
if (hasAny(text, COUNTERPARTY_ACTIVITY_LIFECYCLE_HINTS)) {
@@ -567,6 +594,7 @@ function hasCounterpartyActivityLifecycleSignal(text) {
const hasTimeWindowLexeme = /(?:за\s+вс[её]\s+время|all\s+time|\b(?:19|20)\d{2}\b|(?:^|[^\d])\d{2}\s*(?:г(?:од|ода)?|г)(?:[^\p{L}\p{N}]|$)|в\s+конкретн(?:ом|ый)\s+год|за\s+год|в\s+году)/iu.test(text);
const hasListVerb = /(?:какие|кто|покажи|выведи|список|list|show)/iu.test(text);
const hasRosterQualifier = /(?:у\s+нас|вообще|в\s+баз[еы]|какие\s+есть|кто\s+есть|who\s+are)/iu.test(text);
+ const hasImplicitCounterpartyQuestion = /(?:кто\s+с\s+нами|кто\s+у\s+нас|всех?\s+зак(?:ов|а|и)?|все\s+заки|кто\s+нов(?:ые|ых|ый)\b|кто\s+был\s+активен|самые\s+старые\s+по\s+сотрудничеству)/iu.test(text);
const hasListWithWindow = hasCounterpartyLexeme && hasListVerb && hasTimeWindowLexeme;
if (hasListWithWindow) {
return true;
@@ -577,22 +605,28 @@ function hasCounterpartyActivityLifecycleSignal(text) {
if (hasCounterpartyLexeme && hasLifecycleSegmentationSignal(text)) {
return true;
}
+ if (hasImplicitCounterpartyQuestion && (hasLifecycleSegmentationSignal(text) || hasTimeWindowLexeme || hasActivityLexeme)) {
+ return true;
+ }
+ if (!hasCounterpartyLexeme && hasListVerb && hasLifecycleSegmentationSignal(text) && /\bкто\b/iu.test(text)) {
+ return true;
+ }
return hasCounterpartyLexeme && hasActivityLexeme && (hasTimeWindowLexeme || hasListVerb);
}
function hasContractUsageOverviewSignal(text) {
if (hasAny(text, CONTRACT_USAGE_OVERVIEW_HINTS)) {
return true;
}
- if (/(?:сколько\s+(?:всего\s+)?договор(?:ов|а)?(?:\s+заведен[оы])?|договорн(?:ая|ой)\s+баз[аы]).*(?:сколько|used|использ)/iu.test(text)) {
+ if (/(?:сколько\s+(?:всего\s+)?(?:договор|контракт)(?:ов|а)?(?:\s+заведен[оы])?|(?:договорн(?:ая|ой)|контрактн(?:ая|ой))\s+баз[аы]).*(?:сколько|used|использ)/iu.test(text)) {
return true;
}
- if (/(?:сколько\s+из\s+договор(?:ов|а)?\s+(?:реально\s+)?использ(?:ован[оы]|овал(?:и|ось)?))/iu.test(text)) {
+ if (/(?:сколько\s+из\s+(?:договор|контракт)(?:ов|а)?\s+(?:реально\s+)?использ(?:ован[оы]|овал(?:и|ось)?))/iu.test(text)) {
return true;
}
- if (/(?:total\s+vs\s+used|used\s+vs\s+total).*(?:договор|contract)?/iu.test(text)) {
+ if (/(?:total\s+vs\s+used|used\s+vs\s+total).*(?:договор|контракт|contract)?/iu.test(text)) {
return true;
}
- if (/(?:какие\s+договор(?:ы|а)?).*(?:давно\s+не\s+использ|неиспольз|протух|мертв|мёртв|stale|unused)/iu.test(text)) {
+ if (/(?:какие\s+(?:договор|контракт)(?:ы|а)?).*(?:давно\s+не\s+использ|неиспольз|протух|мертв|мёртв|stale|unused)/iu.test(text)) {
return true;
}
return false;
@@ -606,17 +640,19 @@ function hasCustomerRevenueAndPaymentsSignal(text) {
}
const hasFuzzyCustomerLexeme = hasFuzzyLexeme(text, ["клиент", "заказчик", "покупател", "customer", "client"]);
const hasFuzzySupplierLexeme = hasFuzzyLexeme(text, ["поставщик", "supplier", "vendor"]);
+ const hasCounterpartyLexeme = /(?:контрагент(?:ов|а|ы)?|counterpart(?:y|ies)|компан(?:и|ия|ии|ию)|организац(?:и|ия|ии|ию)|partner(?:s)?)/iu.test(text);
const hasSpecificCounterpartyAnchor = hasLooseByAnchorMention(text) ||
hasHeuristicCounterpartyAnchor(text) ||
/(?:по\s+(?:клиент(?:у|а)?|заказчик(?:у|а)?|покупател(?:ю|я)|customer|client)\s+[a-zа-яё0-9])/iu.test(text);
+ const asksWhoPays = /(?:кто\s+(?:нам\s+)?(?:(?:больше|чаще)\s+)?плат(?:ит|ят)?)/iu.test(text);
const asksCustomerGroup = /(?:клиент(?:ов|а|ы)?|заказчик(?:ов|а|и)?|покупател(?:ей|я|и)?|customer(?:s)?|client(?:s)?)/iu.test(text) ||
hasFuzzyCustomerLexeme ||
- /(?:кто\s+нам\s+(?:больше|чаще)|кто\s+платит)/iu.test(text);
+ asksWhoPays;
const asksCounterpartySource = /(?:с\s+каких|от\s+каких|от\s+кого|from\s+which|from\s+who)/iu.test(text);
const asksIncomingFlow = /(?:приход|поступлен|входящ|зачислен|inflow|incoming)/iu.test(text);
const asksDealBudgetRanking = /(?:сделк|deal|бюджет)/iu.test(text) &&
/(?:топ|top|сам(?:ый|ая|ое|ые)|крупн|мален|жирн|мелк|больше\s+всего|чаще\s+всего|наибольш|максимальн|минимальн)/iu.test(text);
- const asksValue = /(?:доходн|выручк|приход|поступлен|входящ|зачислен|оплат|чек|сделк|бюджет|занес|занёс|принес|принёс|revenue|inflow|deal)/iu.test(text);
+ const asksValue = /(?:доходн|выручк|приход|поступлен|входящ|зачислен|оплат|плат(?:еж|ёж|ежн|ежей|ежа|ит|ят)|деньг|денег|чек|сделк|бюджет|занес|занёс|принес|принёс|revenue|inflow|deal)/iu.test(text);
const asksRankOrTop = /(?:топ|top|сам(?:ый|ая|ое|ые)|крупн|мален|жирн|мелк|больше\s+всего|чаще\s+всего|наибольш|максимальн)/iu.test(text);
const asksCountOnly = /(?:сколько|скока|скок)\s+/iu.test(text) && !asksValue;
if (asksCountOnly) {
@@ -628,6 +664,12 @@ function hasCustomerRevenueAndPaymentsSignal(text) {
if (asksCustomerGroup && (asksValue || asksRankOrTop)) {
return true;
}
+ if (!hasFuzzySupplierLexeme && hasCounterpartyLexeme && asksRankOrTop && (asksValue || asksWhoPays)) {
+ return true;
+ }
+ if (!hasFuzzySupplierLexeme && asksWhoPays && (asksRankOrTop || hasCounterpartyLexeme)) {
+ return true;
+ }
if (asksCounterpartySource && asksValue) {
return true;
}
@@ -668,19 +710,19 @@ function hasContractUsageAndValueSignal(text) {
if (hasAny(text, CONTRACT_USAGE_AND_VALUE_HINTS)) {
return true;
}
- if (!/(?:договор(?:ов|а|ы)?|contract(?:s)?)/iu.test(text)) {
+ if (!/(?:договор(?:ов|а|ы)?|контракт(?:ов|а|ы|у|ом|е)?|contract(?:s)?)/iu.test(text)) {
return false;
}
if (hasContractUsageOverviewSignal(text)) {
return false;
}
- const asksStructure = /(?:нескольк(?:ими|их|ие|о)?\s+договор|мультидоговор|контрагент(?:ов|ы)?.*нескольк(?:ими|их|ие|о)\s+договор|какие\s+договор(?:ы|а)?\s+активн|рабоч(?:ие|их)\s+договор)/iu.test(text);
+ const asksStructure = /(?:нескольк(?:ими|их|ие|о)?\s+(?:договор|контракт)|мультидоговор|контрагент(?:ов|ы)?.*нескольк(?:ими|их|ие|о)\s+(?:договор|контракт)|какие\s+(?:договор|контракт)(?:ы|а)?\s+активн|рабоч(?:ие|их)\s+(?:договор|контракт))/iu.test(text);
const asksValue = /(?:оборот|бюджет|сумм|стоим|value|turnover|amount|revenue|крупн|мелк|миним|максим)/iu.test(text);
const asksRank = /(?:топ|top|ранк|rank|сам(?:ый|ая|ое|ые))/iu.test(text);
return asksStructure || asksValue || asksRank;
}
function hasContractListByCounterpartySignal(text) {
- const hasContractLexeme = /(?:договор(?:а|у|ом|е|ы)?|contracts?|contract)/iu.test(text);
+ const hasContractLexeme = /(?:договор(?:а|у|ом|е|ы)?|контракт(?:а|у|ом|е|ы)?|contracts?|contract)/iu.test(text);
if (!hasContractLexeme) {
return false;
}
@@ -715,7 +757,7 @@ function hasDocumentsByAccountDrilldownSignal(text) {
return hasAccountLexeme && hasDocLexeme && (hasDrilldownVerb || hasSameDate);
}
function hasOpenContractsListSignal(text) {
- const hasContractLexeme = text.includes("договор") || text.includes("contract") || text.includes("dogovor");
+ const hasContractLexeme = text.includes("договор") || text.includes("контракт") || text.includes("contract") || text.includes("dogovor");
const hasOpenLexeme = /(?:незакрыт|не\s+закрыт|открыт|open|unclosed)/iu.test(text);
if (!hasContractLexeme || !hasOpenLexeme) {
return false;
@@ -1059,7 +1101,7 @@ function resolveAddressIntent(userMessage) {
};
}
if (hasAny(text, OPEN_ITEMS_HINTS) &&
- (text.includes("контраг") || text.includes("договор") || text.includes("counterparty") || text.includes("contract"))) {
+ (text.includes("контраг") || text.includes("договор") || text.includes("контракт") || text.includes("counterparty") || text.includes("contract"))) {
return {
intent: "open_items_by_counterparty_or_contract",
confidence: "medium",
@@ -1192,7 +1234,7 @@ function resolveAddressIntent(userMessage) {
reasons: ["generic_lookup_with_loose_anchor_fallback"]
};
}
- if (hasAny(text, OPEN_CONTRACTS_HINTS) && (text.includes("договор") || text.includes("contract"))) {
+ if (hasAny(text, OPEN_CONTRACTS_HINTS) && (text.includes("договор") || text.includes("контракт") || text.includes("contract"))) {
return {
intent: "list_open_contracts",
confidence: "medium",
diff --git a/llm_normalizer/backend/dist/services/addressQueryClassifier.js b/llm_normalizer/backend/dist/services/addressQueryClassifier.js
index 051e17a..87707d3 100644
--- a/llm_normalizer/backend/dist/services/addressQueryClassifier.js
+++ b/llm_normalizer/backend/dist/services/addressQueryClassifier.js
@@ -21,6 +21,10 @@ const ADDRESS_ACTION_TOKENS = [
"вывед",
"кто",
"кому",
+ "какой",
+ "какая",
+ "какое",
+ "какую",
"какие",
"каких",
"что по",
@@ -67,6 +71,7 @@ const ADDRESS_ENTITY_TOKENS = [
"клиент",
"покупател",
"партнер",
+ "контракт",
"банк",
"выписк",
"операц",
@@ -229,7 +234,17 @@ function hasLooseByAnchorMention(text) {
"активности",
"пассивности",
"наименее",
- "минимум"
+ "минимум",
+ "запрос",
+ "запросу",
+ "запроса",
+ "запросом",
+ "запросе",
+ "вопрос",
+ "вопросу",
+ "вопроса",
+ "вопросом",
+ "вопросе"
]);
return !stopWords.has(token);
}
@@ -306,7 +321,17 @@ function hasLikelyCounterpartyToken(text) {
"пассивный",
"наименее",
"минимум",
- "реже"
+ "реже",
+ "запрос",
+ "запросу",
+ "запроса",
+ "запросом",
+ "запросе",
+ "вопрос",
+ "вопросу",
+ "вопроса",
+ "вопросом",
+ "вопросе"
]);
const tokens = String(text ?? "")
.split(/[^a-zа-яё0-9._-]+/iu)
@@ -366,11 +391,11 @@ function detectAddressQuestionMode(userMessage) {
reasons: ["loose_by_anchor_detected", ...(hasFollowupSignal ? ["address_followup_signal_detected"] : [])]
};
}
- if ((hasAddressEntity || hasAccountCode) && !hasDeepReasoning) {
+ if (hasAccountCode && !hasDeepReasoning) {
return {
mode: "address_query",
confidence: "medium",
- reasons: ["address_entity_detected"]
+ reasons: ["account_code_detected"]
};
}
if (!hasDeepReasoning && hasDocsOrBankSignal(text) && (hasLooseByAnchor || hasLikelyCounterpartyToken(text))) {
diff --git a/llm_normalizer/backend/dist/services/addressQueryService.js b/llm_normalizer/backend/dist/services/addressQueryService.js
index c0425b3..1966354 100644
--- a/llm_normalizer/backend/dist/services/addressQueryService.js
+++ b/llm_normalizer/backend/dist/services/addressQueryService.js
@@ -1358,12 +1358,15 @@ class AddressQueryService {
rowsMatched: historicalFilteredRows.length
});
const historicalFactual = (0, composeStage_1.composeFactualReply)(intent.intent, historicalFilteredRows, { userMessage });
- const historicalPrefix = "В последних доступных записях якорь не подтвердился; показаны найденные строки по историческому окну.";
+ const historicalPrefix = "Найдены данные в историческом срезе базы по вашему запросу.";
+ const historicalSuggestion = intent.intent === "list_documents_by_counterparty"
+ ? "\nЕсли нужно, могу дополнительно показать платежи и договоры по этому контрагенту."
+ : "";
const historicalLimitations = [...filters.warnings, "historical_window_sort_recovery_applied"];
const historicalReasons = [...baseReasons, "historical_window_sort_recovery_applied"];
return {
handled: true,
- reply_text: `${historicalPrefix}\n${historicalFactual.text}`,
+ reply_text: `${historicalPrefix}\n${historicalFactual.text}${historicalSuggestion}`,
reply_type: (0, composeStage_1.inferReplyType)(historicalFactual.responseType),
response_type: historicalFactual.responseType,
debug: {
@@ -1419,12 +1422,15 @@ class AddressQueryService {
const documentBankFallbackRows = applyIntentSpecificFilter(intent.intent, normalizedRows);
if (documentBankFallbackRows.length > 0) {
const fallbackFactual = (0, composeStage_1.composeFactualReply)(intent.intent, documentBankFallbackRows, { userMessage });
+ const fallbackPrefix = "По вашему запросу показываю найденные документы и операции в доступном срезе базы.";
+ const fallbackSuggestion = intent.intent === "list_documents_by_counterparty"
+ ? "\nЕсли нужно, могу дополнительно сузить период или показать только платежи."
+ : "";
const fallbackLimitations = [...filters.warnings, "anchor_not_matched_fallback_rows"];
const fallbackReasons = [...baseReasons, "anchor_not_matched_fallback_rows"];
return {
handled: true,
- reply_text: "Точный якорь не подтвердился в текущем окне live-данных; показаны ближайшие доступные документы/операции по выбранному типу.\n" +
- fallbackFactual.text,
+ reply_text: `${fallbackPrefix}\n${fallbackFactual.text}${fallbackSuggestion}`,
reply_type: (0, composeStage_1.inferReplyType)(fallbackFactual.responseType),
response_type: fallbackFactual.responseType,
debug: {
@@ -1484,9 +1490,20 @@ class AddressQueryService {
const isFollowupAnchorCarryover = Array.isArray(filters.warnings) &&
(filters.warnings.includes("counterparty_from_followup_context") ||
filters.warnings.includes("contract_from_followup_context"));
+ const anchorMismatchByCounterparty = isAnchorMismatch && String(matchFailureReason ?? "").includes("counterparty_anchor_not_matched");
+ const anchorMismatchByContract = isAnchorMismatch && String(matchFailureReason ?? "").includes("contract_anchor_not_matched");
const isLowQualityPartyAnchor = (anchor.anchor_type === "counterparty" || anchor.anchor_type === "contract") &&
isLikelyLowQualityPartyAnchor(anchor.anchor_value_raw);
- const anchorMismatchCategory = isFollowupAnchorCarryover || !isLowQualityPartyAnchor ? "empty_match" : "missing_anchor";
+ const requestedPeriodFrom = typeof filters.extracted_filters.period_from === "string" ? filters.extracted_filters.period_from : null;
+ const requestedPeriodTo = typeof filters.extracted_filters.period_to === "string" ? filters.extracted_filters.period_to : null;
+ const requestedPeriodHint = requestedPeriodFrom && requestedPeriodTo ? ` (период ${requestedPeriodFrom}..${requestedPeriodTo} сохранен)` : "";
+ const anchorMismatchCategory = isFollowupAnchorCarryover
+ ? "empty_match"
+ : anchorMismatchByCounterparty || anchorMismatchByContract
+ ? "missing_anchor"
+ : !isLowQualityPartyAnchor
+ ? "empty_match"
+ : "missing_anchor";
const category = isAnchorMismatch
? anchorMismatchCategory
: isRecipeFilteredOut
@@ -1495,18 +1512,26 @@ class AddressQueryService {
? "recipe_visibility_gap"
: "empty_match";
const reasonText = isAnchorMismatch
- ? anchorMismatchCategory === "missing_anchor"
- ? "якорь контрагента/договора не найден в материализованных live-строках"
- : "по указанному якорю и фильтрам в live-выборке нет строк"
+ ? anchorMismatchByCounterparty
+ ? "контрагент по указанному имени/алиасу не найден в materialized live-строках"
+ : anchorMismatchByContract
+ ? "договор по указанному номеру/названию не найден в materialized live-строках"
+ : anchorMismatchCategory === "missing_anchor"
+ ? "якорь контрагента/договора не найден в materialized live-строках"
+ : "по указанному якорю и фильтрам в live-выборке нет строк"
: isRecipeFilteredOut
? "строки по якорю найдены, но отфильтрованы intent-specific recipe"
: isVisibilityGapCandidate
? "в текущем live recipe нет достаточной document/bank видимости после фильтрации"
: "по выбранным фильтрам в live-выборке нет строк";
const nextStep = isAnchorMismatch
- ? anchorMismatchCategory === "missing_anchor"
- ? "уточните контрагента точным именем или добавьте ИНН/договор"
- : "уточните период или снимите часть фильтров"
+ ? anchorMismatchByCounterparty
+ ? `уточните точное имя контрагента или добавьте ИНН${requestedPeriodHint}`
+ : anchorMismatchByContract
+ ? `уточните номер/наименование договора${requestedPeriodHint}`
+ : anchorMismatchCategory === "missing_anchor"
+ ? "уточните контрагента точным именем или добавьте ИНН/договор"
+ : "уточните период или снимите часть фильтров"
: isRecipeFilteredOut
? "сузьте период, уточните контрагента или документный тип"
: isVisibilityGapCandidate
@@ -1514,9 +1539,13 @@ class AddressQueryService {
: "уточните период, контрагента, договор или снимите часть фильтров";
const limitations = isAnchorMismatch
? [
- anchorMismatchCategory === "missing_anchor"
- ? "anchor_not_matched_after_materialization"
- : "no_rows_for_anchor_after_materialization"
+ anchorMismatchByCounterparty
+ ? "counterparty_anchor_not_matched_after_materialization"
+ : anchorMismatchByContract
+ ? "contract_anchor_not_matched_after_materialization"
+ : anchorMismatchCategory === "missing_anchor"
+ ? "anchor_not_matched_after_materialization"
+ : "no_rows_for_anchor_after_materialization"
]
: isRecipeFilteredOut
? ["rows_filtered_out_by_recipe_after_anchor_match"]
diff --git a/llm_normalizer/backend/dist/services/address_runtime/composeStage.js b/llm_normalizer/backend/dist/services/address_runtime/composeStage.js
index 47e8ebb..b819b7b 100644
--- a/llm_normalizer/backend/dist/services/address_runtime/composeStage.js
+++ b/llm_normalizer/backend/dist/services/address_runtime/composeStage.js
@@ -169,13 +169,13 @@ function detectCounterpartyProfileFocus(userMessage) {
const hasCustomerToken = /(?:заказчик(?:ов|а)?|клиент(?:ов|а)?|customer(?:s)?|client(?:s)?)/iu.test(text);
const hasMixedToken = /(?:смешан|проч(?:их|ие)|mixed)/iu.test(text);
const asksRoles = /(?:заказчик(?:ов|а)?|поставщик(?:ов|а)?|смешан|проч(?:их|ие)|типы?\s+контрагент|разбей|раздели|roles?|split)/iu.test(text);
- if (hasSupplierToken && !hasCustomerToken && !hasMixedToken && !asksTotal) {
+ if (hasSupplierToken && !hasCustomerToken && !hasMixedToken) {
return "suppliers_only";
}
- if (hasCustomerToken && !hasSupplierToken && !hasMixedToken && !asksTotal) {
+ if (hasCustomerToken && !hasSupplierToken && !hasMixedToken) {
return "customers_only";
}
- if (hasMixedToken && !hasSupplierToken && !hasCustomerToken && !asksTotal) {
+ if (hasMixedToken && !hasSupplierToken && !hasCustomerToken) {
return "mixed_only";
}
if (asksTotal && !asksRoles) {
@@ -614,7 +614,17 @@ function composeFactualReply(intent, rows, options = {}) {
const focus = detectCounterpartyProfileFocus(options.userMessage);
const includeTotal = focus === "full_profile" || focus === "total_only";
const includeRoles = focus === "full_profile" || focus === "roles_only";
+ const directLead = focus === "suppliers_only"
+ ? `Поставщиков (только supplier-роль): ${supplierOnly}.`
+ : focus === "customers_only"
+ ? `Заказчиков (только customer-роль): ${customerOnly}.`
+ : focus === "mixed_only"
+ ? `Смешанных контрагентов (и customer, и supplier): ${mixedActive}.`
+ : includeTotal && totalCounterparties > 0
+ ? `Всего уникальных контрагентов в базе: ${totalCounterparties}.`
+ : `Активных контрагентов по операциям: ${activeCounterparties}.`;
const lines = [
+ directLead,
"Профиль контрагентов собран (catalog + bank-doc activity aggregate).",
`Строк агрегата: ${rows.length}.`
];
@@ -693,17 +703,17 @@ function composeFactualReply(intent, rows, options = {}) {
? `в ${requestedYear} году`
: "в выбранном периоде";
const lines = [
+ `Активные заказчики ${scopeLabel}: ${counterparties.length}.`,
"Собран профиль активности заказчиков (bank-doc activity aggregate).",
`Строк агрегата: ${rows.length}.`
];
if (counterparties.length === 0) {
- lines.push("Активных заказчиков по выбранному окну не найдено.");
+ lines.push("По выбранному окну активности заказчики не найдены.");
return {
responseType: "FACTUAL_SUMMARY",
text: lines.join("\n")
};
}
- lines.push(`Активные заказчики ${scopeLabel}: ${counterparties.length}.`);
const visible = counterparties.slice(0, 120);
lines.push(...visible.map((item, index) => {
const suffix = item.lastPeriod ? ` | последняя активность: ${item.lastPeriod}` : "";
@@ -734,7 +744,11 @@ function composeFactualReply(intent, rows, options = {}) {
const usedContracts = sumMarker("CT_USED");
const unusedContracts = totalContracts > 0 ? Math.max(0, totalContracts - Math.min(usedContracts, totalContracts)) : null;
const usedShare = totalContracts > 0 ? formatPercent(Math.min(usedContracts, totalContracts), totalContracts) : null;
+ const usageLead = totalContracts > 0
+ ? `Использованных договоров: ${usedContracts} из ${totalContracts}${usedShare ? ` (${usedShare})` : ""}.`
+ : `Использованных договоров (есть factual связь с операциями): ${usedContracts}.`;
const lines = [
+ usageLead,
"Профиль договорной базы собран (catalog + usage aggregate).",
`Строк агрегата: ${rows.length}.`
];
@@ -830,9 +844,10 @@ function composeFactualReply(intent, rows, options = {}) {
}
if (focus === "top_by_ops") {
const visible = rankedByOps.slice(0, limit);
- lines.push(isSupplier
+ const heading = isSupplier
? `Топ-${visible.length} поставщиков по количеству исходящих платежных операций:`
- : `Топ-${visible.length} заказчиков по количеству входящих платежных операций:`);
+ : `Топ-${visible.length} заказчиков по количеству входящих платежных операций:`;
+ lines.unshift(heading);
lines.push(...visible.map((item, index) => `${index + 1}. ${item.name} | операций: ${item.ops} | сумма: ${item.total} | макс: ${item.maxSingle}`));
return {
responseType: "FACTUAL_LIST",
@@ -841,9 +856,10 @@ function composeFactualReply(intent, rows, options = {}) {
}
if (focus === "top_by_max_single") {
const visible = rankedByMaxSingle.slice(0, limit);
- lines.push(isSupplier
+ const heading = isSupplier
? `Топ-${visible.length} поставщиков по максимальной разовой выплате:`
- : `Топ-${visible.length} заказчиков по максимальной сумме одной входящей операции:`);
+ : `Топ-${visible.length} заказчиков по максимальной сумме одной входящей операции:`;
+ lines.unshift(heading);
lines.push(...visible.map((item, index) => `${index + 1}. ${item.name} | max single: ${item.maxSingle} | сумма: ${item.total} | операций: ${item.ops}`));
return {
responseType: "FACTUAL_LIST",
@@ -852,9 +868,10 @@ function composeFactualReply(intent, rows, options = {}) {
}
if (focus === "top_by_avg_check_min_ops") {
const visible = rankedByAvgCheck.slice(0, limit);
- lines.push(isSupplier
+ const heading = isSupplier
? `Топ-${visible.length} поставщиков по среднему чеку (минимум ${minOpsForAvgCheck} операций):`
- : `Топ-${visible.length} заказчиков по среднему чеку (минимум ${minOpsForAvgCheck} входящих операций):`);
+ : `Топ-${visible.length} заказчиков по среднему чеку (минимум ${minOpsForAvgCheck} входящих операций):`;
+ lines.unshift(heading);
if (visible.length === 0) {
lines.push(`Контрагентов с минимум ${minOpsForAvgCheck} операций не найдено.`);
}
@@ -868,9 +885,10 @@ function composeFactualReply(intent, rows, options = {}) {
}
if (focus === "top_deals") {
const visible = rankedDealsTop.slice(0, limit);
- lines.push(isSupplier
+ const heading = isSupplier
? `Топ-${visible.length} самых крупных разовых выплат поставщикам:`
- : `Топ-${visible.length} самых крупных разовых сделок по поступлениям:`);
+ : `Топ-${visible.length} самых крупных разовых сделок по поступлениям:`;
+ lines.unshift(heading);
lines.push(...visible.map((item, index) => `${index + 1}. ${item.period ?? "n/a"} | ${item.counterparty} | ${item.registrator} | ${item.amount}`));
return {
responseType: "FACTUAL_LIST",
@@ -879,9 +897,10 @@ function composeFactualReply(intent, rows, options = {}) {
}
if (focus === "bottom_deals") {
const visible = rankedDealsBottom.slice(0, limit);
- lines.push(isSupplier
+ const heading = isSupplier
? `Топ-${visible.length} самых маленьких разовых выплат:`
- : `Топ-${visible.length} самых маленьких разовых сделок по поступлениям:`);
+ : `Топ-${visible.length} самых маленьких разовых сделок по поступлениям:`;
+ lines.unshift(heading);
if (activeOnlyForBottomDeals) {
lines.push("Фильтр: только активные контрагенты (минимум 3 операции).");
}
@@ -892,9 +911,10 @@ function composeFactualReply(intent, rows, options = {}) {
};
}
const visible = rankedByTotal.slice(0, limit);
- lines.push(isSupplier
+ const heading = isSupplier
? `Топ-${visible.length} поставщиков по сумме выплат:`
- : `Топ-${visible.length} заказчиков по сумме поступлений:`);
+ : `Топ-${visible.length} заказчиков по сумме поступлений:`;
+ lines.unshift(heading);
lines.push(...visible.map((item, index) => {
const avgCheck = item.ops > 0 ? (item.total / item.ops).toFixed(2) : "0";
return `${index + 1}. ${item.name} | сумма: ${item.total} | операций: ${item.ops} | средний чек: ${avgCheck} | макс: ${item.maxSingle}`;
@@ -943,9 +963,10 @@ function composeFactualReply(intent, rows, options = {}) {
.filter((item) => item.docs > 0 && item.turnover > 0)
.sort((a, b) => a.turnover - b.turnover || b.docs - a.docs || a.contract.localeCompare(b.contract));
const lines = [
+ `Активных договоров: ${contractRows.length}.`,
"Собран профиль договоров по обороту/бюджету (bank-doc contract aggregate).",
`Строк источника: ${rows.length}.`,
- `Активных договоров: ${contractRows.length}.`
+ `Договорных агрегатов: ${contractRows.length}.`
];
if (contractRows.length === 0) {
lines.push("В выбранном окне не найдено операций, связанных с договорами.");
@@ -956,7 +977,8 @@ function composeFactualReply(intent, rows, options = {}) {
}
if (focus === "top_by_docs") {
const visible = rankedByDocs.slice(0, limit);
- lines.push(`Топ-${visible.length} договоров по количеству операций:`);
+ const heading = `Топ-${visible.length} договоров по количеству операций:`;
+ lines.unshift(heading);
lines.push(...visible.map((item, index) => `${index + 1}. ${item.contract} | операций: ${item.docs} | оборот: ${item.turnover} | контрагентов: ${item.counterparties.size}`));
return {
responseType: "FACTUAL_LIST",
@@ -965,7 +987,8 @@ function composeFactualReply(intent, rows, options = {}) {
}
if (focus === "bottom_by_turnover_active") {
const visible = rankedBottomActive.slice(0, limit);
- lines.push(`Топ-${visible.length} активных договоров с минимальным бюджетом (оборотом):`);
+ const heading = `Топ-${visible.length} активных договоров с минимальным бюджетом (оборотом):`;
+ lines.unshift(heading);
lines.push(...visible.map((item, index) => `${index + 1}. ${item.contract} | оборот: ${item.turnover} | операций: ${item.docs} | последняя активность: ${item.lastPeriod ?? "n/a"}`));
return {
responseType: "FACTUAL_LIST",
@@ -973,7 +996,8 @@ function composeFactualReply(intent, rows, options = {}) {
};
}
const visible = rankedByTurnover.slice(0, limit);
- lines.push(`Топ-${visible.length} договоров по сумме оборота:`);
+ const heading = `Топ-${visible.length} договоров по сумме оборота:`;
+ lines.unshift(heading);
lines.push(...visible.map((item, index) => `${index + 1}. ${item.contract} | оборот: ${item.turnover} | операций: ${item.docs} | контрагентов: ${item.counterparties.size} | последняя активность: ${item.lastPeriod ?? "n/a"}`));
return {
responseType: "FACTUAL_LIST",
@@ -1073,8 +1097,7 @@ function composeFactualReply(intent, rows, options = {}) {
}
if (intent === "list_documents_by_counterparty") {
const lines = [
- "Собран список документов по контрагенту (live address lane).",
- `Строк отобрано: ${rows.length}.`,
+ `Найдено документов по контрагенту: ${rows.length}.`,
...formatTopRows(rows, rows.length)
];
return {
@@ -1084,6 +1107,7 @@ function composeFactualReply(intent, rows, options = {}) {
}
if (intent === "list_documents_by_contract") {
const lines = [
+ `Найдено документов по договору: ${rows.length}.`,
"Собран список документов по договору (live address lane).",
`Строк отобрано: ${rows.length}.`,
...formatTopRows(rows, rows.length)
@@ -1095,6 +1119,7 @@ function composeFactualReply(intent, rows, options = {}) {
}
if (intent === "bank_operations_by_counterparty") {
const lines = [
+ `Найдено банковских операций по контрагенту: ${rows.length}.`,
"Собран список банковских операций по контрагенту (live address lane).",
`Строк отобрано: ${rows.length}.`,
...formatTopRows(rows, rows.length)
@@ -1106,6 +1131,7 @@ function composeFactualReply(intent, rows, options = {}) {
}
if (intent === "bank_operations_by_contract") {
const lines = [
+ `Найдено банковских операций по договору: ${rows.length}.`,
"Собран список банковских операций по договору (live address lane).",
`Строк отобрано: ${rows.length}.`,
...formatTopRows(rows, rows.length)
diff --git a/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js b/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js
index bf643c4..ac47bda 100644
--- a/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js
+++ b/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js
@@ -24,6 +24,9 @@ function hasAllTimeHint(text) {
function hasSameDateHint(text) {
return /(?:на\s+ту\s+же\s+дат[ауеы]|на\s+эту\s+же\s+дат[ауеы]|та\s+же\s+дата|same\s+date|as\s+of\s+same\s+date|the\s+same\s+date)/iu.test(String(text ?? ""));
}
+function hasExplicitPeriodLiteral(text) {
+ return /\b(?:19|20)\d{2}(?:[./-](?:0?[1-9]|1[0-2]))?\b/.test(String(text ?? ""));
+}
function hasOpenItemsHint(text) {
return /(?:open\s+items|unclosed\s+items|хвост|висят|незакрыт|не\s+закрыт|открыт|долг|задолж|позиц)/iu.test(String(text ?? ""));
}
@@ -93,6 +96,29 @@ const FOLLOWUP_LOW_QUALITY_COUNTERPARTY_TOKENS = new Set([
"что",
"все",
"всё",
+ "кроме",
+ "помимо",
+ "этого",
+ "этот",
+ "эта",
+ "эту",
+ "этом",
+ "это",
+ "эти",
+ "этих",
+ "документ",
+ "документа",
+ "документы",
+ "документов",
+ "договор",
+ "договора",
+ "контрагент",
+ "контрагента",
+ "еще",
+ "ещё",
+ "другие",
+ "другое",
+ "остальное",
"год",
"года",
"году",
@@ -313,6 +339,17 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
}
return { filters: merged, reasons };
}
+ if (intent === "counterparty_activity_lifecycle" &&
+ hasAddressFollowupContextSignal(userMessage) &&
+ !hasExplicitPeriodLiteral(userMessage)) {
+ const currentPeriodFrom = toNonEmptyString(merged.period_from);
+ const currentPeriodTo = toNonEmptyString(merged.period_to);
+ const todayIso = new Date().toISOString().slice(0, 10);
+ if (!currentPeriodFrom && currentPeriodTo === todayIso) {
+ delete merged.period_to;
+ reasons.push("period_to_cleared_for_lifecycle_followup");
+ }
+ }
const currentHasPeriod = hasExplicitPeriodWindow(merged);
const previousHasPeriod = hasExplicitPeriodWindow(previous);
if (!currentHasPeriod && previousHasPeriod && hasAddressFollowupContextSignal(userMessage)) {
@@ -439,23 +476,29 @@ function deriveIntentWithFollowupContext(detectedIntent, userMessage, followupCo
}
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 mode = detectedMode.mode === "address_query"
+ ? detectedMode
+ : detectedIntent.intent !== "unknown"
+ ? {
+ mode: "address_query",
+ confidence: "medium",
+ reasons: [...detectedMode.reasons, "address_mode_from_resolved_intent"]
+ }
+ : 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 intent = deriveIntentWithFollowupContext(detectedIntent, userMessage, followupContext);
const extractedFilters = (0, addressFilterExtractor_1.extractAddressFilters)(userMessage, intent.intent);
const followupMerged = mergeFollowupFilters(extractedFilters.extracted_filters, intent.intent, userMessage, followupContext);
diff --git a/llm_normalizer/backend/dist/services/assistantService.js b/llm_normalizer/backend/dist/services/assistantService.js
index 6e98995..021c09e 100644
--- a/llm_normalizer/backend/dist/services/assistantService.js
+++ b/llm_normalizer/backend/dist/services/assistantService.js
@@ -39,6 +39,9 @@ Object.defineProperty(exports, "__esModule", { value: true });
exports.AssistantService = void 0;
exports.evaluateCoverageForTests = evaluateCoverageForTests;
exports.extractSubjectTokensForTests = extractSubjectTokensForTests;
+exports.extractOrganizationFactsFromRowsForTests = extractOrganizationFactsFromRowsForTests;
+exports.resolveOrganizationNamesByRefsForTests = resolveOrganizationNamesByRefsForTests;
+exports.resolveLivingAssistantModeDecision = resolveLivingAssistantModeDecision;
// @ts-nocheck
const nanoid_1 = __importStar(require("nanoid"));
const stage1Contracts_1 = __importStar(require("../types/stage1Contracts"));
@@ -58,7 +61,11 @@ const addressQueryClassifier_1 = __importStar(require("./addressQueryClassifier"
const addressIntentResolver_1 = __importStar(require("./addressIntentResolver"));
const addressFilterExtractor_1 = __importStar(require("./addressFilterExtractor"));
const predecomposeContract_1 = __importStar(require("./address_runtime/predecomposeContract"));
+const openaiResponsesClient_1 = __importStar(require("./openaiResponsesClient"));
+const addressMcpClient_1 = __importStar(require("./addressMcpClient"));
const iconv_lite_1 = __importDefault(require("iconv-lite"));
+const DATA_SCOPE_CACHE_TTL_MS = 60_000;
+const dataScopeProbeCache = new Map();
function retrievalSummaryForRoute(route) {
if (route === "store_canonical")
return "Canonical accounting data path selected.";
@@ -1428,14 +1435,14 @@ function hasAccountingSignal(text) {
if (/(?:^|[\s,;:])\d{2}(?:\.\d{2})?(?=$|[\s,.;:])/i.test(lower)) {
return true;
}
- return /(проводк|документ|реализац|поступлен|взаиморасчет|сальдо|остатк|счет|счёт|ндс|амортиз|рбп|ос|контрагент|поставщик|покупател|оплат|банк|выписк|склад|товар|материал|закрыти|период|postavshchik|kontragent|schet|schetu|period|counterparty|supplier|invoice|posting|ledger|account|anomaly|risk)/i.test(lower);
+ return /(проводк|документ|реализац|поступлен|взаиморасчет|сальдо|остатк|счет|счёт|ндс|амортиз|рбп|контрагент|поставщик|покупател|оплат|банк|выписк|склад|товар|материал|закрыти|период|postavshchik|kontragent|schet|schetu|period|counterparty|supplier|invoice|posting|ledger|account|anomaly|risk)/i.test(lower);
}
function hasFollowupMarker(text) {
const compact = compactWhitespace(text.toLowerCase());
- return /^(и|а еще|а ещё|еще|ещё|добав|уточн|продолж|также|а если|а теперь|теперь|plus|also|dobav|utochn|prodolzh|then|now)/i.test(compact);
+ return /^(и|а\s+кто|а еще|а ещё|еще|ещё|добав|уточн|продолж|также|а если|а теперь|теперь|plus|also|dobav|utochn|prodolzh|then|now)/i.test(compact);
}
function hasReferentialPointer(text) {
- return /(по этому|по тому|это же|этой|этим|этому|из этого|в этом|тот же|same thing|that one|po etomu|po tomu)/i.test(text.toLowerCase());
+ return /(по этому|по тому|это же|этой|этим|этому|этого|этот|эту|этом|это|эти|этих|из этого|из них|из этих|из тех|в этом|тот же|same thing|that one|po etomu|po tomu)/i.test(text.toLowerCase());
}
function hasSmallTalkSignal(text) {
return /(привет|как дела|спасибо|благодарю|thanks|thank you|hello|hi)\b/i.test(text.toLowerCase());
@@ -1812,6 +1819,8 @@ function buildAddressDebugPayload(addressDebug, llmPreDecomposeMeta = null) {
sanitized_user_message: llmMeta?.sanitizedUserMessage ?? null,
tool_gate_decision: llmMeta?.toolGateDecision ?? null,
tool_gate_reason: llmMeta?.toolGateReason ?? null,
+ dialog_continuation_contract_v2: llmMeta?.dialogContinuationContract ?? null,
+ address_retry_audit: llmMeta?.addressRetryAudit ?? null,
answer_structure_v11: null,
investigation_state_snapshot: null,
normalized: null,
@@ -1877,6 +1886,19 @@ const ADDRESS_PREDECOMPOSE_NOISE_TOKENS = new Set([
"list",
"skazhi",
"выведи",
+ "кроме",
+ "помимо",
+ "этого",
+ "этот",
+ "эта",
+ "эту",
+ "этом",
+ "это",
+ "эти",
+ "этих",
+ "другие",
+ "другое",
+ "остальное",
"что",
"чо",
"которые",
@@ -2323,7 +2345,8 @@ function textMojibakeScoreForAddress(value) {
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;
+ const doubleEncodedMarkers = (source.match(/(?:Г[Ђ-џ]|В[Ђ-џ]|Ã.|Â.)/gu) ?? []).length;
+ return cyrillic + latin - hardMarkers * 3 - pairMarkers * 2 - doubleEncodedMarkers * 2;
}
function looksLikeMojibakeForAddress(value) {
const source = String(value ?? "");
@@ -2333,7 +2356,13 @@ function looksLikeMojibakeForAddress(value) {
if (/[Ѓѓ‚„…†‡€‰‹ЉЊЌЋЏ‘’“”•–—™љ›њќћџ]/.test(source)) {
return true;
}
- return (source.match(/(?:Р.|С.|Ð.|Ñ.)/g) ?? []).length >= 2;
+ if ((source.match(/(?:Р.|С.|Ð.|Ñ.)/g) ?? []).length >= 2) {
+ return true;
+ }
+ if ((source.match(/(?:Г[Ђ-џ]|В[Ђ-џ]|Ã.|Â.)/gu) ?? []).length >= 2) {
+ return true;
+ }
+ return false;
}
function repairAddressMojibake(value) {
const source = String(value ?? "");
@@ -2341,20 +2370,28 @@ function repairAddressMojibake(value) {
return source;
}
let candidate = source;
- try {
- const fromWin1251 = iconv_lite_1.default.encode(candidate, "win1251").toString("utf8");
- if (textMojibakeScoreForAddress(fromWin1251) > textMojibakeScoreForAddress(candidate)) {
- candidate = fromWin1251;
+ for (let pass = 0; pass < 3; pass += 1) {
+ let improved = false;
+ try {
+ const fromWin1251 = iconv_lite_1.default.encode(candidate, "win1251").toString("utf8");
+ if (textMojibakeScoreForAddress(fromWin1251) > textMojibakeScoreForAddress(candidate)) {
+ candidate = fromWin1251;
+ improved = true;
+ }
+ }
+ catch (_error) { }
+ try {
+ const fromLatin1 = Buffer.from(candidate, "latin1").toString("utf8");
+ if (textMojibakeScoreForAddress(fromLatin1) > textMojibakeScoreForAddress(candidate)) {
+ candidate = fromLatin1;
+ improved = true;
+ }
+ }
+ catch (_error) { }
+ if (!improved) {
+ break;
}
}
- catch (_error) { }
- try {
- const fromLatin1 = Buffer.from(candidate, "latin1").toString("utf8");
- if (textMojibakeScoreForAddress(fromLatin1) > textMojibakeScoreForAddress(candidate)) {
- candidate = fromLatin1;
- }
- }
- catch (_error) { }
return candidate;
}
function extractAddressAnchorTokens(value) {
@@ -2413,6 +2450,30 @@ function readAddressFilterString(addressDebug, key) {
}
return toNonEmptyString(filters[key]);
}
+function isAddressLaneDebugPayload(debug) {
+ if (!debug || typeof debug !== "object") {
+ return false;
+ }
+ if (debug.detected_mode === "address_query") {
+ return true;
+ }
+ if (typeof debug.selected_recipe === "string" && debug.selected_recipe.trim().length > 0) {
+ return true;
+ }
+ if (typeof debug.mcp_call_status === "string" && debug.mcp_call_status.trim().length > 0) {
+ return true;
+ }
+ if (typeof debug.anchor_type === "string" && debug.anchor_type.trim().length > 0) {
+ return true;
+ }
+ if (debug.extracted_filters && typeof debug.extracted_filters === "object") {
+ const keys = Object.keys(debug.extracted_filters);
+ if (keys.length > 0 && typeof debug.detected_intent === "string" && debug.detected_intent.trim().length > 0) {
+ return true;
+ }
+ }
+ return false;
+}
function findLastAddressAssistantDebug(items) {
for (let index = items.length - 1; index >= 0; index -= 1) {
const item = items[index];
@@ -2420,7 +2481,7 @@ function findLastAddressAssistantDebug(items) {
continue;
}
const debug = item.debug;
- if (debug.detected_mode === "address_query" || debug.prompt_version === "address_query_runtime_v1") {
+ if (isAddressLaneDebugPayload(debug)) {
return debug;
}
}
@@ -2433,7 +2494,7 @@ function findRecentAddressFilterValue(items, key) {
continue;
}
const debug = item.debug;
- if (!(debug.detected_mode === "address_query" || debug.prompt_version === "address_query_runtime_v1")) {
+ if (!isAddressLaneDebugPayload(debug)) {
continue;
}
const directFilterValue = readAddressFilterString(debug, key);
@@ -2455,12 +2516,93 @@ function findRecentAddressFilterValue(items, key) {
}
return null;
}
+const ADDRESS_FOLLOWUP_OFFER_BY_INTENT = {
+ list_documents_by_counterparty: ["bank_operations_by_counterparty", "list_contracts_by_counterparty"],
+ bank_operations_by_counterparty: ["list_documents_by_counterparty", "list_contracts_by_counterparty"],
+ list_contracts_by_counterparty: ["list_documents_by_contract", "bank_operations_by_contract"],
+ list_documents_by_contract: ["bank_operations_by_contract"],
+ bank_operations_by_contract: ["list_documents_by_contract"],
+ open_items_by_counterparty_or_contract: ["list_documents_by_counterparty", "bank_operations_by_counterparty"]
+};
+function buildAddressFollowupOffer(addressDebug) {
+ if (!isAddressLaneDebugPayload(addressDebug)) {
+ return null;
+ }
+ const intent = toNonEmptyString(addressDebug.detected_intent);
+ if (!intent) {
+ return null;
+ }
+ const suggestedIntents = ADDRESS_FOLLOWUP_OFFER_BY_INTENT[intent];
+ if (!Array.isArray(suggestedIntents) || suggestedIntents.length === 0) {
+ return null;
+ }
+ const anchorType = toNonEmptyString(addressDebug.anchor_type);
+ const anchorValue = toNonEmptyString(addressDebug.anchor_value_resolved) ??
+ toNonEmptyString(addressDebug.anchor_value_raw) ??
+ readAddressFilterString(addressDebug, "counterparty") ??
+ readAddressFilterString(addressDebug, "contract") ??
+ readAddressFilterString(addressDebug, "account");
+ return {
+ enabled: true,
+ source_intent: intent,
+ anchor_type: anchorType ?? "unknown",
+ anchor_value: anchorValue,
+ suggested_intents: suggestedIntents
+ };
+}
+function hasAddressFollowupOffer(addressDebug) {
+ if (!addressDebug || typeof addressDebug !== "object") {
+ return false;
+ }
+ const existingOffer = addressDebug.address_followup_offer;
+ if (existingOffer && typeof existingOffer === "object") {
+ return existingOffer.enabled === true;
+ }
+ return Boolean(buildAddressFollowupOffer(addressDebug));
+}
+function isImplicitAddressContinuationByLlm(userMessage, llmPreDecomposeMeta) {
+ const contract = llmPreDecomposeMeta?.predecomposeContract;
+ if (!contract || typeof contract !== "object") {
+ return false;
+ }
+ const entities = contract.entities && typeof contract.entities === "object" ? contract.entities : {};
+ const hasEntity = [entities.account, entities.counterparty, entities.contract, entities.document_type, entities.document_ref, entities.organization]
+ .some((value) => Boolean(toNonEmptyString(value)));
+ if (hasEntity) {
+ return false;
+ }
+ const hasExplicitPeriod = Boolean(contract.period && typeof contract.period === "object" && contract.period.has_explicit_period);
+ if (hasExplicitPeriod) {
+ return false;
+ }
+ const mode = toNonEmptyString(contract.mode) ?? "unknown";
+ const modeConfidence = toNonEmptyString(contract.mode_confidence) ?? "low";
+ const intent = toNonEmptyString(contract.intent) ?? "unknown";
+ const intentConfidence = toNonEmptyString(contract.intent_confidence) ?? "low";
+ const shape = toNonEmptyString(contract.query_shape) ?? "UNKNOWN";
+ const shapeConfidence = toNonEmptyString(contract.query_shape_confidence) ?? "low";
+ const looksUnderspecified = (mode !== "address_query" || modeConfidence === "low") &&
+ (intent === "unknown" || intentConfidence === "low") &&
+ (shape === "UNKNOWN" || shapeConfidence === "low");
+ if (!looksUnderspecified) {
+ return false;
+ }
+ const normalized = compactWhitespace(repairAddressMojibake(String(userMessage ?? "")).toLowerCase());
+ if (!normalized || hasSmallTalkSignal(normalized) || /[??]$/.test(normalized)) {
+ return false;
+ }
+ const tokenCount = countTokens(normalized);
+ return tokenCount > 0 && tokenCount <= 4;
+}
function hasAddressFollowupContextSignal(userMessage) {
const repaired = repairAddressMojibake(String(userMessage ?? ""));
const text = compactWhitespace(repaired.toLowerCase());
if (!text) {
return false;
}
+ if (shouldHandleAsAssistantCapabilityMetaQuery(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;
}
@@ -2471,12 +2613,25 @@ function hasAddressFollowupContextSignal(userMessage) {
return true;
}
const shortFollowup = countTokens(text) <= 8;
+ if (/(?:кроме|помимо)\s+(?:этого|этой|этот|эту|этих|этого\s+документа|этого\s+договора|этого\s+контрагента)/iu.test(text)) {
+ return true;
+ }
+ if (/(?:есть\s+ещ[её]|что\s+ещ[её]|ещ[её]\s+что|ещ[её]\s+что-?то|остал(?:ось|ось\?)|друг(?:ое|ие))/iu.test(text) && countTokens(text) <= 12) {
+ return true;
+ }
if (shortFollowup && hasFollowupMarker(text)) {
return true;
}
if (shortFollowup && /(?:^|\s)(?:также|тоже|also|same|again|ещ[её]|теперь|then|now)(?=$|[\s,.;:!?])/iu.test(text)) {
return true;
}
+ if (shortFollowup &&
+ /(?:кто\s+из\s+(?:них|этих|тех)|кто\s+нов(?:ые|ых|ый)|кто\s+потом\s+исчез|кто\s+был\s+(?:только|ровно)\s+один\s+раз)/iu.test(text)) {
+ return true;
+ }
+ if (shortFollowup && /^(?:а|и)\s+кто\b/iu.test(text)) {
+ return true;
+ }
if (shortFollowup &&
/(?:^|\s)по\s+[a-zа-яё][a-zа-яё0-9._-]{1,}(?=$|[\s,.;:!?])/iu.test(text) &&
!/(?:по\s+этому|по\s+тому|по\s+нему|по\s+ней|по\s+ним)/iu.test(text)) {
@@ -2487,19 +2642,35 @@ function hasAddressFollowupContextSignal(userMessage) {
}
return false;
}
-function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMessage = null) {
+function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMessage = null, llmPreDecomposeMeta = null) {
+ const previousAddressDebug = findLastAddressAssistantDebug(items);
+ const followupOffer = previousAddressDebug ? buildAddressFollowupOffer(previousAddressDebug) : null;
+ const hasImplicitContinuationSignal = Boolean(previousAddressDebug) &&
+ Boolean(followupOffer?.enabled) &&
+ (isImplicitAddressContinuationByLlm(userMessage, llmPreDecomposeMeta) ||
+ (toNonEmptyString(alternateMessage) ? isImplicitAddressContinuationByLlm(alternateMessage, llmPreDecomposeMeta) : false));
const hasPrimaryFollowupSignal = hasAddressFollowupContextSignal(userMessage);
const hasAlternateFollowupSignal = toNonEmptyString(alternateMessage)
? hasAddressFollowupContextSignal(alternateMessage)
: false;
- if (!hasPrimaryFollowupSignal && !hasAlternateFollowupSignal) {
+ if (!hasPrimaryFollowupSignal && !hasAlternateFollowupSignal && !hasImplicitContinuationSignal) {
return null;
}
- const previousAddressDebug = findLastAddressAssistantDebug(items);
if (!previousAddressDebug) {
return null;
}
- const previousIntent = toNonEmptyString(previousAddressDebug.detected_intent);
+ const sourceIntent = toNonEmptyString(previousAddressDebug.detected_intent);
+ let previousIntent = sourceIntent;
+ let followupSelectionMode = "carry_previous_intent";
+ if (hasImplicitContinuationSignal) {
+ const suggestedIntent = Array.isArray(followupOffer?.suggested_intents)
+ ? toNonEmptyString(followupOffer.suggested_intents[0])
+ : null;
+ if (suggestedIntent) {
+ previousIntent = suggestedIntent;
+ followupSelectionMode = "switch_to_suggested_intent";
+ }
+ }
const previousAnchorType = toNonEmptyString(previousAddressDebug.anchor_type);
const previousAnchor = toNonEmptyString(previousAddressDebug.anchor_value_resolved) ??
toNonEmptyString(previousAddressDebug.anchor_value_raw) ??
@@ -2533,9 +2704,65 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
previous_anchor_value: previousAnchor
},
previousAddressIntent: previousIntent,
- previousAddressAnchor: previousAnchor
+ previousAddressAnchor: previousAnchor,
+ previousSourceIntent: sourceIntent,
+ followupSelectionMode,
+ hasImplicitContinuationSignal
};
}
+function buildAddressDialogContinuationContractV2(userMessage, effectiveMessage, carryoverMeta, llmPreDecomposeMeta) {
+ const sourceMessage = String(userMessage ?? "");
+ const canonicalMessage = String(effectiveMessage ?? sourceMessage);
+ const hasFollowupContext = Boolean(carryoverMeta?.followupContext);
+ const previousIntent = toNonEmptyString(carryoverMeta?.previousSourceIntent) ?? null;
+ const targetIntent = toNonEmptyString(carryoverMeta?.previousAddressIntent) ?? null;
+ const selectionMode = toNonEmptyString(carryoverMeta?.followupSelectionMode) ?? null;
+ const hasImplicitContinuationSignal = Boolean(carryoverMeta?.hasImplicitContinuationSignal);
+ const rewrittenByPredecompose = compactWhitespace(sourceMessage.toLowerCase()) !== compactWhitespace(canonicalMessage.toLowerCase());
+ const hasExplicitIntent = Boolean(toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.intent));
+ const decision = !hasFollowupContext
+ ? "new_topic"
+ : selectionMode === "switch_to_suggested_intent"
+ ? "switch_to_suggested"
+ : "continue_previous";
+ const reasons = [];
+ if (hasFollowupContext) {
+ reasons.push("followup_context_detected");
+ }
+ if (hasImplicitContinuationSignal) {
+ reasons.push("implicit_continuation_by_llm");
+ }
+ if (rewrittenByPredecompose) {
+ reasons.push("effective_message_rewritten_by_predecompose");
+ }
+ if (hasExplicitIntent) {
+ reasons.push("llm_contract_intent_available");
+ }
+ return {
+ schema_version: "address_dialog_continuation_contract_v2",
+ source_message: sourceMessage,
+ effective_message: canonicalMessage,
+ decision,
+ decision_reasons: reasons,
+ followup_context_applied: hasFollowupContext,
+ previous_intent: previousIntent,
+ target_intent: targetIntent,
+ intent_selection_mode: selectionMode,
+ anchor_type: carryoverMeta?.followupContext?.previous_anchor_type ?? null,
+ anchor_value: carryoverMeta?.followupContext?.previous_anchor_value ?? null,
+ implicit_continuation_signal: hasImplicitContinuationSignal
+ };
+}
+function isRetryableAddressLimitedResult(addressLane) {
+ if (!addressLane || !addressLane.handled) {
+ return false;
+ }
+ if (String(addressLane.reply_type ?? "").trim() !== "partial_coverage") {
+ return false;
+ }
+ const category = String(addressLane?.debug?.limited_reason_category ?? "").trim().toLowerCase();
+ return category === "missing_anchor" || category === "empty_match";
+}
function isAddressLlmPreDecomposeCandidate(userMessage) {
const repaired = repairAddressMojibake(String(userMessage ?? ""));
const text = compactWhitespace(repaired.toLowerCase());
@@ -2727,7 +2954,30 @@ const ADDRESS_PREDECOMPOSE_LOW_QUALITY_COUNTERPARTY_TOKENS = new Set([
"сводную",
"сводном",
"сводного",
- "сводному"
+ "сводному",
+ "кроме",
+ "помимо",
+ "этого",
+ "этот",
+ "эта",
+ "эту",
+ "этом",
+ "это",
+ "эти",
+ "этих",
+ "документ",
+ "документа",
+ "документы",
+ "документов",
+ "договор",
+ "договора",
+ "контрагент",
+ "контрагента",
+ "еще",
+ "ещё",
+ "другие",
+ "другое",
+ "остальное"
]);
const ADDRESS_PREDECOMPOSE_LOW_QUALITY_CONTRACT_TOKENS = new Set([
"за",
@@ -3129,17 +3379,62 @@ async function runAddressLlmPreDecompose(normalizerService, payload, userMessage
}, userMessage);
}
}
-function resolveAddressToolGateDecision(addressInputMessage, followupContext, llmPreDecomposeMeta = null) {
+function resolveAddressToolGateDecision(addressInputMessage, followupContext, llmPreDecomposeMeta = null, rawUserMessage = null) {
const repairedInputMessage = repairAddressMojibake(String(addressInputMessage ?? ""));
+ const rawMessageForGate = String(rawUserMessage ?? addressInputMessage ?? "");
+ const dataScopeMetaQuery = hasAssistantDataScopeMetaQuestionSignal(rawMessageForGate) ||
+ hasAssistantDataScopeMetaQuestionSignal(repairedInputMessage);
+ const capabilityMetaQuery = shouldHandleAsAssistantCapabilityMetaQuery(rawMessageForGate) ||
+ shouldHandleAsAssistantCapabilityMetaQuery(repairedInputMessage);
+ const dataRetrievalSignal = hasDataRetrievalRequestSignal(rawMessageForGate) ||
+ hasDataRetrievalRequestSignal(repairedInputMessage);
+ if (dataScopeMetaQuery || (capabilityMetaQuery && !dataRetrievalSignal)) {
+ return {
+ runAddressLane: false,
+ decision: "skip_address_lane",
+ reason: dataScopeMetaQuery ? "assistant_data_scope_query_detected" : "assistant_capability_query_detected"
+ };
+ }
const modeDetection = (0, addressQueryClassifier_1.detectAddressQuestionMode)(repairedInputMessage || addressInputMessage);
const hasClassifierSignal = modeDetection.mode === "address_query";
- const hasLlmCanonicalSignal = Boolean(llmPreDecomposeMeta?.llmCanonicalCandidateDetected);
- const hasMessageSignal = hasClassifierSignal ||
- hasLlmCanonicalSignal ||
- isAddressLlmPreDecomposeCandidate(addressInputMessage) ||
+ const llmContractMode = toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.mode);
+ const llmContractModeConfidence = toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.mode_confidence);
+ const llmContractIntent = toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.intent);
+ const llmContractIntentConfidence = toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.intent_confidence);
+ const hasLlmCanonicalSignal = Boolean(llmPreDecomposeMeta?.llmCanonicalCandidateDetected) &&
+ llmContractMode === "address_query" &&
+ llmContractModeConfidence !== "low" &&
+ llmContractIntent !== null &&
+ llmContractIntent !== "unknown" &&
+ llmContractIntentConfidence !== "low";
+ const hasLlmCanonicalDataSignal = Boolean(llmPreDecomposeMeta?.llmCanonicalCandidateDetected) &&
+ Boolean(llmPreDecomposeMeta?.applied) &&
+ hasStrongDataIntentSignal(repairedInputMessage);
+ const hasLexicalAddressSignal = isAddressLlmPreDecomposeCandidate(addressInputMessage) ||
isAddressLlmPreDecomposeCandidate(repairedInputMessage) ||
hasAccountingSignal(addressInputMessage) ||
hasAccountingSignal(repairedInputMessage);
+ const hasUnsupportedLowConfidencePredecomposeSignal = llmContractMode === "unsupported" &&
+ (llmContractModeConfidence === "low" || llmContractModeConfidence === "medium") &&
+ llmContractIntent === "unknown";
+ const hasAnyAddressSignal = hasClassifierSignal || hasLlmCanonicalSignal || hasLlmCanonicalDataSignal || hasLexicalAddressSignal;
+ const strongDataSignalFromRawMessage = hasStrongDataIntentSignal(rawMessageForGate) ||
+ hasAccountingSignal(rawMessageForGate) ||
+ hasSameDateAccountFollowupSignalForPredecompose(rawMessageForGate);
+ const strongDataSignalFromEffectiveMessage = hasStrongDataIntentSignal(repairedInputMessage) ||
+ hasAccountingSignal(repairedInputMessage) ||
+ hasDataRetrievalRequestSignal(repairedInputMessage);
+ if (hasUnsupportedLowConfidencePredecomposeSignal && !followupContext &&
+ !hasAnyAddressSignal &&
+ !strongDataSignalFromRawMessage &&
+ !strongDataSignalFromEffectiveMessage) {
+ return {
+ runAddressLane: false,
+ decision: "skip_address_lane",
+ reason: "llm_predecompose_unsupported_mode"
+ };
+ }
+ const hasMessageSignal = hasAnyAddressSignal;
if (hasMessageSignal) {
return {
runAddressLane: true,
@@ -3148,7 +3443,9 @@ function resolveAddressToolGateDecision(addressInputMessage, followupContext, ll
? "address_mode_classifier_detected"
: hasLlmCanonicalSignal
? "llm_canonical_candidate_detected"
- : "address_signal_detected"
+ : hasLlmCanonicalDataSignal
+ ? "llm_canonical_data_signal_detected"
+ : "address_signal_detected"
};
}
if (followupContext) {
@@ -3164,18 +3461,725 @@ function resolveAddressToolGateDecision(addressInputMessage, followupContext, ll
reason: "no_address_signal_after_l0"
};
}
+function hasStrongDataIntentSignal(text) {
+ const lower = String(text ?? "").toLowerCase();
+ return /(база|док|документ|проводк|контрагент|договор|контракт|счет|сч[её]т|остат|сальдо|хвост|платеж|плат[её]ж|операц|поставщик|клиент|заказчик|дебитор|кредитор|оборот|баланс|период|месяц|год|инн|mcp|bank|counterparty|contract|document|ledger|posting|account)/i.test(lower);
+}
+function hasDataRetrievalRequestSignal(text) {
+ const lower = compactWhitespace(String(text ?? "").toLowerCase());
+ if (!lower) {
+ return false;
+ }
+ const hasExplicitRetrievalAction = /(?:\bпокажи\b|\bпоказать\b|\bвыведи\b|\bнайди\b|\bсписок\b|\bдай\b|\bраскрой\b|\bshow\b|\blist\b|\bfind\b|\bcount\b)/i.test(lower);
+ const hasInterrogativeRetrievalAction = /(?:\bсколько\b|\bкакой\b|\bкакая\b|\bкакое\b|\bкакую\b|\bкакие\b|\bкто\b|\bwhich\b|\bwho\b)/i.test(lower);
+ if (!hasExplicitRetrievalAction && !hasInterrogativeRetrievalAction) {
+ return false;
+ }
+ const hasRetrievalObject = /(1с|база|док|документ|контрагент|договор|контракт|счет|сч[её]т|остат|сальдо|хвост|платеж|плат[её]ж|операц|поставщик|клиент|заказчик|дебитор|кредитор|период|месяц|год|инн|bank|counterparty|contract|document|account|balance|ledger|posting)/i.test(lower);
+ if (!hasRetrievalObject) {
+ return false;
+ }
+ if (hasExplicitRetrievalAction) {
+ return true;
+ }
+ const hasMetaCapabilityShape = /(?:мож(?:ем|ешь|ете|но)|уме(?:ешь|ете)|доступ|подключ|чья|как\s+называ(?:ет|ется)|работ(?:ать|аем|аешь|аете)|в\s+тебе|у\s+тебя)/i.test(lower);
+ return !hasMetaCapabilityShape;
+}
+function hasOperationalAdminActionRequestSignal(text) {
+ const lower = compactWhitespace(String(text ?? "").toLowerCase()).replace(/ё/g, "е");
+ const normalized = lower.replace(/\b1\s*[cс]\b/giu, "1с");
+ if (!normalized) {
+ return false;
+ }
+ const hasAdminVerb = /(?:настро(?:й|ить|ите|им)|установ(?:и|ить|ите)|подключ(?:и|ить|ите)|обнов(?:и|ить|ите)|почин(?:и|ить|ите)|исправ(?:ь|ить|ьте)|перенастро|перезапуст|удал(?:и|ить|ите|яй)|снес(?:и|ти)|очист(?:и|ить)|восстанов(?:и|ить)|созд(?:ай|ать)|провед(?:и|и)|заведи|завести)/i.test(normalized);
+ const hasAdminObject = /(?:1с|1c|баз|сервер|конфиг|конфигурац|платформ|админ|доступ|роль|права|db|database|документ)/i.test(normalized);
+ if (hasAdminVerb && hasAdminObject) {
+ return true;
+ }
+ return /(?:удаляй?\s+баз|удали\s+баз|снеси\s+баз|delete\s+database|drop\s+database)/i.test(normalized);
+}
+function hasDangerOrCoercionSignal(text) {
+ const lower = compactWhitespace(String(text ?? "").toLowerCase()).replace(/ё/g, "е");
+ if (!lower) {
+ return false;
+ }
+ return /(?:убьют|убьют|убью|убить|убиют|угрож|опасн|омон|полици|насили|заставля|принужда|шантаж)/i.test(lower);
+}
+function hasDestructiveDataActionSignal(text) {
+ const lower = compactWhitespace(String(text ?? "").toLowerCase()).replace(/ё/g, "е");
+ if (!lower) {
+ return false;
+ }
+ return /(?:удаляй?\s+баз|удали\s+баз|снеси\s+баз|drop\s+database|delete\s+database|очисти\s+баз)/i.test(lower);
+}
+function hasAssistantCapabilityQuestionSignal(text) {
+ const repaired = repairAddressMojibake(String(text ?? ""));
+ const lower = compactWhitespace(repaired.toLowerCase()).replace(/ё/g, "е");
+ const normalized = lower.replace(/\b1\s*[cс]\b/giu, "1с");
+ if (!lower) {
+ return false;
+ }
+ const directCapabilityPhrases = [
+ "кто ты",
+ "что ты можешь",
+ "что конкретно ты можешь",
+ "чем ты можешь помочь",
+ "что ты умеешь",
+ "какой у тебя функционал",
+ "какие у тебя функции",
+ "полный список возможностей",
+ "полный список"
+ ];
+ if (directCapabilityPhrases.some((phrase) => normalized.includes(phrase))) {
+ return true;
+ }
+ const hasCanVerb = /(?:можешь|можете|умеешь|умеете|можно)/i.test(normalized);
+ const hasControlAction = /(?:настро|установ|подключ|обнов|созда|подготов|сдела|делат|дела)/i.test(normalized);
+ const hasCapabilityObject = /(?:1с|1c|док|документ|баз|отчет|отч[её]т|конфигурац|настройк)/i.test(normalized);
+ if (hasCanVerb && hasControlAction && hasCapabilityObject) {
+ return true;
+ }
+ const hasCapabilityMetaQuestion = /(?:что|чем)\s+(?:ты\s+)?(?:мож(?:ешь|ете)|уме(?:ешь|ете)|можно)(?=$|[\s,.!?;:])/iu.test(normalized);
+ if (hasCapabilityMetaQuestion && hasCapabilityObject) {
+ return true;
+ }
+ if ((normalized.includes("1с") || normalized.includes("1c")) && hasCanVerb && /(?:настро|установ|подключ|обнов)/i.test(normalized)) {
+ return true;
+ }
+ return false;
+}
+function hasAssistantDataScopeMetaQuestionSignal(text) {
+ const repaired = repairAddressMojibake(String(text ?? ""));
+ const lower = compactWhitespace(repaired.toLowerCase()).replace(/ё/g, "е");
+ const normalized = lower.replace(/\b1\s*[cс]\b/giu, "1с");
+ if (!normalized) {
+ return false;
+ }
+ const hasBaseOrTenantObject = /(?:баз(?:а|е|у|ы)?|тенант|tenant|контур)/i.test(normalized);
+ const hasCompanyObject = /(?:компан(?:ия|ии|ию|ией)|компин(?:ия|ии|ию|ией)?|компини(?:я|и|ю|ей)?|компани[яеию]|организац(?:ия|ии|ию|ией)|контор(?:а|ы|у|ой)?|фирм(?:а|ы|у|ой)?)/i.test(normalized);
+ const hasConnectionCue = /(?:подключен(?:а|о|ы)?|подруб|воткнут|активн(?:ый|ая)\s+канал|mcp-?канал|канал)/i.test(normalized);
+ const hasNamingCue = /(?:как\s+называ(?:ет|ется)|что\s+за\s+(?:контор|компан|организац|фирм))/i.test(normalized);
+ const hasWorkabilityCue = /(?:мож(?:ем|ешь|ете|но)\s+работ|работ(?:ать|аем|аешь|аете))/i.test(normalized);
+ const hasScopeObject = hasBaseOrTenantObject || hasCompanyObject || hasConnectionCue;
+ if (!hasScopeObject) {
+ return false;
+ }
+ const hasMetaPerspective = /(?:ты|тебе|твой|у\s+тебя|в\s+тебе|мы|нам|наш(?:а|е|и|у|ей)?|сейчас|щас)/i.test(normalized);
+ const hasScopedInterrogativePair = /(?:^|\s)(?:по\s+какой|с\s+какой|какая|какой|какие)\s+(?:баз|компан|компин|компини|компани|организац|контор|фирм|тенант|контур)/i.test(normalized);
+ const hasScopeQuestion = /(?:чья|чье|чьи|доступн|подключен|подруб|воткнут|какая\s+баз|какой\s+баз)/i.test(normalized) ||
+ hasNamingCue ||
+ hasWorkabilityCue ||
+ hasScopedInterrogativePair;
+ const hasInterrogativeScopeLead = /(?:^|\s)(?:по\s+какой|с\s+какой|чья|чье|чьи|which|who|what)/i.test(normalized);
+ const isQuestionLike = /[?]/.test(String(text ?? "")) || hasInterrogativeScopeLead || hasScopedInterrogativePair;
+ const hasExplicitScopeContext = hasBaseOrTenantObject || hasConnectionCue || hasWorkabilityCue || hasNamingCue;
+ const hasRetrievalSignal = hasDataRetrievalRequestSignal(normalized);
+ const hasContractAnalyticsCue = /(?:договор|контракт|contract).*(?:топ|сам(?:ый|ая|ое|ые)|крупн|жирн|оборот|бюджет|сумм|стоим|value|turnover|all\s+time|всю\s+истори|за\s+вс[её]\s+время)/iu.test(normalized);
+ if (hasContractAnalyticsCue) {
+ return false;
+ }
+ if (hasRetrievalSignal && !hasExplicitScopeContext) {
+ return false;
+ }
+ const hasEligibleScopeObject = hasBaseOrTenantObject || (hasCompanyObject && (hasConnectionCue || hasWorkabilityCue || hasNamingCue || hasMetaPerspective));
+ return hasEligibleScopeObject && hasScopeQuestion && (hasMetaPerspective || isQuestionLike || hasExplicitScopeContext);
+}
+function shouldHandleAsAssistantCapabilityMetaQuery(text) {
+ const raw = String(text ?? "");
+ const repaired = repairAddressMojibake(raw);
+ const hasScopeMetaSignal = hasAssistantDataScopeMetaQuestionSignal(raw) || hasAssistantDataScopeMetaQuestionSignal(repaired);
+ if (hasScopeMetaSignal) {
+ return true;
+ }
+ const hasCapabilitySignal = hasAssistantCapabilityQuestionSignal(raw) ||
+ hasAssistantCapabilityQuestionSignal(repaired) ||
+ hasOperationalAdminActionRequestSignal(raw) ||
+ hasOperationalAdminActionRequestSignal(repaired);
+ const hasRetrievalSignal = hasDataRetrievalRequestSignal(raw) || hasDataRetrievalRequestSignal(repaired);
+ return hasCapabilitySignal && !hasRetrievalSignal;
+}
+function hasLivingChatSignal(text) {
+ const lower = compactWhitespace(String(text ?? "").toLowerCase());
+ if (!lower) {
+ return false;
+ }
+ if (/^(ага|угу|ок|окей|ясно|понял|поняла|принято|спасибо|благодарю|супер|класс|норм|го|давай|погнали|привет|хай|йо|hello|hi|thanks?)$/i.test(lower)) {
+ return true;
+ }
+ if (/(как дела|как ты|что нового|расскажи о себе|чем можешь помочь|давай поговорим|поговорим|обсудим|посоветуй|что думаешь)/i.test(lower)) {
+ return true;
+ }
+ return hasSmallTalkSignal(lower);
+}
+function buildLivingChatContextWindow(items) {
+ const source = Array.isArray(items) ? items.slice(-6) : [];
+ const lines = [];
+ for (const item of source) {
+ if (!item || typeof item !== "object") {
+ continue;
+ }
+ const role = String(item.role ?? "").trim();
+ const text = compactWhitespace(String(item.text ?? ""));
+ if (!role || !text) {
+ continue;
+ }
+ const clipped = text.length > 220 ? `${text.slice(0, 220)}...` : text;
+ lines.push(`${role}: ${clipped}`);
+ }
+ return lines.join("\n");
+}
+function buildLivingChatPrompt(userMessage, conversationWindow) {
+ const contextBlock = conversationWindow
+ ? `Контекст последних сообщений:\n${conversationWindow}\n\n`
+ : "";
+ return `${contextBlock}Сообщение пользователя:\n${userMessage}`;
+}
+function buildAssistantCapabilityContractReply() {
+ return [
+ "Я ассистент по анализу данных 1С в режиме чтения.",
+ "Что умею сейчас:",
+ "1. Находить документы, операции, договоры и остатки по контрагенту/договору/периоду.",
+ "2. Делать агрегаты по базе: активность, роли контрагентов, top-срезы по суммам и операциям.",
+ "3. Кратко объяснять результат и подсказывать следующий точный запрос.",
+ "Что не умею:",
+ "1. Не настраиваю 1С и не меняю конфигурацию.",
+ "2. Не создаю и не провожу документы в базе.",
+ "3. Не выполняю админские действия на сервере."
+ ].join("\n");
+}
+function normalizeScopeLabel(value) {
+ const repaired = repairAddressMojibake(String(value ?? ""));
+ let normalized = compactWhitespace(repaired.trim());
+ for (let index = 0; index < 2; index += 1) {
+ const first = normalized[0];
+ const last = normalized[normalized.length - 1];
+ const wrappedInQuotes = (first === "\"" && last === "\"") ||
+ (first === "'" && last === "'") ||
+ (first === "«" && last === "»");
+ if (!wrappedInQuotes) {
+ break;
+ }
+ normalized = compactWhitespace(normalized.slice(1, -1).trim());
+ }
+ if (!normalized) {
+ return null;
+ }
+ if (/^(?:null|undefined|nan|0|не\s*заполнено)$/i.test(normalized)) {
+ return null;
+ }
+ return normalized;
+}
+function normalizeScopeKey(value) {
+ return repairAddressMojibake(String(value ?? "")).toLowerCase().replace(/ё/g, "е");
+}
+function normalizeGuidValue(value) {
+ const source = normalizeScopeLabel(value);
+ if (!source) {
+ return null;
+ }
+ const match = source.match(/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/i);
+ return match ? String(match[0]).toLowerCase() : null;
+}
+function extractGuidValuesFromText(value) {
+ const source = normalizeScopeLabel(value);
+ if (!source) {
+ return [];
+ }
+ const matches = source.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/ig);
+ if (!matches || matches.length === 0) {
+ return [];
+ }
+ return Array.from(new Set(matches.map((item) => String(item).toLowerCase())));
+}
+function hasOrganizationKeyHint(key) {
+ const source = String(key ?? "");
+ return /(?:организац|organization|company|контор|org|ð¾ñð³ð°ð½ð¸ð·ð°ñ)/i.test(source);
+}
+function hasNameKeyHint(key) {
+ const source = String(key ?? "");
+ return /(?:представ|наимен|name|title|display|presentation|description|ð¿ñðµð´ññð°ð²|ð½ð°ð¸ð¼ðµð½)/i.test(source);
+}
+function hasGuidKeyHint(key) {
+ const source = String(key ?? "");
+ return /(?:идентифик|guid|uuid|key|ref|ссылк|\bid\b|ð¸ð´ðµð½ñð¸ñ|ñññð»)/i.test(source);
+}
+function looksLikeOrganizationTypeMarker(value) {
+ const normalized = normalizeScopeKey(value);
+ const raw = String(value ?? "").toLowerCase();
+ return /(?:справочникссылка\.\s*организац|catalogref\.\s*organization|организац|organization|company|ð¾ñð³ð°ð½ð¸ð·ð°ñ|ð¡ð¿ñð°ð²ð¾ñð½ð¸ðºð¡ññð»ðºð°\.ðñð³ð°ð½ð¸ð·)/i.test(normalized)
+ || /(?:ð¾ñð³ð°ð½ð¸ð·ð°ñ|ð¡ð¿ñð°ð²ð¾ñð½ð¸ðºð¡ññð»ðºð°\.ðñð³ð°ð½ð¸ð·)/i.test(raw);
+}
+function isPlausibleOrganizationName(value) {
+ const candidate = normalizeScopeLabel(value);
+ if (!candidate) {
+ return false;
+ }
+ if (/^(?:период|регистратор|счетдт|счеткт|amount|period|registrator|accountdt|accountkt)$/i.test(candidate)) {
+ return false;
+ }
+ if (/^[0-9._:/\\-]+$/i.test(candidate)) {
+ return false;
+ }
+ if (/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i.test(candidate)) {
+ return false;
+ }
+ if (/(?:справочникссылка|документссылка|плансчетовссылка|standardodata|recordtype|cmp:)/i.test(candidate)) {
+ return false;
+ }
+ return /[A-Za-zА-Яа-яЁё]/u.test(candidate);
+}
+function appendOrganizationFactsFromValue(value, hints, bucket, depth = 0) {
+ if (depth > 4 || value === null || value === undefined) {
+ return;
+ }
+ if (typeof value === "string") {
+ for (const guid of extractGuidValuesFromText(value)) {
+ if (hints.guidHint || hints.organizationHint || hints.nameHint) {
+ bucket.refs.push(guid);
+ }
+ }
+ if ((hints.organizationHint || hints.nameHint) && isPlausibleOrganizationName(value)) {
+ const normalized = normalizeScopeLabel(value);
+ if (normalized) {
+ bucket.names.push(normalized);
+ }
+ }
+ return;
+ }
+ if (Array.isArray(value)) {
+ for (const item of value) {
+ appendOrganizationFactsFromValue(item, hints, bucket, depth + 1);
+ }
+ return;
+ }
+ if (typeof value === "object") {
+ const entries = Object.entries(value);
+ let objectIsOrganization = false;
+ let hasObjectRefMarker = false;
+ let hasGuidLikeField = false;
+ let hasTypeMarker = false;
+ for (const [rawKey, rawVal] of entries) {
+ const key = normalizeScopeKey(rawKey);
+ if ((key.includes("objectref") || key.includes("_objectref")) && rawVal === true) {
+ hasObjectRefMarker = true;
+ }
+ if (typeof rawVal === "string" && normalizeGuidValue(rawVal)) {
+ hasGuidLikeField = true;
+ }
+ if (hasOrganizationKeyHint(key)) {
+ objectIsOrganization = true;
+ break;
+ }
+ if ((key.includes("типобъекта") || key.includes("type")) && typeof rawVal === "string" && looksLikeOrganizationTypeMarker(rawVal)) {
+ objectIsOrganization = true;
+ hasTypeMarker = true;
+ break;
+ }
+ }
+ if (!objectIsOrganization && hasObjectRefMarker && hasGuidLikeField) {
+ const hasNameLikeValue = entries.some(([rawKey, rawVal]) => {
+ if (typeof rawVal !== "string") {
+ return false;
+ }
+ const key = normalizeScopeKey(rawKey);
+ return hasNameKeyHint(key) || isPlausibleOrganizationName(rawVal);
+ });
+ if (hasTypeMarker || hasNameLikeValue) {
+ objectIsOrganization = true;
+ }
+ }
+ for (const [rawKey, rawVal] of entries) {
+ if (String(rawKey ?? "").startsWith("__")) {
+ continue;
+ }
+ const key = normalizeScopeKey(rawKey);
+ const childHints = {
+ organizationHint: hints.organizationHint || objectIsOrganization || hasOrganizationKeyHint(key),
+ nameHint: hints.nameHint || objectIsOrganization || hasNameKeyHint(key),
+ guidHint: hints.guidHint || objectIsOrganization || hasGuidKeyHint(key)
+ };
+ if (typeof rawVal === "string") {
+ const guid = normalizeGuidValue(rawVal);
+ if (guid && childHints.guidHint) {
+ bucket.refs.push(guid);
+ }
+ }
+ appendOrganizationFactsFromValue(rawVal, childHints, bucket, depth + 1);
+ }
+ }
+}
+function extractOrganizationFactsFromRows(rows) {
+ const names = [];
+ const refs = [];
+ const pairs = [];
+ for (const row of Array.isArray(rows) ? rows : []) {
+ if (!row || typeof row !== "object") {
+ continue;
+ }
+ const rowNames = [];
+ const rowRefs = [];
+ for (const [rawKey, rawValue] of Object.entries(row)) {
+ if (String(rawKey ?? "").startsWith("__")) {
+ continue;
+ }
+ const key = normalizeScopeKey(rawKey);
+ const hints = {
+ organizationHint: hasOrganizationKeyHint(key),
+ nameHint: hasNameKeyHint(key),
+ guidHint: hasGuidKeyHint(key)
+ };
+ appendOrganizationFactsFromValue(rawValue, hints, {
+ names: rowNames,
+ refs: rowRefs
+ });
+ }
+ const dedupRowNames = Array.from(new Set(rowNames))
+ .filter((item) => isPlausibleOrganizationName(item))
+ .slice(0, 20);
+ const dedupRowRefs = Array.from(new Set(rowRefs))
+ .map((item) => String(item ?? "").toLowerCase())
+ .filter((item) => /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(item))
+ .slice(0, 20);
+ if (dedupRowNames.length === 0 && dedupRowRefs.length === 0) {
+ const fallbackBucket = { names: [], refs: [] };
+ appendOrganizationFactsFromValue(row, {
+ organizationHint: true,
+ nameHint: true,
+ guidHint: true
+ }, fallbackBucket);
+ for (const value of fallbackBucket.names) {
+ if (isPlausibleOrganizationName(value)) {
+ dedupRowNames.push(value);
+ }
+ }
+ for (const value of fallbackBucket.refs) {
+ const normalized = String(value ?? "").toLowerCase();
+ if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(normalized)) {
+ dedupRowRefs.push(normalized);
+ }
+ }
+ }
+ for (const name of dedupRowNames) {
+ names.push(name);
+ }
+ for (const ref of dedupRowRefs) {
+ refs.push(ref);
+ }
+ if (dedupRowRefs.length > 0 && dedupRowNames.length > 0) {
+ for (const ref of dedupRowRefs) {
+ for (const name of dedupRowNames) {
+ pairs.push({ ref, name });
+ }
+ }
+ }
+ }
+ return {
+ names: Array.from(new Set(names)).slice(0, 20),
+ refs: Array.from(new Set(refs)).slice(0, 20),
+ pairs: Array.from(new Set(pairs.map((item) => `${item.ref}||${item.name}`)))
+ .map((token) => {
+ const [ref, name] = token.split("||");
+ return { ref, name };
+ })
+ .slice(0, 100)
+ };
+}
+function resolveOrganizationNamesByRefs(refs, facts) {
+ const refSet = new Set((Array.isArray(refs) ? refs : [])
+ .map((item) => String(item ?? "").toLowerCase())
+ .filter((item) => item.length > 0));
+ if (refSet.size === 0) {
+ return [];
+ }
+ const names = [];
+ for (const pair of Array.isArray(facts?.pairs) ? facts.pairs : []) {
+ const ref = String(pair?.ref ?? "").toLowerCase();
+ const name = normalizeScopeLabel(pair?.name ?? "");
+ if (!ref || !name || !refSet.has(ref)) {
+ continue;
+ }
+ names.push(name);
+ }
+ return Array.from(new Set(names)).slice(0, 20);
+}
+function buildResolvedDataScopeProbe(status, organizations) {
+ return {
+ status,
+ channel: config_1.ASSISTANT_MCP_CHANNEL,
+ organizations: Array.from(new Set(Array.isArray(organizations) ? organizations : [])).slice(0, 20),
+ error: null
+ };
+}
+function extractOrganizationFactsFromRowsForTests(rows) {
+ return extractOrganizationFactsFromRows(rows);
+}
+function resolveOrganizationNamesByRefsForTests(refs, facts) {
+ return resolveOrganizationNamesByRefs(refs, facts);
+}
+async function resolveAssistantDataScopeProbe() {
+ const cacheKey = `${config_1.ASSISTANT_MCP_PROXY_URL}|${config_1.ASSISTANT_MCP_CHANNEL}`;
+ const now = Date.now();
+ const cached = dataScopeProbeCache.get(cacheKey);
+ if (cached && typeof cached === "object" && Number(cached.expiresAt ?? 0) > now) {
+ return cached.value;
+ }
+ if (String(process.env.NODE_ENV ?? "").toLowerCase() === "test") {
+ return {
+ status: "skipped_test_env",
+ channel: config_1.ASSISTANT_MCP_CHANNEL,
+ organizations: [],
+ error: null
+ };
+ }
+ const catalogQueryCandidates = [
+ "ВЫБРАТЬ ПЕРВЫЕ 20 ПРЕДСТАВЛЕНИЕ(Организации.Ссылка) КАК Организация ИЗ Справочник.Организации КАК Организации",
+ "ВЫБРАТЬ ПЕРВЫЕ 20 Организации.Наименование КАК Организация ИЗ Справочник.Организации КАК Организации",
+ "ВЫБРАТЬ ПЕРВЫЕ 20 Организации.НаименованиеПолное КАК Организация ИЗ Справочник.Организации КАК Организации",
+ "ВЫБРАТЬ ПЕРВЫЕ 100 Организации.Ссылка КАК Организация, ПРЕДСТАВЛЕНИЕ(Организации.Ссылка) КАК ОрганизацияПредставление ИЗ Справочник.Организации КАК Организации"
+ ];
+ const movementProbeCandidates = [
+ "ВЫБРАТЬ ПЕРВЫЕ 60 Движения.Организация КАК Организация, ПРЕДСТАВЛЕНИЕ(Движения.Организация) КАК ОрганизацияПредставление ИЗ РегистрБухгалтерии.Хозрасчетный КАК Движения УПОРЯДОЧИТЬ ПО Движения.Период УБЫВ",
+ "ВЫБРАТЬ ПЕРВЫЕ 60 Движения.Организация КАК Организация ИЗ РегистрБухгалтерии.Хозрасчетный КАК Движения"
+ ];
+ let lastError = null;
+ const catalogFacts = { names: [], refs: [], pairs: [] };
+ for (const queryText of catalogQueryCandidates) {
+ const probe = await (0, addressMcpClient_1.executeAddressMcpQuery)({
+ query: queryText,
+ limit: 100
+ });
+ if (probe.error) {
+ lastError = probe.error;
+ continue;
+ }
+ const facts = extractOrganizationFactsFromRows(probe.rows);
+ catalogFacts.names.push(...facts.names);
+ catalogFacts.refs.push(...facts.refs);
+ catalogFacts.pairs.push(...facts.pairs);
+ if (facts.names.length > 0) {
+ const resolved = buildResolvedDataScopeProbe("resolved", facts.names);
+ dataScopeProbeCache.set(cacheKey, {
+ expiresAt: now + DATA_SCOPE_CACHE_TTL_MS,
+ value: resolved
+ });
+ return resolved;
+ }
+ }
+ const movementFacts = { names: [], refs: [], pairs: [] };
+ for (const queryText of movementProbeCandidates) {
+ const probe = await (0, addressMcpClient_1.executeAddressMcpQuery)({
+ query: queryText,
+ limit: 60
+ });
+ if (probe.error) {
+ lastError = probe.error;
+ continue;
+ }
+ const facts = extractOrganizationFactsFromRows(probe.rows);
+ movementFacts.names.push(...facts.names);
+ movementFacts.refs.push(...facts.refs);
+ movementFacts.pairs.push(...facts.pairs);
+ if (facts.names.length > 0) {
+ const resolved = buildResolvedDataScopeProbe("resolved_from_activity", facts.names);
+ dataScopeProbeCache.set(cacheKey, {
+ expiresAt: now + DATA_SCOPE_CACHE_TTL_MS,
+ value: resolved
+ });
+ return resolved;
+ }
+ }
+ const movementRefs = Array.from(new Set(movementFacts.refs))
+ .map((item) => String(item ?? "").toLowerCase())
+ .filter((item) => item.length > 0);
+ if (movementRefs.length > 0) {
+ const namesFromCatalogPairs = resolveOrganizationNamesByRefs(movementRefs, {
+ names: Array.from(new Set(catalogFacts.names)),
+ refs: Array.from(new Set(catalogFacts.refs)),
+ pairs: catalogFacts.pairs
+ });
+ if (namesFromCatalogPairs.length > 0) {
+ const resolved = buildResolvedDataScopeProbe("resolved_from_ref_lookup", namesFromCatalogPairs);
+ dataScopeProbeCache.set(cacheKey, {
+ expiresAt: now + DATA_SCOPE_CACHE_TTL_MS,
+ value: resolved
+ });
+ return resolved;
+ }
+ }
+ const fallback = {
+ status: lastError ? "unresolved_with_error" : "unresolved",
+ channel: config_1.ASSISTANT_MCP_CHANNEL,
+ organizations: [],
+ error: lastError
+ };
+ dataScopeProbeCache.set(cacheKey, {
+ expiresAt: now + DATA_SCOPE_CACHE_TTL_MS,
+ value: fallback
+ });
+ return fallback;
+}
+function buildAssistantDataScopeContractReply(scopeProbe = null) {
+ const channel = String(scopeProbe?.channel ?? config_1.ASSISTANT_MCP_CHANNEL ?? "default");
+ const organizations = Array.isArray(scopeProbe?.organizations) ? scopeProbe.organizations : [];
+ if (organizations.length === 1) {
+ return [
+ `Сейчас в активном MCP-канале \`${channel}\` доступна организация: ${organizations[0]}.`,
+ "Работаю в read-only режиме. Могу сразу показать по этой организации документы, операции, договоры или остатки."
+ ].join(" ");
+ }
+ if (organizations.length > 1) {
+ const preview = organizations.slice(0, 10).join(", ");
+ return [
+ `Сейчас в активном MCP-канале \`${channel}\` доступны организации (${organizations.length}): ${preview}.`,
+ "Работаю в read-only режиме. Скажи, по какой организации смотреть документы/операции."
+ ].join(" ");
+ }
+ if (scopeProbe?.status === "unresolved_with_error" && scopeProbe?.error) {
+ return [
+ `Не смог прочитать название организации из live MCP-канала \`${channel}\`: ${scopeProbe.error}.`,
+ "Работаю в read-only режиме и вижу только данные активного контура. Проверь подключение MCP/1С, после этого сразу назову контору."
+ ].join(" ");
+ }
+ return [
+ `Работаю в read-only режиме и вижу только те данные, которые отдает текущий MCP-канал \`${channel}\`.`,
+ "Словарь компаний не зашит в код: рабочий контур определяется live-подключением.",
+ "Если подключено несколько баз, для автосписка нужен MCP-метод метаданных (перечень баз/организаций); без него можно анализировать только активный контур запросов."
+ ].join(" ");
+}
+function buildAssistantOperationalBoundaryReply() {
+ return [
+ "Понимаю, что ситуация срочная.",
+ "Я не могу сам настраивать 1С или менять базу/конфигурацию.",
+ "Могу помочь безопасно: разберем симптомы и подготовим точные шаги для вашего 1С/ИТ-админа."
+ ].join(" ");
+}
+function buildAssistantSafetyRefusalReply() {
+ return [
+ "Я не могу помогать с удалением базы или скрытием данных.",
+ "Если вам угрожает опасность, срочно звоните 112 и следуйте указаниям экстренных служб.",
+ "По 1С могу дать только безопасные диагностические рекомендации."
+ ].join(" ");
+}
+function containsCjkChars(text) {
+ const source = String(text ?? "");
+ if (!source) {
+ return false;
+ }
+ return /[\u3400-\u9FFF\uF900-\uFAFF]/u.test(source);
+}
+function containsLetterLikeChars(text) {
+ const source = String(text ?? "");
+ if (!source) {
+ return false;
+ }
+ return /[A-Za-z\u0400-\u04FF]/u.test(source);
+}
+function applyLivingChatScriptGuard(chatText, userMessage) {
+ const source = String(chatText ?? "").trim();
+ if (!source) {
+ return {
+ text: "",
+ applied: false,
+ reason: null
+ };
+ }
+ if (!containsCjkChars(source) || containsCjkChars(userMessage)) {
+ return {
+ text: source,
+ applied: false,
+ reason: null
+ };
+ }
+ const stripped = source
+ .replace(/[\u3400-\u9FFF\uF900-\uFAFF]+/gu, "")
+ .replace(/[,。!?;:]/gu, "")
+ .replace(/\s{2,}/g, " ")
+ .replace(/\s+([,.!?;:])/g, "$1")
+ .trim();
+ if (stripped && containsLetterLikeChars(stripped)) {
+ return {
+ text: stripped,
+ applied: true,
+ reason: "unexpected_cjk_fragment_stripped"
+ };
+ }
+ return {
+ text: "Понял. Сформулируйте, что именно нужно по данным 1С, и я помогу по шагам.",
+ applied: true,
+ reason: "unexpected_cjk_fragment_fallback"
+ };
+}
+function resolveLivingAssistantModeDecision(input) {
+ const userMessage = String(input?.userMessage ?? "");
+ if (input?.addressLaneTriggered) {
+ return {
+ mode: "address_data",
+ reason: "address_lane_triggered"
+ };
+ }
+ if (!config_1.FEATURE_ASSISTANT_LIVING_CHAT_ROUTER_V1) {
+ return {
+ mode: "deep_analysis",
+ reason: "living_chat_router_disabled"
+ };
+ }
+ if (Boolean(input?.useMock)) {
+ return {
+ mode: "deep_analysis",
+ reason: "mock_mode_keeps_deep_pipeline"
+ };
+ }
+ if (hasAssistantDataScopeMetaQuestionSignal(userMessage)) {
+ return {
+ mode: "chat",
+ reason: "assistant_data_scope_query_detected"
+ };
+ }
+ if (shouldHandleAsAssistantCapabilityMetaQuery(userMessage)) {
+ return {
+ mode: "chat",
+ reason: "assistant_capability_query_detected"
+ };
+ }
+ if (hasStrongDataIntentSignal(userMessage)) {
+ return {
+ mode: "deep_analysis",
+ reason: "strong_data_signal_detected"
+ };
+ }
+ if (hasLivingChatSignal(userMessage)) {
+ return {
+ mode: "chat",
+ reason: "living_chat_signal_detected"
+ };
+ }
+ const predecomposeMode = toNonEmptyString(input?.predecomposeMode);
+ const predecomposeConfidence = toNonEmptyString(input?.predecomposeModeConfidence);
+ if (predecomposeMode === "unsupported" && (predecomposeConfidence === "low" || predecomposeConfidence === "medium")) {
+ return {
+ mode: "chat",
+ reason: "predecompose_unsupported_mode"
+ };
+ }
+ return {
+ mode: "deep_analysis",
+ reason: "default_deep_pipeline"
+ };
+}
class AssistantService {
normalizerService;
sessions;
dataLayer;
sessionLogger;
addressQueryService;
- constructor(normalizerService, sessions, dataLayer = new assistantDataLayer_1.AssistantDataLayer(), sessionLogger = new assistantSessionLogger_1.AssistantSessionLogger(), addressQueryService = new addressQueryService_1.AddressQueryService()) {
+ chatClient;
+ constructor(normalizerService, sessions, dataLayer = new assistantDataLayer_1.AssistantDataLayer(), sessionLogger = new assistantSessionLogger_1.AssistantSessionLogger(), addressQueryService = new addressQueryService_1.AddressQueryService(), chatClient = new openaiResponsesClient_1.OpenAIResponsesClient()) {
this.normalizerService = normalizerService;
this.sessions = sessions;
this.dataLayer = dataLayer;
this.sessionLogger = sessionLogger;
this.addressQueryService = addressQueryService;
+ this.chatClient = chatClient;
}
getSession(sessionId) {
return this.sessions.getSession(sessionId);
@@ -3198,6 +4202,10 @@ class AssistantService {
const finalizeAddressLaneResponse = (addressLane, effectiveAddressUserMessage, carryoverMeta = null, llmPreDecomposeMeta = null) => {
const safeAddressReply = String((0, answerComposer_1.sanitizeAssistantReplyForUserFacing)(addressLane.reply_text) ?? "").trim() || String(addressLane.reply_text ?? "");
const debug = buildAddressDebugPayload(addressLane.debug, llmPreDecomposeMeta);
+ const followupOffer = buildAddressFollowupOffer(debug);
+ if (followupOffer) {
+ debug.address_followup_offer = followupOffer;
+ }
const assistantItem = {
message_id: `msg-${(0, nanoid_1.nanoid)(10)}`,
session_id: sessionId,
@@ -3238,6 +4246,12 @@ class AssistantService {
address_sanitized_user_message: llmPreDecomposeMeta?.sanitizedUserMessage ?? null,
address_tool_gate_decision: llmPreDecomposeMeta?.toolGateDecision ?? null,
address_tool_gate_reason: llmPreDecomposeMeta?.toolGateReason ?? null,
+ address_dialog_continuation_decision: llmPreDecomposeMeta?.dialogContinuationContract?.decision ?? null,
+ address_dialog_continuation_target_intent: llmPreDecomposeMeta?.dialogContinuationContract?.target_intent ?? null,
+ address_retry_attempted: Boolean(llmPreDecomposeMeta?.addressRetryAudit?.attempted),
+ address_retry_reason: llmPreDecomposeMeta?.addressRetryAudit?.reason ?? null,
+ address_retry_initial_limited_category: llmPreDecomposeMeta?.addressRetryAudit?.initial_limited_category ?? null,
+ address_retry_result_category: llmPreDecomposeMeta?.addressRetryAudit?.retry_result_category ?? null,
address_llm_predecompose_contract_intent: llmPreDecomposeMeta?.predecomposeContract?.intent ?? null,
address_llm_predecompose_contract_aggregation_profile: llmPreDecomposeMeta?.predecomposeContract?.aggregation_profile ?? null,
address_llm_predecompose_contract_period_scope: llmPreDecomposeMeta?.predecomposeContract?.period?.scope ?? null,
@@ -3285,6 +4299,160 @@ class AssistantService {
conversation
};
};
+ const tryHandleLivingChat = async (modeDecision, addressRuntimeMeta = null) => {
+ try {
+ const dataScopeMetaQuery = hasAssistantDataScopeMetaQuestionSignal(userMessage);
+ const capabilityMetaQuery = shouldHandleAsAssistantCapabilityMetaQuery(userMessage);
+ const destructiveSignal = hasDestructiveDataActionSignal(userMessage);
+ const dangerSignal = hasDangerOrCoercionSignal(userMessage);
+ const operationalSignal = hasOperationalAdminActionRequestSignal(userMessage);
+ let dataScopeProbe = null;
+ let chatText = "";
+ let livingChatSource = "llm_chat";
+ let livingChatScriptGuardApplied = false;
+ let livingChatScriptGuardReason = null;
+ if (capabilityMetaQuery && (destructiveSignal || dangerSignal)) {
+ chatText = buildAssistantSafetyRefusalReply();
+ livingChatSource = "deterministic_safety_refusal";
+ }
+ else if (dataScopeMetaQuery) {
+ dataScopeProbe = await resolveAssistantDataScopeProbe();
+ chatText = buildAssistantDataScopeContractReply(dataScopeProbe);
+ livingChatSource = dataScopeProbe?.status === "resolved"
+ ? "deterministic_data_scope_contract_live"
+ : "deterministic_data_scope_contract";
+ }
+ else if (capabilityMetaQuery && operationalSignal && !hasAssistantCapabilityQuestionSignal(userMessage)) {
+ chatText = buildAssistantOperationalBoundaryReply();
+ livingChatSource = "deterministic_operational_boundary";
+ }
+ else if (capabilityMetaQuery) {
+ chatText = buildAssistantCapabilityContractReply();
+ livingChatSource = "deterministic_capability_contract";
+ }
+ else {
+ const conversationWindow = buildLivingChatContextWindow(session.items);
+ const userPrompt = buildLivingChatPrompt(userMessage, conversationWindow);
+ const chatResponse = await this.chatClient.chat({
+ llmProvider: payload.llmProvider,
+ apiKey: String(payload.apiKey ?? process.env.OPENAI_API_KEY ?? ""),
+ model: String(payload.model ?? config_1.DEFAULT_MODEL),
+ baseUrl: payload.baseUrl ?? config_1.DEFAULT_OPENAI_BASE_URL,
+ temperature: payload.temperature ?? 0.35,
+ maxOutputTokens: Math.max(120, Math.min(Number(payload.maxOutputTokens ?? 420), 900))
+ }, {
+ systemPrompt: [
+ "Ты живой русскоязычный ассистент для чтения и анализа данных 1С.",
+ "Работай честно: не заявляй действия, которые недоступны в этом рантайме.",
+ "Разрешено: анализ и объяснение данных, формулировка запросов, подсказки по следующему шагу.",
+ "Запрещено: обещать настройку 1С, админ-действия, создание/проведение документов или любые изменения в базе.",
+ "Если пользователь спрашивает про возможности, отвечай только по этому контракту."
+ ].join(" "),
+ developerPrompt: "Формат: коротко и по сути, без JSON и без служебных блоков. Пиши человеко-понятно.",
+ userMessage: userPrompt,
+ maxOutputTokens: Math.max(120, Math.min(Number(payload.maxOutputTokens ?? 420), 900)),
+ temperature: payload.temperature ?? 0.35
+ });
+ chatText = String((0, answerComposer_1.sanitizeAssistantReplyForUserFacing)(chatResponse?.outputText ?? "") ?? "").trim();
+ const scriptGuard = applyLivingChatScriptGuard(chatText, userMessage);
+ chatText = scriptGuard.text;
+ if (scriptGuard.applied) {
+ livingChatScriptGuardApplied = true;
+ livingChatScriptGuardReason = scriptGuard.reason;
+ livingChatSource = "llm_chat_script_guard";
+ }
+ }
+ if (!chatText) {
+ return null;
+ }
+ const debug = {
+ trace_id: `chat-${(0, nanoid_1.nanoid)(10)}`,
+ prompt_version: "living_chat_router_v1",
+ schema_version: "living_chat_router_v1",
+ fallback_type: "none",
+ detected_mode: "chat",
+ detected_mode_confidence: "high",
+ execution_lane: "living_chat",
+ living_router_mode: modeDecision?.mode ?? "chat",
+ living_router_reason: modeDecision?.reason ?? "living_chat_signal_detected",
+ living_chat_response_source: livingChatSource,
+ living_chat_script_guard_applied: livingChatScriptGuardApplied,
+ living_chat_script_guard_reason: livingChatScriptGuardReason,
+ living_chat_data_scope_probe_status: dataScopeProbe?.status ?? null,
+ living_chat_data_scope_probe_channel: dataScopeProbe?.channel ?? null,
+ living_chat_data_scope_probe_org_count: Array.isArray(dataScopeProbe?.organizations)
+ ? dataScopeProbe.organizations.length
+ : 0,
+ living_chat_data_scope_probe_error: dataScopeProbe?.error ?? null,
+ address_llm_predecompose_attempted: Boolean(addressRuntimeMeta?.attempted),
+ address_llm_predecompose_applied: Boolean(addressRuntimeMeta?.applied),
+ address_llm_predecompose_reason: addressRuntimeMeta?.reason ?? null,
+ address_llm_predecompose_contract: addressRuntimeMeta?.predecomposeContract ?? null,
+ tool_gate_decision: addressRuntimeMeta?.toolGateDecision ?? null,
+ tool_gate_reason: addressRuntimeMeta?.toolGateReason ?? null,
+ normalized: null,
+ normalizer_output: null
+ };
+ const assistantItem = {
+ message_id: `msg-${(0, nanoid_1.nanoid)(10)}`,
+ session_id: sessionId,
+ role: "assistant",
+ text: chatText,
+ reply_type: "factual_with_explanation",
+ 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_chat",
+ details: {
+ session_id: sessionId,
+ message_id: assistantItem.message_id,
+ user_message: userMessage,
+ living_router_mode: modeDecision?.mode ?? "chat",
+ living_router_reason: modeDecision?.reason ?? "living_chat_signal_detected",
+ assistant_reply: assistantItem.text,
+ reply_type: assistantItem.reply_type,
+ 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
+ };
+ }
+ catch (error) {
+ (0, log_1.logJson)({
+ timestamp: new Date().toISOString(),
+ level: "warn",
+ service: "assistant_loop",
+ message: "assistant_living_chat_failed_fallback_to_deep",
+ sessionId,
+ details: {
+ session_id: sessionId,
+ user_message: userMessage,
+ reason: error instanceof Error ? error.message : String(error)
+ }
+ });
+ return null;
+ }
+ };
let addressRuntimeMetaForDeep = null;
if (config_1.FEATURE_ASSISTANT_ADDRESS_QUERY_V1) {
const addressPreDecompose = config_1.FEATURE_ASSISTANT_ADDRESS_QUERY_LLM_PREDECOMPOSE_V1
@@ -3307,14 +4475,23 @@ class AssistantService {
toolGateReason: null
};
const addressInputMessage = toNonEmptyString(addressPreDecompose?.effectiveMessage) ?? userMessage;
- const carryover = resolveAddressFollowupCarryoverContext(userMessage, session.items, addressInputMessage);
- const toolGate = resolveAddressToolGateDecision(addressInputMessage, carryover?.followupContext ?? null, addressPreDecompose);
+ const carryover = resolveAddressFollowupCarryoverContext(userMessage, session.items, addressInputMessage, addressPreDecompose);
+ const toolGate = resolveAddressToolGateDecision(addressInputMessage, carryover?.followupContext ?? null, addressPreDecompose, userMessage);
+ const dialogContinuationContract = buildAddressDialogContinuationContractV2(userMessage, addressInputMessage, carryover, addressPreDecompose);
const addressRuntimeMeta = {
...addressPreDecompose,
toolGateDecision: toolGate.decision,
- toolGateReason: toolGate.reason
+ toolGateReason: toolGate.reason,
+ dialogContinuationContract
};
addressRuntimeMetaForDeep = addressRuntimeMeta;
+ const livingModeDecision = resolveLivingAssistantModeDecision({
+ userMessage,
+ addressLaneTriggered: toolGate.runAddressLane,
+ useMock: Boolean(payload.useMock),
+ predecomposeMode: addressRuntimeMeta?.predecomposeContract?.mode ?? null,
+ predecomposeModeConfidence: addressRuntimeMeta?.predecomposeContract?.mode_confidence ?? null
+ });
if (!toolGate.runAddressLane) {
(0, log_1.logJson)({
timestamp: new Date().toISOString(),
@@ -3338,29 +4515,104 @@ class AssistantService {
address_llm_predecompose_contract_period_scope: addressRuntimeMeta?.predecomposeContract?.period?.scope ?? null
}
});
+ if (livingModeDecision.mode === "chat") {
+ const chatHandled = await tryHandleLivingChat(livingModeDecision, addressRuntimeMeta);
+ if (chatHandled) {
+ return chatHandled;
+ }
+ }
}
if (toolGate.runAddressLane) {
const shouldPreferContextualLane = Boolean(carryover?.followupContext);
+ const canRetryWithRawUserMessage = compactWhitespace(String(addressInputMessage ?? "").toLowerCase()) !==
+ compactWhitespace(String(userMessage ?? "").toLowerCase());
+ const retryAudit = {
+ attempted: false,
+ reason: null,
+ initial_limited_category: null,
+ retry_message: null,
+ retry_used_followup_context: false,
+ retry_result_category: null
+ };
+ const withRetryMeta = () => ({
+ ...addressRuntimeMeta,
+ addressRetryAudit: { ...retryAudit }
+ });
+ let pendingLimited = null;
+ const evaluateAddressLane = (addressLane, messageUsed, carryMeta) => {
+ if (!addressLane?.handled) {
+ return null;
+ }
+ if (!isRetryableAddressLimitedResult(addressLane)) {
+ return {
+ action: "return",
+ addressLane,
+ messageUsed,
+ carryMeta
+ };
+ }
+ if (!pendingLimited) {
+ pendingLimited = {
+ addressLane,
+ messageUsed,
+ carryMeta
+ };
+ }
+ return {
+ action: "continue"
+ };
+ };
+ const runAddressLaneAttempt = async (messageUsed, carryMeta) => {
+ if (carryMeta?.followupContext) {
+ return this.addressQueryService.tryHandle(messageUsed, {
+ followupContext: carryMeta.followupContext
+ });
+ }
+ return this.addressQueryService.tryHandle(messageUsed);
+ };
if (shouldPreferContextualLane) {
- const contextualAddressLane = await this.addressQueryService.tryHandle(addressInputMessage, {
- followupContext: carryover.followupContext
- });
- if (contextualAddressLane?.handled) {
- return finalizeAddressLaneResponse(contextualAddressLane, addressInputMessage, carryover, addressRuntimeMeta);
+ const contextualAddressLane = await runAddressLaneAttempt(addressInputMessage, carryover);
+ const decision = evaluateAddressLane(contextualAddressLane, addressInputMessage, carryover);
+ if (decision?.action === "return") {
+ return finalizeAddressLaneResponse(decision.addressLane, decision.messageUsed, decision.carryMeta, withRetryMeta());
}
}
- const primaryAddressLane = await this.addressQueryService.tryHandle(addressInputMessage);
- if (primaryAddressLane?.handled) {
- return finalizeAddressLaneResponse(primaryAddressLane, addressInputMessage, null, addressRuntimeMeta);
+ const primaryAddressLane = await runAddressLaneAttempt(addressInputMessage, null);
+ const primaryDecision = evaluateAddressLane(primaryAddressLane, addressInputMessage, null);
+ if (primaryDecision?.action === "return") {
+ return finalizeAddressLaneResponse(primaryDecision.addressLane, primaryDecision.messageUsed, primaryDecision.carryMeta, withRetryMeta());
}
if (!shouldPreferContextualLane && carryover?.followupContext) {
- const contextualAddressLane = await this.addressQueryService.tryHandle(addressInputMessage, {
- followupContext: carryover.followupContext
- });
- if (contextualAddressLane?.handled) {
- return finalizeAddressLaneResponse(contextualAddressLane, addressInputMessage, carryover, addressRuntimeMeta);
+ const contextualAddressLane = await runAddressLaneAttempt(addressInputMessage, carryover);
+ const contextualDecision = evaluateAddressLane(contextualAddressLane, addressInputMessage, carryover);
+ if (contextualDecision?.action === "return") {
+ return finalizeAddressLaneResponse(contextualDecision.addressLane, contextualDecision.messageUsed, contextualDecision.carryMeta, withRetryMeta());
}
}
+ if (pendingLimited && canRetryWithRawUserMessage) {
+ retryAudit.attempted = true;
+ retryAudit.reason = "limited_result_retry_with_raw_message";
+ retryAudit.initial_limited_category = pendingLimited.addressLane?.debug?.limited_reason_category ?? null;
+ retryAudit.retry_message = userMessage;
+ if (carryover?.followupContext) {
+ retryAudit.retry_used_followup_context = true;
+ const rawContextualLane = await runAddressLaneAttempt(userMessage, carryover);
+ const rawContextualDecision = evaluateAddressLane(rawContextualLane, userMessage, carryover);
+ if (rawContextualDecision?.action === "return") {
+ retryAudit.retry_result_category = rawContextualDecision.addressLane?.debug?.limited_reason_category ?? null;
+ return finalizeAddressLaneResponse(rawContextualDecision.addressLane, rawContextualDecision.messageUsed, rawContextualDecision.carryMeta, withRetryMeta());
+ }
+ }
+ const rawPrimaryLane = await runAddressLaneAttempt(userMessage, null);
+ retryAudit.retry_result_category = rawPrimaryLane?.debug?.limited_reason_category ?? null;
+ const rawPrimaryDecision = evaluateAddressLane(rawPrimaryLane, userMessage, null);
+ if (rawPrimaryDecision?.action === "return") {
+ return finalizeAddressLaneResponse(rawPrimaryDecision.addressLane, rawPrimaryDecision.messageUsed, rawPrimaryDecision.carryMeta, withRetryMeta());
+ }
+ }
+ if (pendingLimited) {
+ return finalizeAddressLaneResponse(pendingLimited.addressLane, pendingLimited.messageUsed, pendingLimited.carryMeta, withRetryMeta());
+ }
}
}
const followupBinding = config_1.FEATURE_ASSISTANT_INVESTIGATION_STATE_V1 &&
diff --git a/llm_normalizer/backend/dist/services/openaiResponsesClient.js b/llm_normalizer/backend/dist/services/openaiResponsesClient.js
index e0eceef..9be8499 100644
--- a/llm_normalizer/backend/dist/services/openaiResponsesClient.js
+++ b/llm_normalizer/backend/dist/services/openaiResponsesClient.js
@@ -167,6 +167,90 @@ function buildBaseUrlCandidates(config) {
return Array.from(new Set([base, `${base}/v1`]));
}
class OpenAIResponsesClient {
+ async chat(config, prompt) {
+ const responsesPayload = {
+ model: config.model,
+ temperature: prompt.temperature ?? config.temperature ?? 0.2,
+ max_output_tokens: prompt.maxOutputTokens ?? config.maxOutputTokens ?? 400,
+ input: [
+ ...(String(prompt.systemPrompt ?? "").trim().length > 0
+ ? [
+ {
+ role: "system",
+ content: [{ type: "input_text", text: String(prompt.systemPrompt ?? "").trim() }]
+ }
+ ]
+ : []),
+ ...(String(prompt.developerPrompt ?? "").trim().length > 0
+ ? [
+ {
+ role: "developer",
+ content: [{ type: "input_text", text: String(prompt.developerPrompt ?? "").trim() }]
+ }
+ ]
+ : []),
+ {
+ role: "user",
+ content: [{ type: "input_text", text: String(prompt.userMessage ?? "") }]
+ }
+ ]
+ };
+ const provider = resolveProvider(config);
+ if (provider === "openai") {
+ const raw = await this.postResponses(config, responsesPayload);
+ return {
+ raw,
+ outputText: extractOutputTextFromResponses(raw),
+ usage: extractUsage(raw)
+ };
+ }
+ 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: prompt.temperature ?? config.temperature ?? 0.2,
+ max_tokens: prompt.maxOutputTokens ?? config.maxOutputTokens ?? 400,
+ messages: [
+ ...(String(prompt.systemPrompt ?? "").trim().length > 0
+ ? [
+ {
+ role: "system",
+ content: String(prompt.systemPrompt ?? "").trim()
+ }
+ ]
+ : []),
+ ...(String(prompt.developerPrompt ?? "").trim().length > 0
+ ? [
+ {
+ role: "developer",
+ content: String(prompt.developerPrompt ?? "").trim()
+ }
+ ]
+ : []),
+ {
+ role: "user",
+ content: String(prompt.userMessage ?? "")
+ }
+ ]
+ };
+ const raw = await this.postChatCompletions(config, chatPayload);
+ return {
+ raw,
+ outputText: extractOutputTextFromChatCompletions(raw),
+ usage: extractUsage(raw)
+ };
+ }
async listModels(config) {
const payload = await this.getModels(config);
const data = Array.isArray(payload.data) ? payload.data : [];
diff --git a/llm_normalizer/backend/src/config.ts b/llm_normalizer/backend/src/config.ts
index 08b40f2..952b59a 100644
--- a/llm_normalizer/backend/src/config.ts
+++ b/llm_normalizer/backend/src/config.ts
@@ -19,6 +19,18 @@ function toNumberFlag(value: string | undefined, defaultValue: number): number {
return Number.isFinite(parsed) ? parsed : defaultValue;
}
+function toStringListFlag(value: string | undefined, defaultValue: string[]): string[] {
+ const source = String(value ?? "").trim();
+ if (!source) {
+ return [...defaultValue];
+ }
+ const tokens = source
+ .split(/[,\s;]+/g)
+ .map((item) => item.trim())
+ .filter((item) => item.length > 0);
+ return tokens.length > 0 ? Array.from(new Set(tokens)) : [...defaultValue];
+}
+
export const PORT = Number(process.env.PORT ?? 8787);
export const TIMEZONE = process.env.TZ_FALLBACK ?? "Europe/Moscow";
export const DEFAULT_OPENAI_BASE_URL = process.env.OPENAI_BASE_URL ?? "https://api.openai.com/v1";
@@ -103,6 +115,10 @@ export const FEATURE_ASSISTANT_ADDRESS_QUERY_LIVE_V1 = toBooleanFlag(
process.env.FEATURE_ASSISTANT_ADDRESS_QUERY_LIVE_V1,
true
);
+export const FEATURE_ASSISTANT_LIVING_CHAT_ROUTER_V1 = toBooleanFlag(
+ process.env.FEATURE_ASSISTANT_LIVING_CHAT_ROUTER_V1,
+ true
+);
export const ASSISTANT_MCP_PROXY_URL = (process.env.ASSISTANT_MCP_PROXY_URL ?? "http://127.0.0.1:6003").replace(
/\/+$/,
""
@@ -110,6 +126,8 @@ export const ASSISTANT_MCP_PROXY_URL = (process.env.ASSISTANT_MCP_PROXY_URL ?? "
export const ASSISTANT_MCP_CHANNEL = process.env.ASSISTANT_MCP_CHANNEL ?? "default";
export const ASSISTANT_MCP_TIMEOUT_MS = toNumberFlag(process.env.ASSISTANT_MCP_TIMEOUT_MS, 6000);
export const ASSISTANT_MCP_LIVE_LIMIT = Math.max(1, Math.trunc(toNumberFlag(process.env.ASSISTANT_MCP_LIVE_LIMIT, 24)));
+export const VAT_PAYABLE_68_PREFIXES = toStringListFlag(process.env.VAT_PAYABLE_68_PREFIXES, ["68.02"]);
+export const VAT_PAYABLE_19_PREFIXES = toStringListFlag(process.env.VAT_PAYABLE_19_PREFIXES, ["19"]);
export const DATA_DIR = process.env.DATA_DIR ?? path.resolve(MODULE_ROOT, "data");
export const TRACES_DIR = path.resolve(DATA_DIR, "traces");
diff --git a/llm_normalizer/backend/src/services/addressFilterExtractor.ts b/llm_normalizer/backend/src/services/addressFilterExtractor.ts
index f42c21e..f4a69fb 100644
--- a/llm_normalizer/backend/src/services/addressFilterExtractor.ts
+++ b/llm_normalizer/backend/src/services/addressFilterExtractor.ts
@@ -5,9 +5,12 @@ const ACCOUNT_PATTERN = /(?:сч[её]т|счет|account)[^0-9]{0,12}(\d{2}(?:[
const LIMIT_PATTERN = /(?:\btop\b|\blimit\b|первые|топ)[\s\-–—_:№#]*?(\d{1,3})/iu;
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 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 DATE_DMY_MONTH_NAME_PATTERN =
+ /(?:^|[\s,.;:!?()\-])(\d{1,2})\s+([a-zа-яё]+)\s+((?:19|20)\d{2}|\d{2})(?:\s*г(?:од|ода|\\.)?)?(?=$|[\s,.;:!?()\-])/iu;
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;
@@ -116,6 +119,45 @@ function extractAsOfDate(text: string): string | undefined {
return toIsoDate(year, month, day) ?? undefined;
}
+ const dmyByMonthName = text.match(DATE_DMY_MONTH_NAME_PATTERN);
+ if (dmyByMonthName) {
+ const day = Number(dmyByMonthName[1]);
+ const month = resolveMonthByName(String(dmyByMonthName[2] ?? ""));
+ const yearRaw = Number(dmyByMonthName[3]);
+ const year = yearRaw < 100 ? 2000 + yearRaw : yearRaw;
+ if (month) {
+ return toIsoDate(year, month, day) ?? undefined;
+ }
+ }
+
+ return undefined;
+}
+
+function extractAsOfDateWithCue(text: string): string | undefined {
+ const source = String(text ?? "");
+ if (!source) {
+ return undefined;
+ }
+
+ const numericCue = source.match(
+ /(?:^|[\s,.;:!?()\-])(?:на|до|к|по\s+состоянию\s+на|as\s+of|by)\s+(\d{1,4}[.\/-]\d{1,2}[.\/-]\d{1,4})(?=$|[\s,.;:!?()\-])/iu
+ );
+ if (numericCue) {
+ return parseDateToken(String(numericCue[1] ?? ""));
+ }
+
+ const monthNameCue = source.match(
+ /(?:^|[\s,.;:!?()\-])(?:на|до|к|по\s+состоянию\s+на|as\s+of|by)\s+(\d{1,2})\s+([a-zа-яё]+)\s+((?:19|20)\d{2})(?:\s*г(?:од|ода|\\.)?)?(?=$|[\s,.;:!?()\-])/iu
+ );
+ if (monthNameCue) {
+ const day = Number(monthNameCue[1]);
+ const month = resolveMonthByName(String(monthNameCue[2] ?? ""));
+ const year = Number(monthNameCue[3]);
+ if (month && Number.isFinite(year) && Number.isFinite(day)) {
+ return toIsoDate(year, month, day) ?? undefined;
+ }
+ }
+
return undefined;
}
@@ -163,6 +205,26 @@ function resolveMonthByName(rawMonthName: string): number | undefined {
return undefined;
}
+function deriveQuarterWindowForDate(asOfIso: string): { period_from: string; period_to: string } | null {
+ const token = String(asOfIso ?? "").trim();
+ const match = token.match(/^(\d{4})-(\d{2})-(\d{2})$/);
+ if (!match) {
+ return null;
+ }
+ const year = Number(match[1]);
+ const month = Number(match[2]);
+ if (!Number.isFinite(year) || !Number.isFinite(month) || month < 1 || month > 12) {
+ return null;
+ }
+ const quarterStartMonth = Math.floor((month - 1) / 3) * 3 + 1;
+ const quarterEndMonth = quarterStartMonth + 2;
+ const quarterEndDay = new Date(Date.UTC(year, quarterEndMonth, 0)).getUTCDate();
+ return {
+ period_from: `${year}-${String(quarterStartMonth).padStart(2, "0")}-01`,
+ period_to: `${year}-${String(quarterEndMonth).padStart(2, "0")}-${String(quarterEndDay).padStart(2, "0")}`
+ };
+}
+
function extractMonthPeriod(text: string): { period_from?: string; period_to?: string } {
const numericMonthYearMatch = text.match(MONTH_PERIOD_NUMERIC_MONTH_YEAR_PATTERN);
if (numericMonthYearMatch) {
@@ -321,8 +383,13 @@ function extractYearRangePeriod(text: string): { period_from?: string; period_to
}
function cleanupAnchorValue(value: string): string {
- const normalized = String(value ?? "").trim();
- if (!normalized) {
+ const stripOuterQuotes = (text: string): string =>
+ String(text ?? "")
+ .replace(/^['"«»“”„`’‘]+|['"«»“”„`’‘]+$/gu, "")
+ .trim();
+
+ let cleaned = stripOuterQuotes(String(value ?? "").trim());
+ if (!cleaned) {
return "";
}
@@ -330,49 +397,47 @@ function cleanupAnchorValue(value: string): string {
// " на 2020-07-31", " на дату 31.07.2020", " as of 2020-07-31".
const asOfTailPattern =
/\s+(?:на\s+(?:дат[ауеы]\s+)?\d{1,4}[./-]\d{1,2}(?:[./-]\d{1,4})?|as\s+of\s+\d{1,4}[./-]\d{1,2}(?:[./-]\d{1,4})?)(?:\s+|$)[\s\S]*$/iu;
- if (asOfTailPattern.test(normalized)) {
- return normalized.replace(asOfTailPattern, "").trim();
- }
const asOfTruncatedTailPattern = /\s+на\s+дат[ауеы]\s+\d{1,2}(?:\s+|$)[\s\S]*$/iu;
- if (asOfTruncatedTailPattern.test(normalized)) {
- return normalized.replace(asOfTruncatedTailPattern, "").trim();
- }
+ const asOfReportDateTailPattern =
+ /\s+на\s+дат[ауеы]\s+(?:отчетност[ьи]|отч[её]тн(?:ую|ой)?\s+дат[ауеы]|конец(?:\s+период[а-яё]*)?)\s+\d{1,4}[./-]\d{1,2}(?:[./-]\d{1,4})?(?:\s+|$)[\s\S]*$/iu;
const periodEndTailPattern =
/\s+на\s+конец(?:\s+период[а-яё]*)?\s+(?:\d{1,4}[./-]\d{1,2}(?:[./-]\d{1,4})?|\d{4}|[a-zа-яё]+\s+\d{4})(?:\s+|$)[\s\S]*$/iu;
- if (periodEndTailPattern.test(normalized)) {
- return normalized.replace(periodEndTailPattern, "").trim();
- }
// Remove trailing period qualifiers that can be swallowed by broad anchor regexes:
// " с 2020-07-01 по 2020-07-31", " from 2020-07-01 to 2020-07-31"
const periodTailPattern =
/\s+(?:с\s+\d{1,4}[./-]\d{1,2}[./-]\d{1,4}|from\s+\d{1,4}[./-]\d{1,2}[./-]\d{1,4}|between\s+\d{1,4}[./-]\d{1,2}[./-]\d{1,4}|за\s+период)(?:\s+|$)[\s\S]*$/iu;
- if (periodTailPattern.test(normalized)) {
- return normalized.replace(periodTailPattern, "").trim();
- }
-
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|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();
+
+ for (const tailPattern of [
+ asOfTailPattern,
+ asOfTruncatedTailPattern,
+ asOfReportDateTailPattern,
+ periodEndTailPattern,
+ periodTailPattern,
+ allTimeTailPattern,
+ allTimeTailPatternEn
+ ]) {
+ if (tailPattern.test(cleaned)) {
+ cleaned = stripOuterQuotes(cleaned.replace(tailPattern, "").trim());
+ }
}
const trailingYearTailPattern =
/\s+(?:year\s+)?(20\d{2})(?:\s*(?:г(?:од|ода)?\.?|year))?(?:\s+|$)[\s\S]*$/iu;
- let cleaned = normalized;
- if (trailingYearTailPattern.test(normalized)) {
- cleaned = normalized.replace(trailingYearTailPattern, "").trim();
+ if (trailingYearTailPattern.test(cleaned)) {
+ cleaned = stripOuterQuotes(cleaned.replace(trailingYearTailPattern, "").trim());
}
- return cleaned
+ cleaned = cleaned
.replace(/\s+(?:from|to|between|and)(?:\s+|$)[\s\S]*$/iu, "")
.replace(/\s+(?:с|по|за)(?:\s+|$)[\s\S]*$/iu, "")
.trim();
+
+ return stripOuterQuotes(cleaned);
}
function cleanupContractAnchorValue(value: string): string {
@@ -442,6 +507,8 @@ function extractLooseByAnchorValue(text: string): string | undefined {
"партнера",
"договору",
"договора",
+ "контракту",
+ "контракта",
"счету",
"счёту",
"дате",
@@ -489,6 +556,7 @@ function extractLooseByAnchorValue(text: string): string | undefined {
"linked",
"нему",
"ней",
+ "нее",
"ним",
"этому",
"тому",
@@ -552,10 +620,51 @@ function isLikelyCounterpartyToken(rawToken: string): boolean {
"каких",
"какому",
"какую",
+ "кто",
+ "что",
+ "чего",
+ "где",
+ "когда",
+ "почему",
+ "зачем",
+ "сколько",
"чьи",
"чья",
"чей",
"чью",
+ "самый",
+ "самая",
+ "самое",
+ "самые",
+ "крупный",
+ "крупная",
+ "крупное",
+ "крупные",
+ "жирный",
+ "жирная",
+ "жирное",
+ "жирные",
+ "больше",
+ "меньше",
+ "платит",
+ "платят",
+ "прогноз",
+ "forecast",
+ "план",
+ "плана",
+ "ндс",
+ "vat",
+ "налог",
+ "оплата",
+ "оплаты",
+ "платеж",
+ "платёж",
+ "платежа",
+ "платежи",
+ "денег",
+ "деньги",
+ "объем",
+ "объём",
"док",
"доки",
"документ",
@@ -685,6 +794,14 @@ function isLowQualityCounterpartyAnchorValue(rawValue: string): boolean {
if (tokens.length === 0) {
return true;
}
+ const questionCue =
+ /(?:кто|что|какой|какая|какие|какого|сколько|где|когда|почему|зачем|which|who|what|how\s+many)/iu.test(value) ||
+ /[?]/u.test(String(rawValue ?? ""));
+ const rankingCue = /(?:больше|меньше|сам(?:ый|ая|ое|ые)|крупн|жирн|максим|миним)/iu.test(value);
+ const paymentCue = /(?:плат(?:ит|ят|еж|ёж|ежн|ежей|ежа)|денег|деньг|money|payment)/iu.test(value);
+ if (questionCue && (rankingCue || paymentCue)) {
+ return true;
+ }
const meaningfulTokens = tokens.filter((token) => isLikelyCounterpartyToken(token));
return meaningfulTokens.length === 0;
}
@@ -723,7 +840,9 @@ function isLowQualityContractAnchorValue(rawValue: string): boolean {
"период",
"периоду",
"договор",
- "договору"
+ "договору",
+ "контракт",
+ "контракту"
]);
const meaningfulTokens = tokens.filter((token) => !lowQualityTokens.has(token));
return meaningfulTokens.length === 0;
@@ -941,7 +1060,8 @@ export function extractAddressFilters(userMessage: string, intent: AddressIntent
intent === "contract_usage_overview" ||
intent === "customer_revenue_and_payments" ||
intent === "supplier_payouts_profile" ||
- intent === "contract_usage_and_value";
+ intent === "contract_usage_and_value" ||
+ intent === "vat_payable_forecast";
const filters: AddressFilterSet = {
sort: "period_desc"
};
@@ -949,6 +1069,8 @@ export function extractAddressFilters(userMessage: string, intent: AddressIntent
filters.limit = 20;
}
const warnings: string[] = [];
+ const explicitAsOfDate = extractAsOfDate(text);
+ const explicitAsOfDateWithCue = extractAsOfDateWithCue(text);
const accountMatch = text.match(ACCOUNT_PATTERN);
if (accountMatch) {
@@ -1071,12 +1193,27 @@ export function extractAddressFilters(userMessage: string, intent: AddressIntent
}
}
+ const vatAsOfDate = explicitAsOfDateWithCue ?? explicitAsOfDate;
+ if (intent === "vat_payable_forecast" && vatAsOfDate && !periodRange.period_from && !periodRange.period_to) {
+ const quarterWindow = deriveQuarterWindowForDate(vatAsOfDate);
+ if (quarterWindow) {
+ filters.period_from = quarterWindow.period_from;
+ warnings.push("period_from_derived_from_quarter_for_vat_forecast");
+ filters.period_to = vatAsOfDate;
+ warnings.push("period_to_derived_from_as_of_date_for_vat_forecast");
+
+ if (filters.period_from && filters.period_to && filters.period_from > filters.period_to) {
+ filters.period_from = quarterWindow.period_from;
+ warnings.push("period_from_adjusted_for_vat_as_of_window");
+ }
+ }
+ }
+
if (isManagementProfileIntent && !filters.period_to && !filters.as_of_date) {
filters.period_to = new Date().toISOString().slice(0, 10);
warnings.push("period_to_defaulted_today_for_management_profile");
}
- const explicitAsOfDate = extractAsOfDate(text);
if (usesAsOfPrimaryWindow(intent) && explicitAsOfDate) {
filters.as_of_date = explicitAsOfDate;
const periodWasDerivedHeuristically =
diff --git a/llm_normalizer/backend/src/services/addressIntentResolver.ts b/llm_normalizer/backend/src/services/addressIntentResolver.ts
index ab04ab3..d23c02c 100644
--- a/llm_normalizer/backend/src/services/addressIntentResolver.ts
+++ b/llm_normalizer/backend/src/services/addressIntentResolver.ts
@@ -57,7 +57,8 @@ const OPEN_CONTRACTS_HINTS = [
"незакрыт",
"не закрыт",
"открыт",
- "договор"
+ "договор",
+ "контракт"
];
const OPEN_ITEMS_HINTS = [
@@ -130,7 +131,10 @@ const DOCUMENTS_BY_CONTRACT_HINTS = [
"доки по договору",
"док по договору",
"документы договор",
- "договор"
+ "договор",
+ "документы по контракту",
+ "доки по контракту",
+ "контракт"
];
const BANK_OPERATIONS_BY_CONTRACT_HINTS = [
"bank operations by contract",
@@ -140,7 +144,10 @@ const BANK_OPERATIONS_BY_CONTRACT_HINTS = [
"bank ops by contract",
"банковские операции по договору",
"платежи по договору",
- "выписка по договору"
+ "выписка по договору",
+ "банковские операции по контракту",
+ "платежи по контракту",
+ "выписка по контракту"
];
const BANK_OPERATION_CORE_HINTS = [
@@ -337,14 +344,22 @@ const CONTRACT_USAGE_AND_VALUE_HINTS = [
"договоры по обороту",
"договоры по сумме оборота",
"топ договоров по обороту",
+ "контракты по обороту",
+ "контракты по сумме оборота",
+ "топ контрактов по обороту",
"договоры с минимальным бюджетом",
"договоры с самым маленьким бюджетом",
+ "контракты с минимальным бюджетом",
+ "контракты с самым маленьким бюджетом",
"активные договоры по бюджету",
+ "активные контракты по бюджету",
"контрагенты с несколькими договорами",
"несколько договоров у контрагента",
"мультидоговорные контрагенты",
"какие договоры активны",
+ "какие контракты активны",
"рабочие договоры",
+ "рабочие контракты",
"contracts by turnover",
"contracts by budget"
];
@@ -355,6 +370,10 @@ const CONTRACT_LIST_BY_COUNTERPARTY_HINTS = [
"список договоров по",
"покажи договоры по",
"выведи договоры по",
+ "контракты по",
+ "список контрактов по",
+ "покажи контракты по",
+ "выведи контракты по",
"contracts by counterparty",
"list contracts by counterparty",
"show contracts by counterparty"
@@ -453,8 +472,25 @@ function hasFuzzyLexeme(text: string, lexemeRoots: string[]): boolean {
}
function hasCompactAccountCodeToken(text: string): boolean {
- // Match compact account tokens like 60.01 / 62, while avoiding date fragments.
- return /(? ({
+ userMessage,
+ periodFrom: typeof filterSet.period_from === "string" ? filterSet.period_from : undefined,
+ periodTo: typeof filterSet.period_to === "string" ? filterSet.period_to : undefined
+ });
let anchor = resolvePrimaryAnchor(intent.intent, filters.extracted_filters);
const recipeSelection = selectAddressRecipe(intent.intent, filters.extracted_filters);
@@ -1273,7 +1278,7 @@ export class AddressQueryService {
const recoveredBankRows = applyIntentSpecificFilter("bank_operations_by_contract", filterByAnchors);
const recoveredRows = recoveredBankRows.length > 0 ? recoveredBankRows : filterByAnchors;
if (recoveredRows.length > 0) {
- const factual = composeFactualReply(intent.intent, recoveredRows, { userMessage });
+ const factual = composeFactualReply(intent.intent, recoveredRows, composeOptionsFromFilters(filters.extracted_filters));
const recoveryReason =
recoveredBankRows.length > 0
? "contract_docs_recovered_via_bank_fallback"
@@ -1392,7 +1397,11 @@ export class AddressQueryService {
rowsAnchorMatched: expandedRowsByAnchor.length,
rowsMatched: expandedFilteredRows.length
});
- const expandedFactual = composeFactualReply(intent.intent, expandedFilteredRows, { userMessage });
+ const expandedFactual = composeFactualReply(
+ intent.intent,
+ expandedFilteredRows,
+ composeOptionsFromFilters(expandedLimitFilters)
+ );
const expandedPrefix = `Период сохранен. Глубина live-выборки автоматически расширена до ${expandedPlan.limit} строк.`;
const expandedLimitations = [...filters.warnings, "query_limit_auto_expanded_for_anchor_recovery"];
const expandedReasons = [...baseReasons, "query_limit_auto_expanded_for_anchor_recovery"];
@@ -1501,7 +1510,11 @@ export class AddressQueryService {
});
const observedWindow = deriveObservedPeriodWindow(broadenedFilteredRows);
const broadenedPrefix = composeAutoBroadenedPeriodPrefix(filters.extracted_filters, observedWindow);
- const broadenedFactual = composeFactualReply(intent.intent, broadenedFilteredRows, { userMessage });
+ const broadenedFactual = composeFactualReply(
+ intent.intent,
+ broadenedFilteredRows,
+ composeOptionsFromFilters(autoBroadenedFilters)
+ );
const broadenedLimitations = [...filters.warnings, "period_window_auto_broadened_to_available_data"];
const broadenedReasons = [...baseReasons, "period_window_auto_broadened_to_available_data"];
return {
@@ -1616,14 +1629,21 @@ export class AddressQueryService {
rowsAnchorMatched: historicalRowsByAnchor.length,
rowsMatched: historicalFilteredRows.length
});
- const historicalFactual = composeFactualReply(intent.intent, historicalFilteredRows, { userMessage });
- const historicalPrefix =
- "В последних доступных записях якорь не подтвердился; показаны найденные строки по историческому окну.";
+ const historicalFactual = composeFactualReply(
+ intent.intent,
+ historicalFilteredRows,
+ composeOptionsFromFilters(historicalFilters)
+ );
+ const historicalPrefix = "Найдены данные в историческом срезе базы по вашему запросу.";
+ const historicalSuggestion =
+ intent.intent === "list_documents_by_counterparty"
+ ? "\nЕсли нужно, могу дополнительно показать платежи и договоры по этому контрагенту."
+ : "";
const historicalLimitations = [...filters.warnings, "historical_window_sort_recovery_applied"];
const historicalReasons = [...baseReasons, "historical_window_sort_recovery_applied"];
return {
handled: true,
- reply_text: `${historicalPrefix}\n${historicalFactual.text}`,
+ reply_text: `${historicalPrefix}\n${historicalFactual.text}${historicalSuggestion}`,
reply_type: inferReplyType(historicalFactual.responseType),
response_type: historicalFactual.responseType,
debug: {
@@ -1681,14 +1701,21 @@ export class AddressQueryService {
) {
const documentBankFallbackRows = applyIntentSpecificFilter(intent.intent, normalizedRows);
if (documentBankFallbackRows.length > 0) {
- const fallbackFactual = composeFactualReply(intent.intent, documentBankFallbackRows, { userMessage });
+ const fallbackFactual = composeFactualReply(
+ intent.intent,
+ documentBankFallbackRows,
+ composeOptionsFromFilters(filters.extracted_filters)
+ );
+ const fallbackPrefix = "По вашему запросу показываю найденные документы и операции в доступном срезе базы.";
+ const fallbackSuggestion =
+ intent.intent === "list_documents_by_counterparty"
+ ? "\nЕсли нужно, могу дополнительно сузить период или показать только платежи."
+ : "";
const fallbackLimitations = [...filters.warnings, "anchor_not_matched_fallback_rows"];
const fallbackReasons = [...baseReasons, "anchor_not_matched_fallback_rows"];
return {
handled: true,
- reply_text:
- "Точный якорь не подтвердился в текущем окне live-данных; показаны ближайшие доступные документы/операции по выбранному типу.\n" +
- fallbackFactual.text,
+ reply_text: `${fallbackPrefix}\n${fallbackFactual.text}${fallbackSuggestion}`,
reply_type: inferReplyType(fallbackFactual.responseType),
response_type: fallbackFactual.responseType,
debug: {
@@ -1751,11 +1778,24 @@ export class AddressQueryService {
Array.isArray(filters.warnings) &&
(filters.warnings.includes("counterparty_from_followup_context") ||
filters.warnings.includes("contract_from_followup_context"));
+ const anchorMismatchByCounterparty =
+ isAnchorMismatch && String(matchFailureReason ?? "").includes("counterparty_anchor_not_matched");
+ const anchorMismatchByContract = isAnchorMismatch && String(matchFailureReason ?? "").includes("contract_anchor_not_matched");
const isLowQualityPartyAnchor =
(anchor.anchor_type === "counterparty" || anchor.anchor_type === "contract") &&
isLikelyLowQualityPartyAnchor(anchor.anchor_value_raw);
- const anchorMismatchCategory: AddressLimitedReasonCategory =
- isFollowupAnchorCarryover || !isLowQualityPartyAnchor ? "empty_match" : "missing_anchor";
+ const requestedPeriodFrom =
+ typeof filters.extracted_filters.period_from === "string" ? filters.extracted_filters.period_from : null;
+ const requestedPeriodTo = typeof filters.extracted_filters.period_to === "string" ? filters.extracted_filters.period_to : null;
+ const requestedPeriodHint =
+ requestedPeriodFrom && requestedPeriodTo ? ` (период ${requestedPeriodFrom}..${requestedPeriodTo} сохранен)` : "";
+ const anchorMismatchCategory: AddressLimitedReasonCategory = isFollowupAnchorCarryover
+ ? "empty_match"
+ : anchorMismatchByCounterparty || anchorMismatchByContract
+ ? "missing_anchor"
+ : !isLowQualityPartyAnchor
+ ? "empty_match"
+ : "missing_anchor";
const category: AddressLimitedReasonCategory = isAnchorMismatch
? anchorMismatchCategory
: isRecipeFilteredOut
@@ -1764,18 +1804,26 @@ export class AddressQueryService {
? "recipe_visibility_gap"
: "empty_match";
const reasonText = isAnchorMismatch
- ? anchorMismatchCategory === "missing_anchor"
- ? "якорь контрагента/договора не найден в материализованных live-строках"
- : "по указанному якорю и фильтрам в live-выборке нет строк"
+ ? anchorMismatchByCounterparty
+ ? "контрагент по указанному имени/алиасу не найден в materialized live-строках"
+ : anchorMismatchByContract
+ ? "договор по указанному номеру/названию не найден в materialized live-строках"
+ : anchorMismatchCategory === "missing_anchor"
+ ? "якорь контрагента/договора не найден в materialized live-строках"
+ : "по указанному якорю и фильтрам в live-выборке нет строк"
: isRecipeFilteredOut
? "строки по якорю найдены, но отфильтрованы intent-specific recipe"
: isVisibilityGapCandidate
? "в текущем live recipe нет достаточной document/bank видимости после фильтрации"
: "по выбранным фильтрам в live-выборке нет строк";
const nextStep = isAnchorMismatch
- ? anchorMismatchCategory === "missing_anchor"
- ? "уточните контрагента точным именем или добавьте ИНН/договор"
- : "уточните период или снимите часть фильтров"
+ ? anchorMismatchByCounterparty
+ ? `уточните точное имя контрагента или добавьте ИНН${requestedPeriodHint}`
+ : anchorMismatchByContract
+ ? `уточните номер/наименование договора${requestedPeriodHint}`
+ : anchorMismatchCategory === "missing_anchor"
+ ? "уточните контрагента точным именем или добавьте ИНН/договор"
+ : "уточните период или снимите часть фильтров"
: isRecipeFilteredOut
? "сузьте период, уточните контрагента или документный тип"
: isVisibilityGapCandidate
@@ -1783,9 +1831,13 @@ export class AddressQueryService {
: "уточните период, контрагента, договор или снимите часть фильтров";
const limitations = isAnchorMismatch
? [
- anchorMismatchCategory === "missing_anchor"
- ? "anchor_not_matched_after_materialization"
- : "no_rows_for_anchor_after_materialization"
+ anchorMismatchByCounterparty
+ ? "counterparty_anchor_not_matched_after_materialization"
+ : anchorMismatchByContract
+ ? "contract_anchor_not_matched_after_materialization"
+ : anchorMismatchCategory === "missing_anchor"
+ ? "anchor_not_matched_after_materialization"
+ : "no_rows_for_anchor_after_materialization"
]
: isRecipeFilteredOut
? ["rows_filtered_out_by_recipe_after_anchor_match"]
@@ -1824,7 +1876,7 @@ export class AddressQueryService {
});
}
- const factual = composeFactualReply(intent.intent, filteredRows, { userMessage });
+ const factual = composeFactualReply(intent.intent, filteredRows, composeOptionsFromFilters(filters.extracted_filters));
return {
handled: true,
reply_text: factual.text,
diff --git a/llm_normalizer/backend/src/services/addressRecipeCatalog.ts b/llm_normalizer/backend/src/services/addressRecipeCatalog.ts
index f22eed7..131472e 100644
--- a/llm_normalizer/backend/src/services/addressRecipeCatalog.ts
+++ b/llm_normalizer/backend/src/services/addressRecipeCatalog.ts
@@ -4,6 +4,7 @@
AddressRecipeDefinition,
AddressRecipeSelection
} from "../types/addressQuery";
+import { VAT_PAYABLE_19_PREFIXES, VAT_PAYABLE_68_PREFIXES } from "../config";
const MOVEMENTS_QUERY_TEMPLATE = `
ВЫБРАТЬ ПЕРВЫЕ __LIMIT__
@@ -347,6 +348,66 @@ const CONTRACTS_BY_COUNTERPARTY_QUERY_TEMPLATE = `
Справочник.ДоговорыКонтрагентов КАК Договоры
`;
+const VAT_PAYABLE_FORECAST_QUERY_TEMPLATE = `
+ВЫБРАТЬ
+ ДАТАВРЕМЯ(2000, 1, 1, 0, 0, 0) КАК Период,
+ "VAT_68_CREDIT" КАК Регистратор,
+ "68" КАК СчетДт,
+ "" КАК СчетКт,
+ СУММА(ВЫБОР
+ КОГДА __VAT68_KT_MATCH__
+ ТОГДА Движения.Сумма
+ ИНАЧЕ 0
+ КОНЕЦ) КАК Сумма
+ИЗ
+ РегистрБухгалтерии.Хозрасчетный КАК Движения
+__WHERE_CLAUSE__
+ОБЪЕДИНИТЬ ВСЕ
+ВЫБРАТЬ
+ ДАТАВРЕМЯ(2000, 1, 1, 0, 0, 0) КАК Период,
+ "VAT_68_DEBIT" КАК Регистратор,
+ "68" КАК СчетДт,
+ "" КАК СчетКт,
+ СУММА(ВЫБОР
+ КОГДА __VAT68_DT_MATCH__
+ ТОГДА Движения.Сумма
+ ИНАЧЕ 0
+ КОНЕЦ) КАК Сумма
+ИЗ
+ РегистрБухгалтерии.Хозрасчетный КАК Движения
+__WHERE_CLAUSE__
+ОБЪЕДИНИТЬ ВСЕ
+ВЫБРАТЬ
+ ДАТАВРЕМЯ(2000, 1, 1, 0, 0, 0) КАК Период,
+ "VAT_19_DEBIT" КАК Регистратор,
+ "19" КАК СчетДт,
+ "" КАК СчетКт,
+ СУММА(ВЫБОР
+ КОГДА __VAT19_DT_MATCH__
+ ТОГДА Движения.Сумма
+ ИНАЧЕ 0
+ КОНЕЦ) КАК Сумма
+ИЗ
+ РегистрБухгалтерии.Хозрасчетный КАК Движения
+__WHERE_CLAUSE__
+ОБЪЕДИНИТЬ ВСЕ
+ВЫБРАТЬ
+ ДАТАВРЕМЯ(2000, 1, 1, 0, 0, 0) КАК Период,
+ "VAT_19_CREDIT" КАК Регистратор,
+ "19" КАК СчетДт,
+ "" КАК СчетКт,
+ СУММА(ВЫБОР
+ КОГДА __VAT19_KT_MATCH__
+ ТОГДА Движения.Сумма
+ ИНАЧЕ 0
+ КОНЕЦ) КАК Сумма
+ИЗ
+ РегистрБухгалтерии.Хозрасчетный КАК Движения
+__WHERE_CLAUSE__
+УПОРЯДОЧИТЬ ПО
+ Регистратор
+`;
+
const BASE_RECIPES: AddressRecipeDefinition[] = [
{
recipe_id: "address_period_coverage_profile_v1",
@@ -428,6 +489,16 @@ const BASE_RECIPES: AddressRecipeDefinition[] = [
account_scope_mode: "preferred",
query_template: "contract_value_profile"
},
+ {
+ recipe_id: "address_vat_payable_forecast_v1",
+ intent: "vat_payable_forecast",
+ purpose: "Estimate VAT payable from factual turnovers on accounts 68 and 19 for selected period",
+ required_filters: [],
+ optional_filters: ["period_from", "period_to", "as_of_date", "organization"],
+ default_limit: 32,
+ account_scope_mode: "preferred",
+ query_template: "vat_payable_forecast_profile"
+ },
{
recipe_id: "address_contracts_by_counterparty_v1",
intent: "list_contracts_by_counterparty",
@@ -669,6 +740,64 @@ function buildMovementAccountCondition(filters: AddressFilterSet): string | null
return clauses.length === 1 ? clauses[0] : `(${clauses.join(" ИЛИ ")})`;
}
+function normalizeAccountPrefixForQuery(value: string): string | null {
+ const normalized = String(value ?? "")
+ .trim()
+ .replace(",", ".")
+ .replace(/[^0-9.]+/g, "");
+ if (!normalized) {
+ return null;
+ }
+ if (!/^\d{2}(?:\.\d{1,3})*$/.test(normalized)) {
+ return null;
+ }
+ return normalized;
+}
+
+function accountPrefixVariants(prefix: string): string[] {
+ const value = normalizeAccountPrefixForQuery(prefix);
+ if (!value) {
+ return [];
+ }
+
+ const variants = new Set([value]);
+ const segments = value.split(".");
+ if (segments.length <= 1) {
+ return Array.from(variants);
+ }
+
+ const base = segments[0];
+ const normalizedTail = segments.slice(1).map((segment) => {
+ const trimmed = segment.replace(/^0+(?=\d)/, "");
+ return trimmed.length > 0 ? trimmed : "0";
+ });
+ const compact = [base, ...normalizedTail].join(".");
+ if (compact !== value) {
+ variants.add(compact);
+ }
+
+ return Array.from(variants);
+}
+
+function buildAccountPrefixPredicate(fieldPath: string, prefixes: string[]): string {
+ const normalizedPrefixes = Array.from(
+ new Set(
+ (prefixes ?? [])
+ .flatMap((item) => accountPrefixVariants(item))
+ .filter((item): item is string => Boolean(item))
+ )
+ );
+
+ if (normalizedPrefixes.length === 0) {
+ return "ЛОЖЬ";
+ }
+
+ const clauses = normalizedPrefixes.map(
+ (prefix) => `ПОДСТРОКА(ЕСТЬNULL(${fieldPath}.Код, ""), 1, ${prefix.length}) = "${prefix}"`
+ );
+ return clauses.length === 1 ? clauses[0] : `(${clauses.join(" ИЛИ ")})`;
+}
+
function shouldBoostLimitForAllTimeCounterparty(filters: AddressFilterSet): boolean {
const hasAnchor =
(typeof filters.counterparty === "string" && filters.counterparty.trim().length > 0) ||
@@ -694,6 +823,7 @@ function maxLimitForIntent(intent: AddressIntent): number {
intent === "customer_revenue_and_payments" ||
intent === "supplier_payouts_profile" ||
intent === "contract_usage_and_value" ||
+ intent === "vat_payable_forecast" ||
intent === "list_contracts_by_counterparty" ||
intent === "list_documents_by_counterparty" ||
intent === "bank_operations_by_counterparty" ||
@@ -742,7 +872,8 @@ export function buildAddressRecipePlan(
recipe.query_template === "period_profile" ||
recipe.query_template === "document_section_profile" ||
recipe.query_template === "counterparty_roles_profile" ||
- recipe.query_template === "contract_usage_profile";
+ recipe.query_template === "contract_usage_profile" ||
+ recipe.query_template === "vat_payable_forecast_profile";
const baseLimit =
typeof filters.limit === "number" && Number.isFinite(filters.limit)
? Math.max(1, Math.min(maxLimit, Math.trunc(filters.limit)))
@@ -830,6 +961,13 @@ export function buildAddressRecipePlan(
buildContractValueWhereClause(filters, "БанкСписание.Дата", "БанкСписание.ДоговорКонтрагента")
)
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort))
+ : recipe.query_template === "vat_payable_forecast_profile"
+ ? VAT_PAYABLE_FORECAST_QUERY_TEMPLATE
+ .replaceAll("__WHERE_CLAUSE__", buildManagementWhereClause(filters, "Движения.Период"))
+ .replaceAll("__VAT68_KT_MATCH__", buildAccountPrefixPredicate("Движения.СчетКт", VAT_PAYABLE_68_PREFIXES))
+ .replaceAll("__VAT68_DT_MATCH__", buildAccountPrefixPredicate("Движения.СчетДт", VAT_PAYABLE_68_PREFIXES))
+ .replaceAll("__VAT19_DT_MATCH__", buildAccountPrefixPredicate("Движения.СчетДт", VAT_PAYABLE_19_PREFIXES))
+ .replaceAll("__VAT19_KT_MATCH__", buildAccountPrefixPredicate("Движения.СчетКт", VAT_PAYABLE_19_PREFIXES))
: recipe.query_template === "contracts_by_counterparty_profile"
? CONTRACTS_BY_COUNTERPARTY_QUERY_TEMPLATE.replaceAll("__LIMIT__", String(resolvedLimit))
: MOVEMENTS_QUERY_TEMPLATE
diff --git a/llm_normalizer/backend/src/services/address_runtime/composeStage.ts b/llm_normalizer/backend/src/services/address_runtime/composeStage.ts
index bfa66ce..65d85f9 100644
--- a/llm_normalizer/backend/src/services/address_runtime/composeStage.ts
+++ b/llm_normalizer/backend/src/services/address_runtime/composeStage.ts
@@ -11,6 +11,8 @@ export interface ComposeStageRow {
interface ComposeFactualReplyOptions {
userMessage?: string;
+ periodFrom?: string;
+ periodTo?: string;
}
type PeriodProfileFocus =
@@ -130,6 +132,93 @@ function formatPercent(value: number, total: number): string | null {
return `${((value / total) * 100).toFixed(1)}%`;
}
+function formatMoney(value: number): string {
+ if (!Number.isFinite(value)) {
+ return "0.00";
+ }
+ return value.toFixed(2);
+}
+
+function parseIsoDateToken(value: string | null | undefined): { year: number; month: number; day: number } | null {
+ const source = String(value ?? "").trim();
+ const match = source.match(/^(\d{4})-(\d{2})-(\d{2})/);
+ if (!match) {
+ return null;
+ }
+ const year = Number(match[1]);
+ const month = Number(match[2]);
+ const day = Number(match[3]);
+ if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) {
+ return null;
+ }
+ if (month < 1 || month > 12 || day < 1 || day > 31) {
+ return null;
+ }
+ return { year, month, day };
+}
+
+function toIsoDate(year: number, month: number, day: number): string {
+ return `${String(year).padStart(4, "0")}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
+}
+
+function formatDateRu(isoDate: string): string {
+ const parsed = parseIsoDateToken(isoDate);
+ if (!parsed) {
+ return isoDate;
+ }
+ return `${String(parsed.day).padStart(2, "0")}.${String(parsed.month).padStart(2, "0")}.${String(parsed.year).padStart(4, "0")}`;
+}
+
+function buildIsoDateWithMonthShift(
+ year: number,
+ monthOneBased: number,
+ day: number,
+ monthShift = 0
+): string {
+ const date = new Date(Date.UTC(year, monthOneBased - 1 + monthShift, day));
+ return date.toISOString().slice(0, 10);
+}
+
+function deriveVatDeadlineCalendar(
+ periodFrom: string | null | undefined,
+ periodTo: string | null | undefined
+): {
+ periodLabel: string;
+ quarterStart: string;
+ quarterEnd: string;
+ declarationDueDate: string;
+ paymentDueDates: [string, string, string];
+ windowFrom: string | null;
+ windowTo: string | null;
+} | null {
+ const reference = parseIsoDateToken(periodTo) ?? parseIsoDateToken(periodFrom);
+ if (!reference) {
+ return null;
+ }
+
+ const quarterIndex = Math.floor((reference.month - 1) / 3);
+ const quarterNumber = quarterIndex + 1;
+ const quarterStartMonth = quarterIndex * 3 + 1;
+ const quarterEndMonth = quarterStartMonth + 2;
+ const quarterEndDay = new Date(Date.UTC(reference.year, quarterEndMonth, 0)).getUTCDate();
+ const quarterStart = toIsoDate(reference.year, quarterStartMonth, 1);
+ const quarterEnd = toIsoDate(reference.year, quarterEndMonth, quarterEndDay);
+ const declarationDueDate = buildIsoDateWithMonthShift(reference.year, quarterEndMonth, 25, 1);
+ const payment1 = buildIsoDateWithMonthShift(reference.year, quarterEndMonth, 28, 1);
+ const payment2 = buildIsoDateWithMonthShift(reference.year, quarterEndMonth, 28, 2);
+ const payment3 = buildIsoDateWithMonthShift(reference.year, quarterEndMonth, 28, 3);
+
+ return {
+ periodLabel: `${quarterNumber} кв. ${reference.year}`,
+ quarterStart,
+ quarterEnd,
+ declarationDueDate,
+ paymentDueDates: [payment1, payment2, payment3],
+ windowFrom: periodFrom ?? null,
+ windowTo: periodTo ?? null
+ };
+}
+
function extractAccountSectionCode(value: string | null): string | null {
const source = String(value ?? "").trim();
if (!source) {
@@ -150,6 +239,18 @@ function normalizeQuestionText(value: string | null | undefined): string {
.trim();
}
+function needsVatWhyExplanation(userMessage: string | null | undefined): boolean {
+ const text = normalizeQuestionText(userMessage);
+ if (!text) {
+ return false;
+ }
+ const asksReason = /(?:почему|why|из[-\s]?за\s+чего|как\s+так|reason)/iu.test(text);
+ if (!asksReason) {
+ return false;
+ }
+ return /(?:ндс|vat|прогноз|к\s+уплате|нул|ноль|\b0(?:[.,]0+)?\b)/iu.test(text);
+}
+
function detectRankingLimit(userMessage: string | null | undefined, fallback = 20): number {
const text = normalizeQuestionText(userMessage);
if (!text) {
@@ -249,13 +350,13 @@ function detectCounterpartyProfileFocus(userMessage: string | null | undefined):
text
);
- if (hasSupplierToken && !hasCustomerToken && !hasMixedToken && !asksTotal) {
+ if (hasSupplierToken && !hasCustomerToken && !hasMixedToken) {
return "suppliers_only";
}
- if (hasCustomerToken && !hasSupplierToken && !hasMixedToken && !asksTotal) {
+ if (hasCustomerToken && !hasSupplierToken && !hasMixedToken) {
return "customers_only";
}
- if (hasMixedToken && !hasSupplierToken && !hasCustomerToken && !asksTotal) {
+ if (hasMixedToken && !hasSupplierToken && !hasCustomerToken) {
return "mixed_only";
}
@@ -781,8 +882,19 @@ export function composeFactualReply(
const focus = detectCounterpartyProfileFocus(options.userMessage);
const includeTotal = focus === "full_profile" || focus === "total_only";
const includeRoles = focus === "full_profile" || focus === "roles_only";
+ const directLead =
+ focus === "suppliers_only"
+ ? `Поставщиков (только supplier-роль): ${supplierOnly}.`
+ : focus === "customers_only"
+ ? `Заказчиков (только customer-роль): ${customerOnly}.`
+ : focus === "mixed_only"
+ ? `Смешанных контрагентов (и customer, и supplier): ${mixedActive}.`
+ : includeTotal && totalCounterparties > 0
+ ? `Всего уникальных контрагентов в базе: ${totalCounterparties}.`
+ : `Активных контрагентов по операциям: ${activeCounterparties}.`;
const lines: string[] = [
+ directLead,
"Профиль контрагентов собран (catalog + bank-doc activity aggregate).",
`Строк агрегата: ${rows.length}.`
];
@@ -871,19 +983,19 @@ export function composeFactualReply(
: "в выбранном периоде";
const lines: string[] = [
+ `Активные заказчики ${scopeLabel}: ${counterparties.length}.`,
"Собран профиль активности заказчиков (bank-doc activity aggregate).",
`Строк агрегата: ${rows.length}.`
];
if (counterparties.length === 0) {
- lines.push("Активных заказчиков по выбранному окну не найдено.");
+ lines.push("По выбранному окну активности заказчики не найдены.");
return {
responseType: "FACTUAL_SUMMARY",
text: lines.join("\n")
};
}
- lines.push(`Активные заказчики ${scopeLabel}: ${counterparties.length}.`);
const visible = counterparties.slice(0, 120);
lines.push(
...visible.map((item, index) => {
@@ -923,7 +1035,13 @@ export function composeFactualReply(
totalContracts > 0 ? Math.max(0, totalContracts - Math.min(usedContracts, totalContracts)) : null;
const usedShare = totalContracts > 0 ? formatPercent(Math.min(usedContracts, totalContracts), totalContracts) : null;
+ const usageLead =
+ totalContracts > 0
+ ? `Использованных договоров: ${usedContracts} из ${totalContracts}${usedShare ? ` (${usedShare})` : ""}.`
+ : `Использованных договоров (есть factual связь с операциями): ${usedContracts}.`;
+
const lines: string[] = [
+ usageLead,
"Профиль договорной базы собран (catalog + usage aggregate).",
`Строк агрегата: ${rows.length}.`
];
@@ -1046,11 +1164,10 @@ export function composeFactualReply(
if (focus === "top_by_ops") {
const visible = rankedByOps.slice(0, limit);
- lines.push(
- isSupplier
- ? `Топ-${visible.length} поставщиков по количеству исходящих платежных операций:`
- : `Топ-${visible.length} заказчиков по количеству входящих платежных операций:`
- );
+ const heading = isSupplier
+ ? `Топ-${visible.length} поставщиков по количеству исходящих платежных операций:`
+ : `Топ-${visible.length} заказчиков по количеству входящих платежных операций:`;
+ lines.unshift(heading);
lines.push(
...visible.map(
(item, index) => `${index + 1}. ${item.name} | операций: ${item.ops} | сумма: ${item.total} | макс: ${item.maxSingle}`
@@ -1064,11 +1181,10 @@ export function composeFactualReply(
if (focus === "top_by_max_single") {
const visible = rankedByMaxSingle.slice(0, limit);
- lines.push(
- isSupplier
- ? `Топ-${visible.length} поставщиков по максимальной разовой выплате:`
- : `Топ-${visible.length} заказчиков по максимальной сумме одной входящей операции:`
- );
+ const heading = isSupplier
+ ? `Топ-${visible.length} поставщиков по максимальной разовой выплате:`
+ : `Топ-${visible.length} заказчиков по максимальной сумме одной входящей операции:`;
+ lines.unshift(heading);
lines.push(
...visible.map((item, index) => `${index + 1}. ${item.name} | max single: ${item.maxSingle} | сумма: ${item.total} | операций: ${item.ops}`)
);
@@ -1080,11 +1196,10 @@ export function composeFactualReply(
if (focus === "top_by_avg_check_min_ops") {
const visible = rankedByAvgCheck.slice(0, limit);
- lines.push(
- isSupplier
- ? `Топ-${visible.length} поставщиков по среднему чеку (минимум ${minOpsForAvgCheck} операций):`
- : `Топ-${visible.length} заказчиков по среднему чеку (минимум ${minOpsForAvgCheck} входящих операций):`
- );
+ const heading = isSupplier
+ ? `Топ-${visible.length} поставщиков по среднему чеку (минимум ${minOpsForAvgCheck} операций):`
+ : `Топ-${visible.length} заказчиков по среднему чеку (минимум ${minOpsForAvgCheck} входящих операций):`;
+ lines.unshift(heading);
if (visible.length === 0) {
lines.push(`Контрагентов с минимум ${minOpsForAvgCheck} операций не найдено.`);
} else {
@@ -1103,11 +1218,10 @@ export function composeFactualReply(
if (focus === "top_deals") {
const visible = rankedDealsTop.slice(0, limit);
- lines.push(
- isSupplier
- ? `Топ-${visible.length} самых крупных разовых выплат поставщикам:`
- : `Топ-${visible.length} самых крупных разовых сделок по поступлениям:`
- );
+ const heading = isSupplier
+ ? `Топ-${visible.length} самых крупных разовых выплат поставщикам:`
+ : `Топ-${visible.length} самых крупных разовых сделок по поступлениям:`;
+ lines.unshift(heading);
lines.push(
...visible.map(
(item, index) => `${index + 1}. ${item.period ?? "n/a"} | ${item.counterparty} | ${item.registrator} | ${item.amount}`
@@ -1121,11 +1235,10 @@ export function composeFactualReply(
if (focus === "bottom_deals") {
const visible = rankedDealsBottom.slice(0, limit);
- lines.push(
- isSupplier
- ? `Топ-${visible.length} самых маленьких разовых выплат:`
- : `Топ-${visible.length} самых маленьких разовых сделок по поступлениям:`
- );
+ const heading = isSupplier
+ ? `Топ-${visible.length} самых маленьких разовых выплат:`
+ : `Топ-${visible.length} самых маленьких разовых сделок по поступлениям:`;
+ lines.unshift(heading);
if (activeOnlyForBottomDeals) {
lines.push("Фильтр: только активные контрагенты (минимум 3 операции).");
}
@@ -1141,11 +1254,10 @@ export function composeFactualReply(
}
const visible = rankedByTotal.slice(0, limit);
- lines.push(
- isSupplier
- ? `Топ-${visible.length} поставщиков по сумме выплат:`
- : `Топ-${visible.length} заказчиков по сумме поступлений:`
- );
+ const heading = isSupplier
+ ? `Топ-${visible.length} поставщиков по сумме выплат:`
+ : `Топ-${visible.length} заказчиков по сумме поступлений:`;
+ lines.unshift(heading);
lines.push(
...visible.map((item, index) => {
const avgCheck = item.ops > 0 ? (item.total / item.ops).toFixed(2) : "0";
@@ -1212,9 +1324,10 @@ export function composeFactualReply(
.sort((a, b) => a.turnover - b.turnover || b.docs - a.docs || a.contract.localeCompare(b.contract));
const lines: string[] = [
+ `Активных договоров: ${contractRows.length}.`,
"Собран профиль договоров по обороту/бюджету (bank-doc contract aggregate).",
`Строк источника: ${rows.length}.`,
- `Активных договоров: ${contractRows.length}.`
+ `Договорных агрегатов: ${contractRows.length}.`
];
if (contractRows.length === 0) {
@@ -1227,7 +1340,8 @@ export function composeFactualReply(
if (focus === "top_by_docs") {
const visible = rankedByDocs.slice(0, limit);
- lines.push(`Топ-${visible.length} договоров по количеству операций:`);
+ const heading = `Топ-${visible.length} договоров по количеству операций:`;
+ lines.unshift(heading);
lines.push(
...visible.map(
(item, index) =>
@@ -1242,7 +1356,8 @@ export function composeFactualReply(
if (focus === "bottom_by_turnover_active") {
const visible = rankedBottomActive.slice(0, limit);
- lines.push(`Топ-${visible.length} активных договоров с минимальным бюджетом (оборотом):`);
+ const heading = `Топ-${visible.length} активных договоров с минимальным бюджетом (оборотом):`;
+ lines.unshift(heading);
lines.push(
...visible.map(
(item, index) =>
@@ -1256,7 +1371,8 @@ export function composeFactualReply(
}
const visible = rankedByTurnover.slice(0, limit);
- lines.push(`Топ-${visible.length} договоров по сумме оборота:`);
+ const heading = `Топ-${visible.length} договоров по сумме оборота:`;
+ lines.unshift(heading);
lines.push(
...visible.map(
(item, index) =>
@@ -1269,6 +1385,98 @@ export function composeFactualReply(
};
}
+ if (intent === "vat_payable_forecast") {
+ const rowsByMarker = new Map();
+ for (const row of rows) {
+ const marker = String(row.registrator ?? "").trim().toUpperCase();
+ if (!marker) {
+ continue;
+ }
+ const nextValue = (rowsByMarker.get(marker) ?? 0) + (row.amount ?? 0);
+ rowsByMarker.set(marker, nextValue);
+ }
+
+ const turnover68Credit = rowsByMarker.get("VAT_68_CREDIT") ?? 0;
+ const turnover68Debit = rowsByMarker.get("VAT_68_DEBIT") ?? 0;
+ const turnover19Debit = rowsByMarker.get("VAT_19_DEBIT") ?? 0;
+ const turnover19Credit = rowsByMarker.get("VAT_19_CREDIT") ?? 0;
+
+ const netVat = turnover68Credit - turnover68Debit;
+ const vatToPay = Math.max(0, netVat);
+ const carryoverOrOverpayment = Math.max(0, -netVat);
+ const totalVatTurnoverAbs =
+ Math.abs(turnover68Credit) + Math.abs(turnover68Debit) + Math.abs(turnover19Debit) + Math.abs(turnover19Credit);
+ const vatActivityDetected = totalVatTurnoverAbs > 0.0000001;
+ const netVatIsEffectivelyZero = Math.abs(netVat) <= 0.005;
+ const explainWhyRequested = needsVatWhyExplanation(options.userMessage);
+ const vatCalendar = deriveVatDeadlineCalendar(options.periodFrom, options.periodTo);
+
+ const lines = [
+ "Собран прогноз НДС к уплате по фактическим проводкам (НДС-субсчета 68.02*/19*).",
+ `Строк агрегата: ${rows.length}.`,
+ `Оборот по кредиту 68*: ${formatMoney(turnover68Credit)}.`,
+ `Оборот по дебету 68*: ${formatMoney(turnover68Debit)}.`,
+ `Нетто НДС (68 Кт - 68 Дт): ${formatMoney(netVat)}.`,
+ `Прогноз НДС к уплате: ${formatMoney(vatToPay)}.`,
+ `Потенциальный перенос/переплата: ${formatMoney(carryoverOrOverpayment)}.`,
+ `Справочно по 19*: дебет ${formatMoney(turnover19Debit)}, кредит ${formatMoney(turnover19Credit)}.`
+ ];
+
+ if (!vatActivityDetected) {
+ lines.push(
+ "В выбранном окне не найдено движений по НДС-субсчетам 68.02*/19*; поэтому оперативный прогноз к уплате равен 0.00."
+ );
+ } else if (vatToPay === 0 && netVatIsEffectivelyZero) {
+ lines.push("В выбранном окне обороты по 68* взаимно перекрылись (нетто близко к нулю), поэтому к уплате 0.00.");
+ } else if (vatToPay === 0 && netVat < 0) {
+ lines.push("В выбранном окне дебет 68* превышает кредит 68*; сумма показана как перенос/переплата, к уплате 0.00.");
+ }
+ if (vatToPay === 0) {
+ lines.push(
+ "Чеклист проверки в 1С (почему к уплате 0):",
+ `1) Проверьте ОСВ/анализ счета по 68.02 и 19 за окно ${options.periodFrom && options.periodTo ? `${formatDateRu(options.periodFrom)}..${formatDateRu(options.periodTo)}` : "расчета"}.`,
+ "2) Проверьте наличие движений в РегистрБухгалтерии.Хозрасчетный по счетам 68.02*/19* (включая субсчета).",
+ "3) Сверьте счета-фактуры, корректировки и момент принятия НДС к вычету (не попали ли в другой период).",
+ "4) Сверьте книгу продаж/покупок и операции Помощника по учету НДС за тот же период.",
+ "5) Убедитесь, что документы проведены, период закрыт корректно и нет неподтвержденных/неперепроведенных документов."
+ );
+ }
+
+ if (vatCalendar) {
+ const periodWindowLabel =
+ vatCalendar.windowFrom && vatCalendar.windowTo
+ ? `${formatDateRu(vatCalendar.windowFrom)}..${formatDateRu(vatCalendar.windowTo)}`
+ : `${formatDateRu(vatCalendar.quarterStart)}..${formatDateRu(vatCalendar.quarterEnd)}`;
+ const [payment1, payment2, payment3] = vatCalendar.paymentDueDates;
+ const installmentRaw = vatToPay / 3;
+ const installmentRounded = Number(installmentRaw.toFixed(2));
+ const installmentThird = Number((vatToPay - installmentRounded * 2).toFixed(2));
+ lines.push(
+ `Период расчета (срез обязательств): ${periodWindowLabel}.`,
+ `Налоговый период: ${vatCalendar.periodLabel}.`,
+ `Срок сдачи декларации: до ${formatDateRu(vatCalendar.declarationDueDate)}.`,
+ `Сроки уплаты: ${formatDateRu(payment1)}, ${formatDateRu(payment2)}, ${formatDateRu(payment3)}.`,
+ `Ориентир по долям к уплате: ${formatMoney(installmentRounded)} / ${formatMoney(installmentRounded)} / ${formatMoney(installmentThird)}.`,
+ "Важно: даже при нулевой сумме к уплате декларация по НДС подается в установленный срок; переносы по выходным/праздникам сверяйте по календарю ФНС/1С."
+ );
+ }
+ if (explainWhyRequested) {
+ lines.push(
+ "Почему прогноз к уплате 0: в текущей модели используем формулу max(0, 68 Кт - 68 Дт).",
+ `За период 68 Кт = ${formatMoney(turnover68Credit)}, 68 Дт = ${formatMoney(turnover68Debit)}, разница = ${formatMoney(netVat)}.`,
+ netVat <= 0
+ ? "Разница неположительная, поэтому к уплате = 0, а отрицательная часть показана как перенос/переплата."
+ : "Разница положительная, поэтому к уплате берется эта положительная величина.",
+ "Важно: это оперативный прогноз по оборотам НДС-субсчетов 68.02*/19*; финальную сумму налога подтверждают регистры НДС и декларация."
+ );
+ }
+
+ return {
+ responseType: "FACTUAL_SUMMARY",
+ text: lines.join("\n")
+ };
+ }
+
if (intent === "account_balance_snapshot") {
const movementSum = rows.reduce((sum, row) => sum + (row.amount ?? 0), 0);
const lines = [
@@ -1372,8 +1580,7 @@ export function composeFactualReply(
if (intent === "list_documents_by_counterparty") {
const lines = [
- "Собран список документов по контрагенту (live address lane).",
- `Строк отобрано: ${rows.length}.`,
+ `Найдено документов по контрагенту: ${rows.length}.`,
...formatTopRows(rows, rows.length)
];
return {
@@ -1384,6 +1591,7 @@ export function composeFactualReply(
if (intent === "list_documents_by_contract") {
const lines = [
+ `Найдено документов по договору: ${rows.length}.`,
"Собран список документов по договору (live address lane).",
`Строк отобрано: ${rows.length}.`,
...formatTopRows(rows, rows.length)
@@ -1396,6 +1604,7 @@ export function composeFactualReply(
if (intent === "bank_operations_by_counterparty") {
const lines = [
+ `Найдено банковских операций по контрагенту: ${rows.length}.`,
"Собран список банковских операций по контрагенту (live address lane).",
`Строк отобрано: ${rows.length}.`,
...formatTopRows(rows, rows.length)
@@ -1408,6 +1617,7 @@ export function composeFactualReply(
if (intent === "bank_operations_by_contract") {
const lines = [
+ `Найдено банковских операций по договору: ${rows.length}.`,
"Собран список банковских операций по договору (live address lane).",
`Строк отобрано: ${rows.length}.`,
...formatTopRows(rows, rows.length)
diff --git a/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts b/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts
index bd9b59b..a7ad5e9 100644
--- a/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts
+++ b/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts
@@ -144,6 +144,29 @@ const FOLLOWUP_LOW_QUALITY_COUNTERPARTY_TOKENS = new Set([
"что",
"все",
"всё",
+ "кроме",
+ "помимо",
+ "этого",
+ "этот",
+ "эта",
+ "эту",
+ "этом",
+ "это",
+ "эти",
+ "этих",
+ "документ",
+ "документа",
+ "документы",
+ "документов",
+ "договор",
+ "договора",
+ "контрагент",
+ "контрагента",
+ "еще",
+ "ещё",
+ "другие",
+ "другое",
+ "остальное",
"год",
"года",
"году",
@@ -284,6 +307,13 @@ export function hasAddressFollowupContextSignal(text: string): boolean {
}
const tokenCount = normalized.split(/\s+/).filter(Boolean).length;
+ if (
+ tokenCount <= 12 &&
+ /(?:почему|why|из[-\s]?за\s+чего|как\s+так|reason)/iu.test(normalized) &&
+ /(?:ндс|vat|прогноз|к\s+уплате|нул|ноль|\b0(?:[.,]0+)?\b)/iu.test(normalized)
+ ) {
+ return true;
+ }
const hasPeriodLiteral = /\b(?:19|20)\d{2}(?:[./-](?:0?[1-9]|1[0-2]))?\b/.test(normalized);
if (tokenCount <= 8 && hasPeriodLiteral) {
return true;
@@ -411,9 +441,28 @@ function mergeFollowupFilters(
}
}
+ const hasFollowupSignal = hasAddressFollowupContextSignal(userMessage);
+ const hasExplicitPeriodInMessage = hasExplicitPeriodLiteral(userMessage);
const currentHasPeriod = hasExplicitPeriodWindow(merged);
const previousHasPeriod = hasExplicitPeriodWindow(previous);
- if (!currentHasPeriod && previousHasPeriod && hasAddressFollowupContextSignal(userMessage)) {
+
+ if (intent === "vat_payable_forecast" && previousHasPeriod && hasFollowupSignal && !hasExplicitPeriodInMessage) {
+ const currentPeriodFrom = toNonEmptyString(merged.period_from);
+ const currentPeriodTo = toNonEmptyString(merged.period_to);
+ const todayIso = new Date().toISOString().slice(0, 10);
+ const currentLooksDefaultedToToday = !currentPeriodFrom && currentPeriodTo === todayIso;
+ if (!currentPeriodFrom || currentLooksDefaultedToToday) {
+ if (previousPeriodFrom) {
+ merged.period_from = previousPeriodFrom;
+ }
+ if (previousPeriodTo) {
+ merged.period_to = previousPeriodTo;
+ }
+ reasons.push("period_from_followup_context");
+ }
+ }
+
+ if (!currentHasPeriod && previousHasPeriod && hasFollowupSignal) {
if (previousPeriodFrom) {
merged.period_from = previousPeriodFrom;
}
@@ -562,7 +611,11 @@ export function runAddressDecomposeStage(
): AddressDecomposeStageResult | null {
const detectedMode = detectAddressQuestionMode(userMessage);
const shape = classifyAddressQueryShape(userMessage);
- if (shape.shape === "EXPLAIN_OR_REASON") {
+ const allowExplainAsFollowup =
+ shape.shape === "EXPLAIN_OR_REASON" &&
+ Boolean(followupContext?.previous_intent) &&
+ hasAddressFollowupContextSignal(userMessage);
+ if (shape.shape === "EXPLAIN_OR_REASON" && !allowExplainAsFollowup) {
return null;
}
const detectedIntent = resolveAddressIntent(userMessage);
diff --git a/llm_normalizer/backend/src/services/address_runtime/predecomposeContract.ts b/llm_normalizer/backend/src/services/address_runtime/predecomposeContract.ts
index fe4e5e0..1351008 100644
--- a/llm_normalizer/backend/src/services/address_runtime/predecomposeContract.ts
+++ b/llm_normalizer/backend/src/services/address_runtime/predecomposeContract.ts
@@ -85,7 +85,8 @@ function inferAggregationProfile(intent: AddressIntent, shape: AddressQueryShape
intent === "contract_usage_overview" ||
intent === "customer_revenue_and_payments" ||
intent === "supplier_payouts_profile" ||
- intent === "contract_usage_and_value"
+ intent === "contract_usage_and_value" ||
+ intent === "vat_payable_forecast"
) {
return "management_profile";
}
diff --git a/llm_normalizer/backend/src/services/assistantService.ts b/llm_normalizer/backend/src/services/assistantService.ts
index daf4be2..52d9283 100644
--- a/llm_normalizer/backend/src/services/assistantService.ts
+++ b/llm_normalizer/backend/src/services/assistantService.ts
@@ -17,7 +17,11 @@ import * as addressQueryClassifier_1 from "./addressQueryClassifier";
import * as addressIntentResolver_1 from "./addressIntentResolver";
import * as addressFilterExtractor_1 from "./addressFilterExtractor";
import * as predecomposeContract_1 from "./address_runtime/predecomposeContract";
+import * as openaiResponsesClient_1 from "./openaiResponsesClient";
+import * as addressMcpClient_1 from "./addressMcpClient";
import iconv from "iconv-lite";
+const DATA_SCOPE_CACHE_TTL_MS = 60_000;
+const dataScopeProbeCache = new Map();
function retrievalSummaryForRoute(route) {
if (route === "store_canonical")
return "Canonical accounting data path selected.";
@@ -1383,18 +1387,33 @@ function compactWhitespace(value) {
return value.replace(/\s+/g, " ").trim();
}
function hasAccountingSignal(text) {
- const lower = text.toLowerCase();
- if (/(?:^|[\s,;:])\d{2}(?:\.\d{2})?(?=$|[\s,.;:])/i.test(lower)) {
- return true;
+ const lower = repairAddressMojibake(String(text ?? "")).toLowerCase();
+ const excludedSpans = [...collectDateSpans(lower), ...collectAmountSpans(lower), ...collectPercentSpans(lower), ...collectContractSpans(lower)];
+ const accountTokenPattern = /\b(?:01|02|07|08|10|13|19|20|21|23|25|26|28|29|41|43|44|50|51|52|55|57|58|60|62|66|67|68|69|70|71|73|75|76|80|81|84|90|91|97)(?:[.,]\d{1,2})?\b/g;
+ let accountMatch = null;
+ while ((accountMatch = accountTokenPattern.exec(lower)) !== null) {
+ const token = String(accountMatch[0] ?? "").trim();
+ if (!token) {
+ continue;
+ }
+ const start = accountMatch.index;
+ const end = start + token.length;
+ if (intersectsAnySpan(start, end, excludedSpans)) {
+ continue;
+ }
+ const hasExplicitSubaccount = /[.,]\d{1,2}/.test(token);
+ if (hasExplicitSubaccount || hasAccountContextAround(lower, start, end) || countTokens(lower) <= 4) {
+ return true;
+ }
}
- return /(проводк|документ|реализац|поступлен|взаиморасчет|сальдо|остатк|счет|счёт|ндс|амортиз|рбп|ос|контрагент|поставщик|покупател|оплат|банк|выписк|склад|товар|материал|закрыти|период|postavshchik|kontragent|schet|schetu|period|counterparty|supplier|invoice|posting|ledger|account|anomaly|risk)/i.test(lower);
+ return /(проводк|документ|реализац|поступлен|взаиморасчет|сальдо|остатк|счет|счёт|ндс|амортиз|рбп|контрагент|поставщик|покупател|оплат|банк|выписк|склад|товар|материал|закрыти|период|postavshchik|kontragent|schet|schetu|period|counterparty|supplier|invoice|posting|ledger|account|anomaly|risk)/i.test(lower);
}
function hasFollowupMarker(text) {
const compact = compactWhitespace(text.toLowerCase());
return /^(и|а\s+кто|а еще|а ещё|еще|ещё|добав|уточн|продолж|также|а если|а теперь|теперь|plus|also|dobav|utochn|prodolzh|then|now)/i.test(compact);
}
function hasReferentialPointer(text) {
- return /(по этому|по тому|это же|этой|этим|этому|из этого|из них|из этих|из тех|в этом|тот же|same thing|that one|po etomu|po tomu)/i.test(text.toLowerCase());
+ return /(по этому|по тому|это же|этой|этим|этому|этого|этот|эту|этом|это|эти|этих|из этого|из них|из этих|из тех|в этом|тот же|same thing|that one|po etomu|po tomu)/i.test(text.toLowerCase());
}
function hasSmallTalkSignal(text) {
return /(привет|как дела|спасибо|благодарю|thanks|thank you|hello|hi)\b/i.test(text.toLowerCase());
@@ -1771,6 +1790,9 @@ function buildAddressDebugPayload(addressDebug, llmPreDecomposeMeta = null) {
sanitized_user_message: llmMeta?.sanitizedUserMessage ?? null,
tool_gate_decision: llmMeta?.toolGateDecision ?? null,
tool_gate_reason: llmMeta?.toolGateReason ?? null,
+ orchestration_contract_v1: llmMeta?.orchestrationContract ?? null,
+ dialog_continuation_contract_v2: llmMeta?.dialogContinuationContract ?? null,
+ address_retry_audit: llmMeta?.addressRetryAudit ?? null,
answer_structure_v11: null,
investigation_state_snapshot: null,
normalized: null,
@@ -1836,6 +1858,19 @@ const ADDRESS_PREDECOMPOSE_NOISE_TOKENS = new Set([
"list",
"skazhi",
"выведи",
+ "кроме",
+ "помимо",
+ "этого",
+ "этот",
+ "эта",
+ "эту",
+ "этом",
+ "это",
+ "эти",
+ "этих",
+ "другие",
+ "другое",
+ "остальное",
"что",
"чо",
"которые",
@@ -2284,7 +2319,8 @@ function textMojibakeScoreForAddress(value) {
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;
+ const doubleEncodedMarkers = (source.match(/(?:Г[Ђ-џ]|В[Ђ-џ]|Ã.|Â.)/gu) ?? []).length;
+ return cyrillic + latin - hardMarkers * 3 - pairMarkers * 2 - doubleEncodedMarkers * 2;
}
function looksLikeMojibakeForAddress(value) {
const source = String(value ?? "");
@@ -2294,7 +2330,13 @@ function looksLikeMojibakeForAddress(value) {
if (/[Ѓѓ‚„…†‡€‰‹ЉЊЌЋЏ‘’“”•–—™љ›њќћџ]/.test(source)) {
return true;
}
- return (source.match(/(?:Р.|С.|Ð.|Ñ.)/g) ?? []).length >= 2;
+ if ((source.match(/(?:Р.|С.|Ð.|Ñ.)/g) ?? []).length >= 2) {
+ return true;
+ }
+ if ((source.match(/(?:Г[Ђ-џ]|В[Ђ-џ]|Ã.|Â.)/gu) ?? []).length >= 2) {
+ return true;
+ }
+ return false;
}
function repairAddressMojibake(value) {
const source = String(value ?? "");
@@ -2302,22 +2344,39 @@ function repairAddressMojibake(value) {
return source;
}
let candidate = source;
- try {
- const fromWin1251 = iconv.encode(candidate, "win1251").toString("utf8");
- if (textMojibakeScoreForAddress(fromWin1251) > textMojibakeScoreForAddress(candidate)) {
- candidate = fromWin1251;
+ for (let pass = 0; pass < 3; pass += 1) {
+ let improved = false;
+ try {
+ const fromWin1251 = iconv.encode(candidate, "win1251").toString("utf8");
+ if (textMojibakeScoreForAddress(fromWin1251) > textMojibakeScoreForAddress(candidate)) {
+ candidate = fromWin1251;
+ improved = true;
+ }
+ }
+ catch (_error) { }
+ try {
+ const fromLatin1 = Buffer.from(candidate, "latin1").toString("utf8");
+ if (textMojibakeScoreForAddress(fromLatin1) > textMojibakeScoreForAddress(candidate)) {
+ candidate = fromLatin1;
+ improved = true;
+ }
+ }
+ catch (_error) { }
+ if (!improved) {
+ break;
}
}
- catch (_error) { }
- try {
- const fromLatin1 = Buffer.from(candidate, "latin1").toString("utf8");
- if (textMojibakeScoreForAddress(fromLatin1) > textMojibakeScoreForAddress(candidate)) {
- candidate = fromLatin1;
- }
- }
- catch (_error) { }
return candidate;
}
+function sanitizeOutgoingAssistantText(value, fallback = "Не смог сформировать читаемый ответ. Уточните запрос.") {
+ const repaired = repairAddressMojibake(String(value ?? ""));
+ const sanitized = String((0, answerComposer_1.sanitizeAssistantReplyForUserFacing)(repaired) ?? "").trim();
+ if (sanitized) {
+ return sanitized;
+ }
+ const fallbackText = String(fallback ?? "").trim();
+ return fallbackText || "Не смог сформировать читаемый ответ. Уточните запрос.";
+}
function extractAddressAnchorTokens(value) {
const source = repairAddressMojibake(compactWhitespace(String(value ?? "").toLowerCase()));
if (!source) {
@@ -2440,12 +2499,93 @@ function findRecentAddressFilterValue(items, key) {
}
return null;
}
+const ADDRESS_FOLLOWUP_OFFER_BY_INTENT = {
+ list_documents_by_counterparty: ["bank_operations_by_counterparty", "list_contracts_by_counterparty"],
+ bank_operations_by_counterparty: ["list_documents_by_counterparty", "list_contracts_by_counterparty"],
+ list_contracts_by_counterparty: ["list_documents_by_contract", "bank_operations_by_contract"],
+ list_documents_by_contract: ["bank_operations_by_contract"],
+ bank_operations_by_contract: ["list_documents_by_contract"],
+ open_items_by_counterparty_or_contract: ["list_documents_by_counterparty", "bank_operations_by_counterparty"]
+} as Record;
+function buildAddressFollowupOffer(addressDebug) {
+ if (!isAddressLaneDebugPayload(addressDebug)) {
+ return null;
+ }
+ const intent = toNonEmptyString(addressDebug.detected_intent);
+ if (!intent) {
+ return null;
+ }
+ const suggestedIntents = ADDRESS_FOLLOWUP_OFFER_BY_INTENT[intent];
+ if (!Array.isArray(suggestedIntents) || suggestedIntents.length === 0) {
+ return null;
+ }
+ const anchorType = toNonEmptyString(addressDebug.anchor_type);
+ const anchorValue = toNonEmptyString(addressDebug.anchor_value_resolved) ??
+ toNonEmptyString(addressDebug.anchor_value_raw) ??
+ readAddressFilterString(addressDebug, "counterparty") ??
+ readAddressFilterString(addressDebug, "contract") ??
+ readAddressFilterString(addressDebug, "account");
+ return {
+ enabled: true,
+ source_intent: intent,
+ anchor_type: anchorType ?? "unknown",
+ anchor_value: anchorValue,
+ suggested_intents: suggestedIntents
+ };
+}
+function hasAddressFollowupOffer(addressDebug) {
+ if (!addressDebug || typeof addressDebug !== "object") {
+ return false;
+ }
+ const existingOffer = addressDebug.address_followup_offer;
+ if (existingOffer && typeof existingOffer === "object") {
+ return existingOffer.enabled === true;
+ }
+ return Boolean(buildAddressFollowupOffer(addressDebug));
+}
+function isImplicitAddressContinuationByLlm(userMessage, llmPreDecomposeMeta) {
+ const contract = llmPreDecomposeMeta?.predecomposeContract;
+ if (!contract || typeof contract !== "object") {
+ return false;
+ }
+ const entities = contract.entities && typeof contract.entities === "object" ? contract.entities : {};
+ const hasEntity = [entities.account, entities.counterparty, entities.contract, entities.document_type, entities.document_ref, entities.organization]
+ .some((value) => Boolean(toNonEmptyString(value)));
+ if (hasEntity) {
+ return false;
+ }
+ const hasExplicitPeriod = Boolean(contract.period && typeof contract.period === "object" && contract.period.has_explicit_period);
+ if (hasExplicitPeriod) {
+ return false;
+ }
+ const mode = toNonEmptyString(contract.mode) ?? "unknown";
+ const modeConfidence = toNonEmptyString(contract.mode_confidence) ?? "low";
+ const intent = toNonEmptyString(contract.intent) ?? "unknown";
+ const intentConfidence = toNonEmptyString(contract.intent_confidence) ?? "low";
+ const shape = toNonEmptyString(contract.query_shape) ?? "UNKNOWN";
+ const shapeConfidence = toNonEmptyString(contract.query_shape_confidence) ?? "low";
+ const looksUnderspecified = (mode !== "address_query" || modeConfidence === "low") &&
+ (intent === "unknown" || intentConfidence === "low") &&
+ (shape === "UNKNOWN" || shapeConfidence === "low");
+ if (!looksUnderspecified) {
+ return false;
+ }
+ const normalized = compactWhitespace(repairAddressMojibake(String(userMessage ?? "")).toLowerCase());
+ if (!normalized || hasSmallTalkSignal(normalized) || /[??]$/.test(normalized)) {
+ return false;
+ }
+ const tokenCount = countTokens(normalized);
+ return tokenCount > 0 && tokenCount <= 4;
+}
function hasAddressFollowupContextSignal(userMessage) {
const repaired = repairAddressMojibake(String(userMessage ?? ""));
const text = compactWhitespace(repaired.toLowerCase());
if (!text) {
return false;
}
+ if (shouldHandleAsAssistantCapabilityMetaQuery(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;
}
@@ -2456,6 +2596,12 @@ function hasAddressFollowupContextSignal(userMessage) {
return true;
}
const shortFollowup = countTokens(text) <= 8;
+ if (/(?:кроме|помимо)\s+(?:этого|этой|этот|эту|этих|этого\s+документа|этого\s+договора|этого\s+контрагента)/iu.test(text)) {
+ return true;
+ }
+ if (/(?:есть\s+ещ[её]|что\s+ещ[её]|ещ[её]\s+что|ещ[её]\s+что-?то|остал(?:ось|ось\?)|друг(?:ое|ие))/iu.test(text) && countTokens(text) <= 12) {
+ return true;
+ }
if (shortFollowup && hasFollowupMarker(text)) {
return true;
}
@@ -2469,6 +2615,11 @@ function hasAddressFollowupContextSignal(userMessage) {
if (shortFollowup && /^(?:а|и)\s+кто\b/iu.test(text)) {
return true;
}
+ if (shortFollowup &&
+ /(?:почему|why|из[-\s]?за\s+чего|как\s+так|reason)/iu.test(text) &&
+ /(?:ндс|vat|прогноз|к\s+уплате|нул|ноль|\b0(?:[.,]0+)?\b)/iu.test(text)) {
+ return true;
+ }
if (shortFollowup &&
/(?:^|\s)по\s+[a-zа-яё][a-zа-яё0-9._-]{1,}(?=$|[\s,.;:!?])/iu.test(text) &&
!/(?:по\s+этому|по\s+тому|по\s+нему|по\s+ней|по\s+ним)/iu.test(text)) {
@@ -2479,19 +2630,35 @@ function hasAddressFollowupContextSignal(userMessage) {
}
return false;
}
-function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMessage = null) {
+function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMessage = null, llmPreDecomposeMeta = null) {
+ const previousAddressDebug = findLastAddressAssistantDebug(items);
+ const followupOffer = previousAddressDebug ? buildAddressFollowupOffer(previousAddressDebug) : null;
+ const hasImplicitContinuationSignal = Boolean(previousAddressDebug) &&
+ Boolean(followupOffer?.enabled) &&
+ (isImplicitAddressContinuationByLlm(userMessage, llmPreDecomposeMeta) ||
+ (toNonEmptyString(alternateMessage) ? isImplicitAddressContinuationByLlm(alternateMessage, llmPreDecomposeMeta) : false));
const hasPrimaryFollowupSignal = hasAddressFollowupContextSignal(userMessage);
const hasAlternateFollowupSignal = toNonEmptyString(alternateMessage)
? hasAddressFollowupContextSignal(alternateMessage)
: false;
- if (!hasPrimaryFollowupSignal && !hasAlternateFollowupSignal) {
+ if (!hasPrimaryFollowupSignal && !hasAlternateFollowupSignal && !hasImplicitContinuationSignal) {
return null;
}
- const previousAddressDebug = findLastAddressAssistantDebug(items);
if (!previousAddressDebug) {
return null;
}
- const previousIntent = toNonEmptyString(previousAddressDebug.detected_intent);
+ const sourceIntent = toNonEmptyString(previousAddressDebug.detected_intent);
+ let previousIntent = sourceIntent;
+ let followupSelectionMode = "carry_previous_intent";
+ if (hasImplicitContinuationSignal) {
+ const suggestedIntent = Array.isArray(followupOffer?.suggested_intents)
+ ? toNonEmptyString(followupOffer.suggested_intents[0])
+ : null;
+ if (suggestedIntent) {
+ previousIntent = suggestedIntent;
+ followupSelectionMode = "switch_to_suggested_intent";
+ }
+ }
const previousAnchorType = toNonEmptyString(previousAddressDebug.anchor_type);
const previousAnchor = toNonEmptyString(previousAddressDebug.anchor_value_resolved) ??
toNonEmptyString(previousAddressDebug.anchor_value_raw) ??
@@ -2525,9 +2692,65 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
previous_anchor_value: previousAnchor
},
previousAddressIntent: previousIntent,
- previousAddressAnchor: previousAnchor
+ previousAddressAnchor: previousAnchor,
+ previousSourceIntent: sourceIntent,
+ followupSelectionMode,
+ hasImplicitContinuationSignal
};
}
+function buildAddressDialogContinuationContractV2(userMessage, effectiveMessage, carryoverMeta, llmPreDecomposeMeta) {
+ const sourceMessage = String(userMessage ?? "");
+ const canonicalMessage = String(effectiveMessage ?? sourceMessage);
+ const hasFollowupContext = Boolean(carryoverMeta?.followupContext);
+ const previousIntent = toNonEmptyString(carryoverMeta?.previousSourceIntent) ?? null;
+ const targetIntent = toNonEmptyString(carryoverMeta?.previousAddressIntent) ?? null;
+ const selectionMode = toNonEmptyString(carryoverMeta?.followupSelectionMode) ?? null;
+ const hasImplicitContinuationSignal = Boolean(carryoverMeta?.hasImplicitContinuationSignal);
+ const rewrittenByPredecompose = compactWhitespace(sourceMessage.toLowerCase()) !== compactWhitespace(canonicalMessage.toLowerCase());
+ const hasExplicitIntent = Boolean(toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.intent));
+ const decision = !hasFollowupContext
+ ? "new_topic"
+ : selectionMode === "switch_to_suggested_intent"
+ ? "switch_to_suggested"
+ : "continue_previous";
+ const reasons = [];
+ if (hasFollowupContext) {
+ reasons.push("followup_context_detected");
+ }
+ if (hasImplicitContinuationSignal) {
+ reasons.push("implicit_continuation_by_llm");
+ }
+ if (rewrittenByPredecompose) {
+ reasons.push("effective_message_rewritten_by_predecompose");
+ }
+ if (hasExplicitIntent) {
+ reasons.push("llm_contract_intent_available");
+ }
+ return {
+ schema_version: "address_dialog_continuation_contract_v2",
+ source_message: sourceMessage,
+ effective_message: canonicalMessage,
+ decision,
+ decision_reasons: reasons,
+ followup_context_applied: hasFollowupContext,
+ previous_intent: previousIntent,
+ target_intent: targetIntent,
+ intent_selection_mode: selectionMode,
+ anchor_type: carryoverMeta?.followupContext?.previous_anchor_type ?? null,
+ anchor_value: carryoverMeta?.followupContext?.previous_anchor_value ?? null,
+ implicit_continuation_signal: hasImplicitContinuationSignal
+ };
+}
+function isRetryableAddressLimitedResult(addressLane) {
+ if (!addressLane || !addressLane.handled) {
+ return false;
+ }
+ if (String(addressLane.reply_type ?? "").trim() !== "partial_coverage") {
+ return false;
+ }
+ const category = String(addressLane?.debug?.limited_reason_category ?? "").trim().toLowerCase();
+ return category === "missing_anchor" || category === "empty_match";
+}
function isAddressLlmPreDecomposeCandidate(userMessage) {
const repaired = repairAddressMojibake(String(userMessage ?? ""));
const text = compactWhitespace(repaired.toLowerCase());
@@ -2719,7 +2942,30 @@ const ADDRESS_PREDECOMPOSE_LOW_QUALITY_COUNTERPARTY_TOKENS = new Set([
"сводную",
"сводном",
"сводного",
- "сводному"
+ "сводному",
+ "кроме",
+ "помимо",
+ "этого",
+ "этот",
+ "эта",
+ "эту",
+ "этом",
+ "это",
+ "эти",
+ "этих",
+ "документ",
+ "документа",
+ "документы",
+ "документов",
+ "договор",
+ "договора",
+ "контрагент",
+ "контрагента",
+ "еще",
+ "ещё",
+ "другие",
+ "другое",
+ "остальное"
]);
const ADDRESS_PREDECOMPOSE_LOW_QUALITY_CONTRACT_TOKENS = new Set([
"за",
@@ -3121,17 +3367,63 @@ async function runAddressLlmPreDecompose(normalizerService, payload, userMessage
}, userMessage);
}
}
-function resolveAddressToolGateDecision(addressInputMessage, followupContext, llmPreDecomposeMeta = null) {
+function resolveAddressToolGateDecision(addressInputMessage, followupContext, llmPreDecomposeMeta = null, rawUserMessage = null) {
const repairedInputMessage = repairAddressMojibake(String(addressInputMessage ?? ""));
+ const rawMessageForGate = String(rawUserMessage ?? addressInputMessage ?? "");
+ const dataScopeMetaQuery = hasAssistantDataScopeMetaQuestionSignal(rawMessageForGate) ||
+ hasAssistantDataScopeMetaQuestionSignal(repairedInputMessage);
+ const capabilityMetaQuery = shouldHandleAsAssistantCapabilityMetaQuery(rawMessageForGate) ||
+ shouldHandleAsAssistantCapabilityMetaQuery(repairedInputMessage);
+ const dataRetrievalSignal = hasDataRetrievalRequestSignal(rawMessageForGate) ||
+ hasDataRetrievalRequestSignal(repairedInputMessage);
+ if (dataScopeMetaQuery || (capabilityMetaQuery && !dataRetrievalSignal)) {
+ return {
+ runAddressLane: false,
+ decision: "skip_address_lane",
+ reason: dataScopeMetaQuery ? "assistant_data_scope_query_detected" : "assistant_capability_query_detected"
+ };
+ }
const modeDetection = (0, addressQueryClassifier_1.detectAddressQuestionMode)(repairedInputMessage || addressInputMessage);
const hasClassifierSignal = modeDetection.mode === "address_query";
- const hasLlmCanonicalSignal = Boolean(llmPreDecomposeMeta?.llmCanonicalCandidateDetected);
- const hasMessageSignal = hasClassifierSignal ||
- hasLlmCanonicalSignal ||
- isAddressLlmPreDecomposeCandidate(addressInputMessage) ||
+ const llmContractMode = toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.mode);
+ const llmContractModeConfidence = toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.mode_confidence);
+ const llmContractIntent = toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.intent);
+ const llmContractIntentConfidence = toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.intent_confidence);
+ const hasLlmCanonicalSignal = Boolean(llmPreDecomposeMeta?.llmCanonicalCandidateDetected) &&
+ llmContractMode === "address_query" &&
+ llmContractModeConfidence !== "low" &&
+ llmContractIntent !== null &&
+ llmContractIntent !== "unknown" &&
+ llmContractIntentConfidence !== "low";
+ const hasLlmCanonicalDataSignal = Boolean(llmPreDecomposeMeta?.llmCanonicalCandidateDetected) &&
+ Boolean(llmPreDecomposeMeta?.applied) &&
+ llmContractMode === "address_query" &&
+ hasStrongDataIntentSignal(repairedInputMessage);
+ const hasLexicalAddressSignal = isAddressLlmPreDecomposeCandidate(addressInputMessage) ||
isAddressLlmPreDecomposeCandidate(repairedInputMessage) ||
hasAccountingSignal(addressInputMessage) ||
hasAccountingSignal(repairedInputMessage);
+ const hasUnsupportedLowConfidencePredecomposeSignal = llmContractMode === "unsupported" &&
+ (llmContractModeConfidence === "low" || llmContractModeConfidence === "medium") &&
+ llmContractIntent === "unknown";
+ const hasAnyAddressSignal = hasClassifierSignal || hasLlmCanonicalSignal || hasLlmCanonicalDataSignal || hasLexicalAddressSignal;
+ const strongDataSignalFromRawMessage = hasStrongDataIntentSignal(rawMessageForGate) ||
+ hasAccountingSignal(rawMessageForGate) ||
+ hasSameDateAccountFollowupSignalForPredecompose(rawMessageForGate);
+ const strongDataSignalFromEffectiveMessage = hasStrongDataIntentSignal(repairedInputMessage) ||
+ hasAccountingSignal(repairedInputMessage) ||
+ hasDataRetrievalRequestSignal(repairedInputMessage);
+ if (hasUnsupportedLowConfidencePredecomposeSignal && !followupContext &&
+ !hasAnyAddressSignal &&
+ !strongDataSignalFromRawMessage &&
+ !strongDataSignalFromEffectiveMessage) {
+ return {
+ runAddressLane: false,
+ decision: "skip_address_lane",
+ reason: "llm_predecompose_unsupported_mode"
+ };
+ }
+ const hasMessageSignal = hasAnyAddressSignal;
if (hasMessageSignal) {
return {
runAddressLane: true,
@@ -3140,6 +3432,8 @@ function resolveAddressToolGateDecision(addressInputMessage, followupContext, ll
? "address_mode_classifier_detected"
: hasLlmCanonicalSignal
? "llm_canonical_candidate_detected"
+ : hasLlmCanonicalDataSignal
+ ? "llm_canonical_data_signal_detected"
: "address_signal_detected"
};
}
@@ -3156,18 +3450,883 @@ function resolveAddressToolGateDecision(addressInputMessage, followupContext, ll
reason: "no_address_signal_after_l0"
};
}
+export function resolveAssistantOrchestrationDecision(input) {
+ const rawUserMessage = String(input?.rawUserMessage ?? input?.userMessage ?? "");
+ const effectiveAddressUserMessage = String(input?.effectiveAddressUserMessage ?? rawUserMessage);
+ const repairedRawUserMessage = repairAddressMojibake(rawUserMessage);
+ const repairedEffectiveAddressUserMessage = repairAddressMojibake(effectiveAddressUserMessage);
+ const followupContext = input?.followupContext ?? null;
+ const llmPreDecomposeMeta = input?.llmPreDecomposeMeta ?? null;
+ const useMock = Boolean(input?.useMock);
+ const dataScopeMetaQuery = hasAssistantDataScopeMetaQuestionSignal(rawUserMessage) ||
+ hasAssistantDataScopeMetaQuestionSignal(repairedRawUserMessage) ||
+ hasAssistantDataScopeMetaQuestionSignal(effectiveAddressUserMessage) ||
+ hasAssistantDataScopeMetaQuestionSignal(repairedEffectiveAddressUserMessage);
+ const capabilityMetaQuery = shouldHandleAsAssistantCapabilityMetaQuery(rawUserMessage) ||
+ shouldHandleAsAssistantCapabilityMetaQuery(repairedRawUserMessage) ||
+ shouldHandleAsAssistantCapabilityMetaQuery(effectiveAddressUserMessage) ||
+ shouldHandleAsAssistantCapabilityMetaQuery(repairedEffectiveAddressUserMessage);
+ const dataRetrievalSignal = hasDataRetrievalRequestSignal(rawUserMessage) ||
+ hasDataRetrievalRequestSignal(repairedRawUserMessage) ||
+ hasDataRetrievalRequestSignal(effectiveAddressUserMessage) ||
+ hasDataRetrievalRequestSignal(repairedEffectiveAddressUserMessage);
+ const modeSample = repairedEffectiveAddressUserMessage || effectiveAddressUserMessage;
+ const modeDetection = (0, addressQueryClassifier_1.detectAddressQuestionMode)(modeSample);
+ const intentResolution = (0, addressIntentResolver_1.resolveAddressIntent)(modeSample);
+ const strongDataSignal = hasStrongDataIntentSignal(rawUserMessage) ||
+ hasStrongDataIntentSignal(repairedRawUserMessage) ||
+ hasStrongDataIntentSignal(effectiveAddressUserMessage) ||
+ hasStrongDataIntentSignal(repairedEffectiveAddressUserMessage) ||
+ hasAccountingSignal(rawUserMessage) ||
+ hasAccountingSignal(repairedRawUserMessage) ||
+ hasAccountingSignal(effectiveAddressUserMessage) ||
+ hasAccountingSignal(repairedEffectiveAddressUserMessage) ||
+ hasDataRetrievalRequestSignal(rawUserMessage) ||
+ hasDataRetrievalRequestSignal(repairedRawUserMessage);
+ const hardMetaMode = dataScopeMetaQuery
+ ? "data_scope"
+ : capabilityMetaQuery && !dataRetrievalSignal
+ ? "capability"
+ : null;
+ if (hardMetaMode === "data_scope") {
+ return {
+ runAddressLane: false,
+ toolGateDecision: "skip_address_lane",
+ toolGateReason: "assistant_data_scope_query_detected",
+ livingMode: "chat",
+ livingReason: "assistant_data_scope_query_detected",
+ orchestrationContract: {
+ schema_version: "assistant_orchestration_contract_v1",
+ hard_meta_mode: "data_scope",
+ address_mode: modeDetection.mode,
+ address_mode_confidence: modeDetection.confidence,
+ address_intent: intentResolution.intent,
+ address_intent_confidence: intentResolution.confidence,
+ strong_data_signal_detected: strongDataSignal,
+ data_retrieval_signal_detected: dataRetrievalSignal,
+ followup_context_detected: Boolean(followupContext),
+ unsupported_address_intent_fallback_to_deep: false,
+ final_decision: {
+ run_address_lane: false,
+ tool_gate_decision: "skip_address_lane",
+ tool_gate_reason: "assistant_data_scope_query_detected",
+ living_mode: "chat",
+ living_reason: "assistant_data_scope_query_detected"
+ }
+ }
+ };
+ }
+ if (hardMetaMode === "capability") {
+ return {
+ runAddressLane: false,
+ toolGateDecision: "skip_address_lane",
+ toolGateReason: "assistant_capability_query_detected",
+ livingMode: "chat",
+ livingReason: "assistant_capability_query_detected",
+ orchestrationContract: {
+ schema_version: "assistant_orchestration_contract_v1",
+ hard_meta_mode: "capability",
+ address_mode: modeDetection.mode,
+ address_mode_confidence: modeDetection.confidence,
+ address_intent: intentResolution.intent,
+ address_intent_confidence: intentResolution.confidence,
+ strong_data_signal_detected: strongDataSignal,
+ data_retrieval_signal_detected: dataRetrievalSignal,
+ followup_context_detected: Boolean(followupContext),
+ unsupported_address_intent_fallback_to_deep: false,
+ final_decision: {
+ run_address_lane: false,
+ tool_gate_decision: "skip_address_lane",
+ tool_gate_reason: "assistant_capability_query_detected",
+ living_mode: "chat",
+ living_reason: "assistant_capability_query_detected"
+ }
+ }
+ };
+ }
+ const baseToolGate = resolveAddressToolGateDecision(effectiveAddressUserMessage, followupContext, llmPreDecomposeMeta, rawUserMessage);
+ const unsupportedAddressIntentFallbackToDeep = Boolean(!followupContext &&
+ baseToolGate?.runAddressLane &&
+ modeDetection.mode === "address_query" &&
+ intentResolution.intent === "unknown" &&
+ strongDataSignal);
+ let runAddressLane = Boolean(baseToolGate?.runAddressLane);
+ let toolGateDecision = String(baseToolGate?.decision ?? "skip_address_lane");
+ let toolGateReason = String(baseToolGate?.reason ?? "no_address_signal_after_l0");
+ if (unsupportedAddressIntentFallbackToDeep) {
+ runAddressLane = false;
+ toolGateDecision = "skip_address_lane";
+ toolGateReason = "address_signal_unsupported_intent_fallback_to_deep";
+ }
+ let livingDecision = resolveLivingAssistantModeDecision({
+ userMessage: rawUserMessage,
+ addressLaneTriggered: runAddressLane,
+ useMock,
+ predecomposeMode: llmPreDecomposeMeta?.predecomposeContract?.mode ?? null,
+ predecomposeModeConfidence: llmPreDecomposeMeta?.predecomposeContract?.mode_confidence ?? null
+ });
+ if (unsupportedAddressIntentFallbackToDeep) {
+ livingDecision = {
+ mode: "deep_analysis",
+ reason: "unsupported_address_intent_fallback_to_deep"
+ };
+ }
+ return {
+ runAddressLane,
+ toolGateDecision,
+ toolGateReason,
+ livingMode: livingDecision.mode,
+ livingReason: livingDecision.reason,
+ orchestrationContract: {
+ schema_version: "assistant_orchestration_contract_v1",
+ hard_meta_mode: null,
+ address_mode: modeDetection.mode,
+ address_mode_confidence: modeDetection.confidence,
+ address_intent: intentResolution.intent,
+ address_intent_confidence: intentResolution.confidence,
+ strong_data_signal_detected: strongDataSignal,
+ data_retrieval_signal_detected: dataRetrievalSignal,
+ followup_context_detected: Boolean(followupContext),
+ unsupported_address_intent_fallback_to_deep: unsupportedAddressIntentFallbackToDeep,
+ final_decision: {
+ run_address_lane: runAddressLane,
+ tool_gate_decision: toolGateDecision,
+ tool_gate_reason: toolGateReason,
+ living_mode: livingDecision.mode,
+ living_reason: livingDecision.reason
+ }
+ }
+ };
+}
+function hasStrongDataIntentSignal(text) {
+ const lower = String(text ?? "").toLowerCase();
+ return /(база|док|документ|проводк|контрагент|договор|контракт|счет|сч[её]т|остат|сальдо|хвост|платеж|плат[её]ж|операц|поставщик|клиент|заказчик|дебитор|кредитор|оборот|баланс|период|месяц|год|инн|mcp|bank|counterparty|contract|document|ledger|posting|account)/i.test(lower);
+}
+function hasDataRetrievalRequestSignal(text) {
+ const lower = compactWhitespace(String(text ?? "").toLowerCase());
+ if (!lower) {
+ return false;
+ }
+ const hasExplicitRetrievalAction = /(?:\bпокажи\b|\bпоказать\b|\bвыведи\b|\bнайди\b|\bсписок\b|\bдай\b|\bраскрой\b|\bshow\b|\blist\b|\bfind\b|\bcount\b)/i.test(lower);
+ const hasInterrogativeRetrievalAction = /(?:\bсколько\b|\bкакой\b|\bкакая\b|\bкакое\b|\bкакую\b|\bкакие\b|\bкто\b|\bwhich\b|\bwho\b)/i.test(lower);
+ if (!hasExplicitRetrievalAction && !hasInterrogativeRetrievalAction) {
+ return false;
+ }
+ const hasRetrievalObject = /(1с|база|док|документ|контрагент|договор|контракт|счет|сч[её]т|остат|сальдо|хвост|платеж|плат[её]ж|операц|поставщик|клиент|заказчик|дебитор|кредитор|период|месяц|год|инн|bank|counterparty|contract|document|account|balance|ledger|posting)/i.test(lower);
+ if (!hasRetrievalObject) {
+ return false;
+ }
+ if (hasExplicitRetrievalAction) {
+ return true;
+ }
+ const hasMetaCapabilityShape = /(?:мож(?:ем|ешь|ете|но)|уме(?:ешь|ете)|доступ|подключ|чья|как\s+называ(?:ет|ется)|работ(?:ать|аем|аешь|аете)|в\s+тебе|у\s+тебя)/i.test(lower);
+ return !hasMetaCapabilityShape;
+}
+function hasOperationalAdminActionRequestSignal(text) {
+ const lower = compactWhitespace(String(text ?? "").toLowerCase()).replace(/ё/g, "е");
+ const normalized = lower.replace(/\b1\s*[cс]\b/giu, "1с");
+ if (!normalized) {
+ return false;
+ }
+ const hasAdminVerb = /(?:настро(?:й|ить|ите|им)|установ(?:и|ить|ите)|подключ(?:и|ить|ите)|обнов(?:и|ить|ите)|почин(?:и|ить|ите)|исправ(?:ь|ить|ьте)|перенастро|перезапуст|удал(?:и|ить|ите|яй)|снес(?:и|ти)|очист(?:и|ить)|восстанов(?:и|ить)|созд(?:ай|ать)|провед(?:и|и)|заведи|завести)/i.test(normalized);
+ const hasAdminObject = /(?:1с|1c|баз|сервер|конфиг|конфигурац|платформ|админ|доступ|роль|права|db|database|документ)/i.test(normalized);
+ if (hasAdminVerb && hasAdminObject) {
+ return true;
+ }
+ return /(?:удаляй?\s+баз|удали\s+баз|снеси\s+баз|delete\s+database|drop\s+database)/i.test(normalized);
+}
+function hasDangerOrCoercionSignal(text) {
+ const lower = compactWhitespace(String(text ?? "").toLowerCase()).replace(/ё/g, "е");
+ if (!lower) {
+ return false;
+ }
+ return /(?:убьют|убьют|убью|убить|убиют|угрож|опасн|омон|полици|насили|заставля|принужда|шантаж)/i.test(lower);
+}
+function hasDestructiveDataActionSignal(text) {
+ const lower = compactWhitespace(String(text ?? "").toLowerCase()).replace(/ё/g, "е");
+ if (!lower) {
+ return false;
+ }
+ return /(?:удаляй?\s+баз|удали\s+баз|снеси\s+баз|drop\s+database|delete\s+database|очисти\s+баз)/i.test(lower);
+}
+function hasAssistantCapabilityQuestionSignal(text) {
+ const repaired = repairAddressMojibake(String(text ?? ""));
+ const lower = compactWhitespace(repaired.toLowerCase()).replace(/ё/g, "е");
+ const normalized = lower.replace(/\b1\s*[cс]\b/giu, "1с");
+ if (!lower) {
+ return false;
+ }
+ const directCapabilityPhrases = [
+ "кто ты",
+ "что ты можешь",
+ "что конкретно ты можешь",
+ "чем ты можешь помочь",
+ "что ты умеешь",
+ "какой у тебя функционал",
+ "какие у тебя функции",
+ "какие фичи",
+ "что отработано",
+ "что у тебя отработано",
+ "полный список возможностей",
+ "полный список"
+ ];
+ if (directCapabilityPhrases.some((phrase) => normalized.includes(phrase))) {
+ return true;
+ }
+ if (/(?:каки[ею].*(?:фич|функц|возможност|отработан)|какого\s+рода\s+ошибк.*ты\s+мож(?:ешь|ете)|какие\s+ошибк.*ты\s+мож(?:ешь|ете))/iu.test(normalized)) {
+ return true;
+ }
+ const hasCanVerb = /(?:можешь|можете|умеешь|умеете|можно)/i.test(normalized);
+ const hasControlAction = /(?:настро|установ|подключ|обнов|созда|подготов|сдела|делат|дела)/i.test(normalized);
+ const hasAnalysisAction = /(?:найт|искать|провер|анализ|разоб|объясн|расска|подсказ|показ)/i.test(normalized);
+ const hasCapabilityObject = /(?:1с|1c|док|документ|баз|отчет|отч[её]т|конфигурац|настройк)/i.test(normalized);
+ if (hasCanVerb && hasControlAction && hasCapabilityObject) {
+ return true;
+ }
+ if (hasCanVerb && hasAnalysisAction && !hasDataRetrievalRequestSignal(normalized)) {
+ return true;
+ }
+ const hasCapabilityMetaQuestion = /(?:что|чем)\s+(?:ты\s+)?(?:мож(?:ешь|ете)|уме(?:ешь|ете)|можно)(?=$|[\s,.!?;:])/iu.test(normalized);
+ if (hasCapabilityMetaQuestion && hasCapabilityObject) {
+ return true;
+ }
+ if ((normalized.includes("1с") || normalized.includes("1c")) && hasCanVerb && /(?:настро|установ|подключ|обнов)/i.test(normalized)) {
+ return true;
+ }
+ return false;
+}
+function hasAssistantDataScopeMetaQuestionSignal(text) {
+ const repaired = repairAddressMojibake(String(text ?? ""));
+ const lower = compactWhitespace(repaired.toLowerCase()).replace(/ё/g, "е");
+ const normalized = lower.replace(/\b1\s*[cс]\b/giu, "1с");
+ if (!normalized) {
+ return false;
+ }
+ const hasBaseOrTenantObject = /(?:баз(?:а|е|у|ы)?|тенант|tenant|контур)/i.test(normalized);
+ const hasCompanyObject = /(?:компан(?:ия|ии|ию|ией)|компин(?:ия|ии|ию|ией)?|компини(?:я|и|ю|ей)?|компани[яеию]|организац(?:ия|ии|ию|ией)|контор(?:а|ы|у|ой)?|фирм(?:а|ы|у|ой)?)/i.test(normalized);
+ const hasConnectionCue = /(?:подключен(?:а|о|ы)?|подруб|воткнут|активн(?:ый|ая)\s+канал|mcp-?канал|канал)/i.test(normalized);
+ const hasNamingCue = /(?:как\s+называ(?:ет|ется)|что\s+за\s+(?:контор|компан|организац|фирм))/i.test(normalized);
+ const hasWorkabilityCue = /(?:мож(?:ем|ешь|ете|но)\s+работ|работ(?:ать|аем|аешь|аете))/i.test(normalized);
+ const hasScopeObject = hasBaseOrTenantObject || hasCompanyObject || hasConnectionCue;
+ if (!hasScopeObject) {
+ return false;
+ }
+ const hasMetaPerspective = /(?:ты|тебе|твой|у\s+тебя|в\s+тебе|мы|нам|наш(?:а|е|и|у|ей)?|сейчас|щас)/i.test(normalized);
+ const hasScopedInterrogativePair = /(?:^|\s)(?:по\s+какой|с\s+какой|какая|какой|какие)\s+(?:баз|компан|компин|компини|компани|организац|контор|фирм|тенант|контур)/i.test(normalized);
+ const hasScopeQuestion = /(?:чья|чье|чьи|доступн|подключен|подруб|воткнут|какая\s+баз|какой\s+баз)/i.test(normalized) ||
+ hasNamingCue ||
+ hasWorkabilityCue ||
+ hasScopedInterrogativePair;
+ const hasInterrogativeScopeLead = /(?:^|\s)(?:по\s+какой|с\s+какой|чья|чье|чьи|which|who|what)/i.test(normalized);
+ const isQuestionLike = /[?]/.test(String(text ?? "")) || hasInterrogativeScopeLead || hasScopedInterrogativePair;
+ const hasExplicitScopeContext = hasBaseOrTenantObject || hasConnectionCue || hasWorkabilityCue || hasNamingCue;
+ const hasRetrievalSignal = hasDataRetrievalRequestSignal(normalized);
+ const hasContractAnalyticsCue = /(?:договор|контракт|contract).*(?:топ|сам(?:ый|ая|ое|ые)|крупн|жирн|оборот|бюджет|сумм|стоим|value|turnover|all\s+time|всю\s+истори|за\s+вс[её]\s+время)/iu.test(normalized);
+ if (hasContractAnalyticsCue) {
+ return false;
+ }
+ if (hasRetrievalSignal && !hasExplicitScopeContext) {
+ return false;
+ }
+ const hasEligibleScopeObject = hasBaseOrTenantObject || (hasCompanyObject && (hasConnectionCue || hasWorkabilityCue || hasNamingCue || hasMetaPerspective));
+ return hasEligibleScopeObject && hasScopeQuestion && (hasMetaPerspective || isQuestionLike || hasExplicitScopeContext);
+}
+function shouldHandleAsAssistantCapabilityMetaQuery(text) {
+ const raw = String(text ?? "");
+ const repaired = repairAddressMojibake(raw);
+ const hasScopeMetaSignal = hasAssistantDataScopeMetaQuestionSignal(raw) || hasAssistantDataScopeMetaQuestionSignal(repaired);
+ if (hasScopeMetaSignal) {
+ return true;
+ }
+ const hasCapabilitySignal = hasAssistantCapabilityQuestionSignal(raw) ||
+ hasAssistantCapabilityQuestionSignal(repaired) ||
+ hasOperationalAdminActionRequestSignal(raw) ||
+ hasOperationalAdminActionRequestSignal(repaired);
+ const hasRetrievalSignal = hasDataRetrievalRequestSignal(raw) || hasDataRetrievalRequestSignal(repaired);
+ return hasCapabilitySignal && !hasRetrievalSignal;
+}
+function hasLivingChatSignal(text) {
+ const lower = compactWhitespace(String(text ?? "").toLowerCase());
+ if (!lower) {
+ return false;
+ }
+ if (/^(ага|угу|ок|окей|ясно|понял|поняла|принято|спасибо|благодарю|супер|класс|норм|го|давай|погнали|привет|хай|йо|hello|hi|thanks?)$/i.test(lower)) {
+ return true;
+ }
+ if (/(как дела|как ты|что нового|расскажи о себе|чем можешь помочь|давай поговорим|поговорим|обсудим|посоветуй|что думаешь)/i.test(lower)) {
+ return true;
+ }
+ return hasSmallTalkSignal(lower);
+}
+function buildLivingChatContextWindow(items) {
+ const source = Array.isArray(items) ? items.slice(-6) : [];
+ const lines = [];
+ for (const item of source) {
+ if (!item || typeof item !== "object") {
+ continue;
+ }
+ const role = String(item.role ?? "").trim();
+ const text = compactWhitespace(String(item.text ?? ""));
+ if (!role || !text) {
+ continue;
+ }
+ const clipped = text.length > 220 ? `${text.slice(0, 220)}...` : text;
+ lines.push(`${role}: ${clipped}`);
+ }
+ return lines.join("\n");
+}
+function buildLivingChatPrompt(userMessage, conversationWindow) {
+ const contextBlock = conversationWindow
+ ? `Контекст последних сообщений:\n${conversationWindow}\n\n`
+ : "";
+ return `${contextBlock}Сообщение пользователя:\n${userMessage}`;
+}
+function buildAssistantCapabilityContractReply() {
+ return [
+ "Я ассистент по анализу данных 1С в режиме чтения.",
+ "Что умею сейчас:",
+ "1. Находить документы, операции, договоры и остатки по контрагенту/договору/периоду.",
+ "2. Делать агрегаты по базе: активность, роли контрагентов, top-срезы по суммам и операциям.",
+ "3. Кратко объяснять результат и подсказывать следующий точный запрос.",
+ "Что не умею:",
+ "1. Не настраиваю 1С и не меняю конфигурацию.",
+ "2. Не создаю и не провожу документы в базе.",
+ "3. Не выполняю админские действия на сервере."
+ ].join("\n");
+}
+function normalizeScopeLabel(value) {
+ const repaired = repairAddressMojibake(String(value ?? ""));
+ let normalized = compactWhitespace(repaired.trim());
+ for (let index = 0; index < 2; index += 1) {
+ const first = normalized[0];
+ const last = normalized[normalized.length - 1];
+ const wrappedInQuotes = (first === "\"" && last === "\"") ||
+ (first === "'" && last === "'") ||
+ (first === "«" && last === "»");
+ if (!wrappedInQuotes) {
+ break;
+ }
+ normalized = compactWhitespace(normalized.slice(1, -1).trim());
+ }
+ if (!normalized) {
+ return null;
+ }
+ if (/^(?:null|undefined|nan|0|не\s*заполнено)$/i.test(normalized)) {
+ return null;
+ }
+ return normalized;
+}
+function normalizeScopeKey(value) {
+ return repairAddressMojibake(String(value ?? "")).toLowerCase().replace(/ё/g, "е");
+}
+function normalizeGuidValue(value) {
+ const source = normalizeScopeLabel(value);
+ if (!source) {
+ return null;
+ }
+ const match = source.match(/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/i);
+ return match ? String(match[0]).toLowerCase() : null;
+}
+function extractGuidValuesFromText(value) {
+ const source = normalizeScopeLabel(value);
+ if (!source) {
+ return [];
+ }
+ const matches = source.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/ig);
+ if (!matches || matches.length === 0) {
+ return [];
+ }
+ return Array.from(new Set(matches.map((item) => String(item).toLowerCase())));
+}
+function hasOrganizationKeyHint(key) {
+ const source = String(key ?? "");
+ return /(?:организац|organization|company|контор|org|ð¾ñð³ð°ð½ð¸ð·ð°ñ)/i.test(source);
+}
+function hasNameKeyHint(key) {
+ const source = String(key ?? "");
+ return /(?:представ|наимен|name|title|display|presentation|description|ð¿ñðµð´ññð°ð²|ð½ð°ð¸ð¼ðµð½)/i.test(source);
+}
+function hasGuidKeyHint(key) {
+ const source = String(key ?? "");
+ return /(?:идентифик|guid|uuid|key|ref|ссылк|\bid\b|ð¸ð´ðµð½ñð¸ñ|ñññð»)/i.test(source);
+}
+function looksLikeOrganizationTypeMarker(value) {
+ const normalized = normalizeScopeKey(value);
+ const raw = String(value ?? "").toLowerCase();
+ return /(?:справочникссылка\.\s*организац|catalogref\.\s*organization|организац|organization|company|ð¾ñð³ð°ð½ð¸ð·ð°ñ|ð¡ð¿ñð°ð²ð¾ñð½ð¸ðºð¡ññð»ðºð°\.ðñð³ð°ð½ð¸ð·)/i.test(normalized)
+ || /(?:ð¾ñð³ð°ð½ð¸ð·ð°ñ|ð¡ð¿ñð°ð²ð¾ñð½ð¸ðºð¡ññð»ðºð°\.ðñð³ð°ð½ð¸ð·)/i.test(raw);
+}
+function isPlausibleOrganizationName(value) {
+ const candidate = normalizeScopeLabel(value);
+ if (!candidate) {
+ return false;
+ }
+ if (/^(?:период|регистратор|счетдт|счеткт|amount|period|registrator|accountdt|accountkt)$/i.test(candidate)) {
+ return false;
+ }
+ if (/^[0-9._:/\\-]+$/i.test(candidate)) {
+ return false;
+ }
+ if (/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i.test(candidate)) {
+ return false;
+ }
+ if (/(?:справочникссылка|документссылка|плансчетовссылка|standardodata|recordtype|cmp:)/i.test(candidate)) {
+ return false;
+ }
+ return /[A-Za-zА-Яа-яЁё]/u.test(candidate);
+}
+function appendOrganizationFactsFromValue(value, hints, bucket, depth = 0) {
+ if (depth > 4 || value === null || value === undefined) {
+ return;
+ }
+ if (typeof value === "string") {
+ for (const guid of extractGuidValuesFromText(value)) {
+ if (hints.guidHint || hints.organizationHint || hints.nameHint) {
+ bucket.refs.push(guid);
+ }
+ }
+ if ((hints.organizationHint || hints.nameHint) && isPlausibleOrganizationName(value)) {
+ const normalized = normalizeScopeLabel(value);
+ if (normalized) {
+ bucket.names.push(normalized);
+ }
+ }
+ return;
+ }
+ if (Array.isArray(value)) {
+ for (const item of value) {
+ appendOrganizationFactsFromValue(item, hints, bucket, depth + 1);
+ }
+ return;
+ }
+ if (typeof value === "object") {
+ const entries = Object.entries(value);
+ let objectIsOrganization = false;
+ let hasObjectRefMarker = false;
+ let hasGuidLikeField = false;
+ let hasTypeMarker = false;
+ for (const [rawKey, rawVal] of entries) {
+ const key = normalizeScopeKey(rawKey);
+ if ((key.includes("objectref") || key.includes("_objectref")) && rawVal === true) {
+ hasObjectRefMarker = true;
+ }
+ if (typeof rawVal === "string" && normalizeGuidValue(rawVal)) {
+ hasGuidLikeField = true;
+ }
+ if (hasOrganizationKeyHint(key)) {
+ objectIsOrganization = true;
+ break;
+ }
+ if ((key.includes("типобъекта") || key.includes("type")) && typeof rawVal === "string" && looksLikeOrganizationTypeMarker(rawVal)) {
+ objectIsOrganization = true;
+ hasTypeMarker = true;
+ break;
+ }
+ }
+ if (!objectIsOrganization && hasObjectRefMarker && hasGuidLikeField) {
+ const hasNameLikeValue = entries.some(([rawKey, rawVal]) => {
+ if (typeof rawVal !== "string") {
+ return false;
+ }
+ const key = normalizeScopeKey(rawKey);
+ return hasNameKeyHint(key) || isPlausibleOrganizationName(rawVal);
+ });
+ if (hasTypeMarker || hasNameLikeValue) {
+ objectIsOrganization = true;
+ }
+ }
+ for (const [rawKey, rawVal] of entries) {
+ if (String(rawKey ?? "").startsWith("__")) {
+ continue;
+ }
+ const key = normalizeScopeKey(rawKey);
+ const childHints = {
+ organizationHint: hints.organizationHint || objectIsOrganization || hasOrganizationKeyHint(key),
+ nameHint: hints.nameHint || objectIsOrganization || hasNameKeyHint(key),
+ guidHint: hints.guidHint || objectIsOrganization || hasGuidKeyHint(key)
+ };
+ if (typeof rawVal === "string") {
+ const guid = normalizeGuidValue(rawVal);
+ if (guid && childHints.guidHint) {
+ bucket.refs.push(guid);
+ }
+ }
+ appendOrganizationFactsFromValue(rawVal, childHints, bucket, depth + 1);
+ }
+ }
+}
+function extractOrganizationFactsFromRows(rows) {
+ const names = [];
+ const refs = [];
+ const pairs = [];
+ for (const row of Array.isArray(rows) ? rows : []) {
+ if (!row || typeof row !== "object") {
+ continue;
+ }
+ const rowNames = [];
+ const rowRefs = [];
+ for (const [rawKey, rawValue] of Object.entries(row)) {
+ if (String(rawKey ?? "").startsWith("__")) {
+ continue;
+ }
+ const key = normalizeScopeKey(rawKey);
+ const hints = {
+ organizationHint: hasOrganizationKeyHint(key),
+ nameHint: hasNameKeyHint(key),
+ guidHint: hasGuidKeyHint(key)
+ };
+ appendOrganizationFactsFromValue(rawValue, hints, {
+ names: rowNames,
+ refs: rowRefs
+ });
+ }
+ const dedupRowNames = Array.from(new Set(rowNames))
+ .filter((item) => isPlausibleOrganizationName(item))
+ .slice(0, 20);
+ const dedupRowRefs = Array.from(new Set(rowRefs))
+ .map((item) => String(item ?? "").toLowerCase())
+ .filter((item) => /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(item))
+ .slice(0, 20);
+ if (dedupRowNames.length === 0 && dedupRowRefs.length === 0) {
+ const fallbackBucket = { names: [], refs: [] };
+ appendOrganizationFactsFromValue(row, {
+ organizationHint: true,
+ nameHint: true,
+ guidHint: true
+ }, fallbackBucket);
+ for (const value of fallbackBucket.names) {
+ if (isPlausibleOrganizationName(value)) {
+ dedupRowNames.push(value);
+ }
+ }
+ for (const value of fallbackBucket.refs) {
+ const normalized = String(value ?? "").toLowerCase();
+ if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(normalized)) {
+ dedupRowRefs.push(normalized);
+ }
+ }
+ }
+ for (const name of dedupRowNames) {
+ names.push(name);
+ }
+ for (const ref of dedupRowRefs) {
+ refs.push(ref);
+ }
+ if (dedupRowRefs.length > 0 && dedupRowNames.length > 0) {
+ for (const ref of dedupRowRefs) {
+ for (const name of dedupRowNames) {
+ pairs.push({ ref, name });
+ }
+ }
+ }
+ }
+ return {
+ names: Array.from(new Set(names)).slice(0, 20),
+ refs: Array.from(new Set(refs)).slice(0, 20),
+ pairs: Array.from(new Set(pairs.map((item) => `${item.ref}||${item.name}`)))
+ .map((token) => {
+ const [ref, name] = token.split("||");
+ return { ref, name };
+ })
+ .slice(0, 100)
+ };
+}
+function resolveOrganizationNamesByRefs(refs, facts) {
+ const refSet = new Set((Array.isArray(refs) ? refs : [])
+ .map((item) => String(item ?? "").toLowerCase())
+ .filter((item) => item.length > 0));
+ if (refSet.size === 0) {
+ return [];
+ }
+ const names = [];
+ for (const pair of Array.isArray(facts?.pairs) ? facts.pairs : []) {
+ const ref = String(pair?.ref ?? "").toLowerCase();
+ const name = normalizeScopeLabel(pair?.name ?? "");
+ if (!ref || !name || !refSet.has(ref)) {
+ continue;
+ }
+ names.push(name);
+ }
+ return Array.from(new Set(names)).slice(0, 20);
+}
+function buildResolvedDataScopeProbe(status, organizations) {
+ return {
+ status,
+ channel: config_1.ASSISTANT_MCP_CHANNEL,
+ organizations: Array.from(new Set(Array.isArray(organizations) ? organizations : [])).slice(0, 20),
+ error: null
+ };
+}
+export function extractOrganizationFactsFromRowsForTests(rows) {
+ return extractOrganizationFactsFromRows(rows);
+}
+export function resolveOrganizationNamesByRefsForTests(refs, facts) {
+ return resolveOrganizationNamesByRefs(refs, facts);
+}
+async function resolveAssistantDataScopeProbe() {
+ const cacheKey = `${config_1.ASSISTANT_MCP_PROXY_URL}|${config_1.ASSISTANT_MCP_CHANNEL}`;
+ const now = Date.now();
+ const cached = dataScopeProbeCache.get(cacheKey);
+ if (cached && typeof cached === "object" && Number(cached.expiresAt ?? 0) > now) {
+ return cached.value;
+ }
+ if (String(process.env.NODE_ENV ?? "").toLowerCase() === "test") {
+ return {
+ status: "skipped_test_env",
+ channel: config_1.ASSISTANT_MCP_CHANNEL,
+ organizations: [],
+ error: null
+ };
+ }
+ const catalogQueryCandidates = [
+ "ВЫБРАТЬ ПЕРВЫЕ 20 ПРЕДСТАВЛЕНИЕ(Организации.Ссылка) КАК Организация ИЗ Справочник.Организации КАК Организации",
+ "ВЫБРАТЬ ПЕРВЫЕ 20 Организации.Наименование КАК Организация ИЗ Справочник.Организации КАК Организации",
+ "ВЫБРАТЬ ПЕРВЫЕ 20 Организации.НаименованиеПолное КАК Организация ИЗ Справочник.Организации КАК Организации",
+ "ВЫБРАТЬ ПЕРВЫЕ 100 Организации.Ссылка КАК Организация, ПРЕДСТАВЛЕНИЕ(Организации.Ссылка) КАК ОрганизацияПредставление ИЗ Справочник.Организации КАК Организации"
+ ];
+ const movementProbeCandidates = [
+ "ВЫБРАТЬ ПЕРВЫЕ 60 Движения.Организация КАК Организация, ПРЕДСТАВЛЕНИЕ(Движения.Организация) КАК ОрганизацияПредставление ИЗ РегистрБухгалтерии.Хозрасчетный КАК Движения УПОРЯДОЧИТЬ ПО Движения.Период УБЫВ",
+ "ВЫБРАТЬ ПЕРВЫЕ 60 Движения.Организация КАК Организация ИЗ РегистрБухгалтерии.Хозрасчетный КАК Движения"
+ ];
+ let lastError = null;
+ const catalogFacts = { names: [], refs: [], pairs: [] };
+ for (const queryText of catalogQueryCandidates) {
+ const probe = await (0, addressMcpClient_1.executeAddressMcpQuery)({
+ query: queryText,
+ limit: 100
+ });
+ if (probe.error) {
+ lastError = probe.error;
+ continue;
+ }
+ const facts = extractOrganizationFactsFromRows(probe.rows);
+ catalogFacts.names.push(...facts.names);
+ catalogFacts.refs.push(...facts.refs);
+ catalogFacts.pairs.push(...facts.pairs);
+ if (facts.names.length > 0) {
+ const resolved = buildResolvedDataScopeProbe("resolved", facts.names);
+ dataScopeProbeCache.set(cacheKey, {
+ expiresAt: now + DATA_SCOPE_CACHE_TTL_MS,
+ value: resolved
+ });
+ return resolved;
+ }
+ }
+ const movementFacts = { names: [], refs: [], pairs: [] };
+ for (const queryText of movementProbeCandidates) {
+ const probe = await (0, addressMcpClient_1.executeAddressMcpQuery)({
+ query: queryText,
+ limit: 60
+ });
+ if (probe.error) {
+ lastError = probe.error;
+ continue;
+ }
+ const facts = extractOrganizationFactsFromRows(probe.rows);
+ movementFacts.names.push(...facts.names);
+ movementFacts.refs.push(...facts.refs);
+ movementFacts.pairs.push(...facts.pairs);
+ if (facts.names.length > 0) {
+ const resolved = buildResolvedDataScopeProbe("resolved_from_activity", facts.names);
+ dataScopeProbeCache.set(cacheKey, {
+ expiresAt: now + DATA_SCOPE_CACHE_TTL_MS,
+ value: resolved
+ });
+ return resolved;
+ }
+ }
+ const movementRefs = Array.from(new Set(movementFacts.refs))
+ .map((item) => String(item ?? "").toLowerCase())
+ .filter((item) => item.length > 0);
+ if (movementRefs.length > 0) {
+ const namesFromCatalogPairs = resolveOrganizationNamesByRefs(movementRefs, {
+ names: Array.from(new Set(catalogFacts.names)),
+ refs: Array.from(new Set(catalogFacts.refs)),
+ pairs: catalogFacts.pairs
+ });
+ if (namesFromCatalogPairs.length > 0) {
+ const resolved = buildResolvedDataScopeProbe("resolved_from_ref_lookup", namesFromCatalogPairs);
+ dataScopeProbeCache.set(cacheKey, {
+ expiresAt: now + DATA_SCOPE_CACHE_TTL_MS,
+ value: resolved
+ });
+ return resolved;
+ }
+ }
+ const fallback = {
+ status: lastError ? "unresolved_with_error" : "unresolved",
+ channel: config_1.ASSISTANT_MCP_CHANNEL,
+ organizations: [],
+ error: lastError
+ };
+ dataScopeProbeCache.set(cacheKey, {
+ expiresAt: now + DATA_SCOPE_CACHE_TTL_MS,
+ value: fallback
+ });
+ return fallback;
+}
+function buildAssistantDataScopeContractReply(scopeProbe = null) {
+ const channel = String(scopeProbe?.channel ?? config_1.ASSISTANT_MCP_CHANNEL ?? "default");
+ const organizations = Array.isArray(scopeProbe?.organizations) ? scopeProbe.organizations : [];
+ if (organizations.length === 1) {
+ return [
+ `Сейчас в активном MCP-канале \`${channel}\` доступна организация: ${organizations[0]}.`,
+ "Работаю в read-only режиме. Могу сразу показать по этой организации документы, операции, договоры или остатки."
+ ].join(" ");
+ }
+ if (organizations.length > 1) {
+ const preview = organizations.slice(0, 10).join(", ");
+ return [
+ `Сейчас в активном MCP-канале \`${channel}\` доступны организации (${organizations.length}): ${preview}.`,
+ "Работаю в read-only режиме. Скажи, по какой организации смотреть документы/операции."
+ ].join(" ");
+ }
+ if (scopeProbe?.status === "unresolved_with_error" && scopeProbe?.error) {
+ return [
+ `Не смог прочитать название организации из live MCP-канала \`${channel}\`: ${scopeProbe.error}.`,
+ "Работаю в read-only режиме и вижу только данные активного контура. Проверь подключение MCP/1С, после этого сразу назову контору."
+ ].join(" ");
+ }
+ return [
+ `Работаю в read-only режиме и вижу только те данные, которые отдает текущий MCP-канал \`${channel}\`.`,
+ "Словарь компаний не зашит в код: рабочий контур определяется live-подключением.",
+ "Если подключено несколько баз, для автосписка нужен MCP-метод метаданных (перечень баз/организаций); без него можно анализировать только активный контур запросов."
+ ].join(" ");
+}
+function buildAssistantOperationalBoundaryReply() {
+ return [
+ "Понимаю, что ситуация срочная.",
+ "Я не могу сам настраивать 1С или менять базу/конфигурацию.",
+ "Могу помочь безопасно: разберем симптомы и подготовим точные шаги для вашего 1С/ИТ-админа."
+ ].join(" ");
+}
+function buildAssistantSafetyRefusalReply() {
+ return [
+ "Я не могу помогать с удалением базы или скрытием данных.",
+ "Если вам угрожает опасность, срочно звоните 112 и следуйте указаниям экстренных служб.",
+ "По 1С могу дать только безопасные диагностические рекомендации."
+ ].join(" ");
+}
+function containsCjkChars(text) {
+ const source = String(text ?? "");
+ if (!source) {
+ return false;
+ }
+ return /[\u3400-\u9FFF\uF900-\uFAFF]/u.test(source);
+}
+function containsLetterLikeChars(text) {
+ const source = String(text ?? "");
+ if (!source) {
+ return false;
+ }
+ return /[A-Za-z\u0400-\u04FF]/u.test(source);
+}
+function applyLivingChatScriptGuard(chatText, userMessage) {
+ const source = String(chatText ?? "").trim();
+ if (!source) {
+ return {
+ text: "",
+ applied: false,
+ reason: null
+ };
+ }
+ if (!containsCjkChars(source) || containsCjkChars(userMessage)) {
+ return {
+ text: source,
+ applied: false,
+ reason: null
+ };
+ }
+ const stripped = source
+ .replace(/[\u3400-\u9FFF\uF900-\uFAFF]+/gu, "")
+ .replace(/[,。!?;:]/gu, "")
+ .replace(/\s{2,}/g, " ")
+ .replace(/\s+([,.!?;:])/g, "$1")
+ .trim();
+ if (stripped && containsLetterLikeChars(stripped)) {
+ return {
+ text: stripped,
+ applied: true,
+ reason: "unexpected_cjk_fragment_stripped"
+ };
+ }
+ return {
+ text: "Понял. Сформулируйте, что именно нужно по данным 1С, и я помогу по шагам.",
+ applied: true,
+ reason: "unexpected_cjk_fragment_fallback"
+ };
+}
+export function resolveLivingAssistantModeDecision(input) {
+ const userMessage = String(input?.userMessage ?? "");
+ if (input?.addressLaneTriggered) {
+ return {
+ mode: "address_data",
+ reason: "address_lane_triggered"
+ };
+ }
+ if (!config_1.FEATURE_ASSISTANT_LIVING_CHAT_ROUTER_V1) {
+ return {
+ mode: "deep_analysis",
+ reason: "living_chat_router_disabled"
+ };
+ }
+ if (Boolean(input?.useMock)) {
+ return {
+ mode: "deep_analysis",
+ reason: "mock_mode_keeps_deep_pipeline"
+ };
+ }
+ if (hasAssistantDataScopeMetaQuestionSignal(userMessage)) {
+ return {
+ mode: "chat",
+ reason: "assistant_data_scope_query_detected"
+ };
+ }
+ if (shouldHandleAsAssistantCapabilityMetaQuery(userMessage)) {
+ return {
+ mode: "chat",
+ reason: "assistant_capability_query_detected"
+ };
+ }
+ if (hasStrongDataIntentSignal(userMessage)) {
+ return {
+ mode: "deep_analysis",
+ reason: "strong_data_signal_detected"
+ };
+ }
+ if (hasLivingChatSignal(userMessage)) {
+ return {
+ mode: "chat",
+ reason: "living_chat_signal_detected"
+ };
+ }
+ const predecomposeMode = toNonEmptyString(input?.predecomposeMode);
+ const predecomposeConfidence = toNonEmptyString(input?.predecomposeModeConfidence);
+ if (predecomposeMode === "unsupported" && (predecomposeConfidence === "low" || predecomposeConfidence === "medium")) {
+ return {
+ mode: "chat",
+ reason: "predecompose_unsupported_mode"
+ };
+ }
+ return {
+ mode: "deep_analysis",
+ reason: "default_deep_pipeline"
+ };
+}
export class AssistantService {
normalizerService;
sessions;
dataLayer;
sessionLogger;
addressQueryService;
- constructor(normalizerService, sessions, dataLayer = new assistantDataLayer_1.AssistantDataLayer(), sessionLogger = new assistantSessionLogger_1.AssistantSessionLogger(), addressQueryService = new addressQueryService_1.AddressQueryService()) {
+ chatClient;
+ constructor(normalizerService, sessions, dataLayer = new assistantDataLayer_1.AssistantDataLayer(), sessionLogger = new assistantSessionLogger_1.AssistantSessionLogger(), addressQueryService = new addressQueryService_1.AddressQueryService(), chatClient = new openaiResponsesClient_1.OpenAIResponsesClient()) {
this.normalizerService = normalizerService;
this.sessions = sessions;
this.dataLayer = dataLayer;
this.sessionLogger = sessionLogger;
this.addressQueryService = addressQueryService;
+ this.chatClient = chatClient;
}
getSession(sessionId) {
return this.sessions.getSession(sessionId);
@@ -3188,8 +4347,12 @@ export class AssistantService {
};
this.sessions.appendItem(sessionId, userItem);
const finalizeAddressLaneResponse = (addressLane, effectiveAddressUserMessage, carryoverMeta = null, llmPreDecomposeMeta = null) => {
- const safeAddressReply = String((0, answerComposer_1.sanitizeAssistantReplyForUserFacing)(addressLane.reply_text) ?? "").trim() || String(addressLane.reply_text ?? "");
+ const safeAddressReply = sanitizeOutgoingAssistantText(addressLane.reply_text);
const debug = buildAddressDebugPayload(addressLane.debug, llmPreDecomposeMeta);
+ const followupOffer = buildAddressFollowupOffer(debug);
+ if (followupOffer) {
+ debug.address_followup_offer = followupOffer;
+ }
const assistantItem = {
message_id: `msg-${(0, nanoid_1.nanoid)(10)}`,
session_id: sessionId,
@@ -3230,6 +4393,12 @@ export class AssistantService {
address_sanitized_user_message: llmPreDecomposeMeta?.sanitizedUserMessage ?? null,
address_tool_gate_decision: llmPreDecomposeMeta?.toolGateDecision ?? null,
address_tool_gate_reason: llmPreDecomposeMeta?.toolGateReason ?? null,
+ address_dialog_continuation_decision: llmPreDecomposeMeta?.dialogContinuationContract?.decision ?? null,
+ address_dialog_continuation_target_intent: llmPreDecomposeMeta?.dialogContinuationContract?.target_intent ?? null,
+ address_retry_attempted: Boolean(llmPreDecomposeMeta?.addressRetryAudit?.attempted),
+ address_retry_reason: llmPreDecomposeMeta?.addressRetryAudit?.reason ?? null,
+ address_retry_initial_limited_category: llmPreDecomposeMeta?.addressRetryAudit?.initial_limited_category ?? null,
+ address_retry_result_category: llmPreDecomposeMeta?.addressRetryAudit?.retry_result_category ?? null,
address_llm_predecompose_contract_intent: llmPreDecomposeMeta?.predecomposeContract?.intent ?? null,
address_llm_predecompose_contract_aggregation_profile: llmPreDecomposeMeta?.predecomposeContract?.aggregation_profile ?? null,
address_llm_predecompose_contract_period_scope: llmPreDecomposeMeta?.predecomposeContract?.period?.scope ?? null,
@@ -3277,6 +4446,161 @@ export class AssistantService {
conversation
};
};
+ const tryHandleLivingChat = async (modeDecision, addressRuntimeMeta = null) => {
+ try {
+ const dataScopeMetaQuery = hasAssistantDataScopeMetaQuestionSignal(userMessage);
+ const capabilityMetaQuery = shouldHandleAsAssistantCapabilityMetaQuery(userMessage);
+ const destructiveSignal = hasDestructiveDataActionSignal(userMessage);
+ const dangerSignal = hasDangerOrCoercionSignal(userMessage);
+ const operationalSignal = hasOperationalAdminActionRequestSignal(userMessage);
+ let dataScopeProbe = null;
+ let chatText = "";
+ let livingChatSource = "llm_chat";
+ let livingChatScriptGuardApplied = false;
+ let livingChatScriptGuardReason = null;
+ if (capabilityMetaQuery && (destructiveSignal || dangerSignal)) {
+ chatText = buildAssistantSafetyRefusalReply();
+ livingChatSource = "deterministic_safety_refusal";
+ }
+ else if (dataScopeMetaQuery) {
+ dataScopeProbe = await resolveAssistantDataScopeProbe();
+ chatText = buildAssistantDataScopeContractReply(dataScopeProbe);
+ livingChatSource = dataScopeProbe?.status === "resolved"
+ ? "deterministic_data_scope_contract_live"
+ : "deterministic_data_scope_contract";
+ }
+ else if (capabilityMetaQuery && operationalSignal && !hasAssistantCapabilityQuestionSignal(userMessage)) {
+ chatText = buildAssistantOperationalBoundaryReply();
+ livingChatSource = "deterministic_operational_boundary";
+ }
+ else if (capabilityMetaQuery) {
+ chatText = buildAssistantCapabilityContractReply();
+ livingChatSource = "deterministic_capability_contract";
+ }
+ else {
+ const conversationWindow = buildLivingChatContextWindow(session.items);
+ const userPrompt = buildLivingChatPrompt(userMessage, conversationWindow);
+ const chatResponse = await this.chatClient.chat({
+ llmProvider: payload.llmProvider,
+ apiKey: String(payload.apiKey ?? process.env.OPENAI_API_KEY ?? ""),
+ model: String(payload.model ?? config_1.DEFAULT_MODEL),
+ baseUrl: payload.baseUrl ?? config_1.DEFAULT_OPENAI_BASE_URL,
+ temperature: payload.temperature ?? 0.35,
+ maxOutputTokens: Math.max(120, Math.min(Number(payload.maxOutputTokens ?? 420), 900))
+ }, {
+ systemPrompt: [
+ "Ты живой русскоязычный ассистент для чтения и анализа данных 1С.",
+ "Работай честно: не заявляй действия, которые недоступны в этом рантайме.",
+ "Разрешено: анализ и объяснение данных, формулировка запросов, подсказки по следующему шагу.",
+ "Запрещено: обещать настройку 1С, админ-действия, создание/проведение документов или любые изменения в базе.",
+ "Если пользователь спрашивает про возможности, отвечай только по этому контракту."
+ ].join(" "),
+ developerPrompt: "Формат: коротко и по сути, без JSON и без служебных блоков. Пиши человеко-понятно.",
+ userMessage: userPrompt,
+ maxOutputTokens: Math.max(120, Math.min(Number(payload.maxOutputTokens ?? 420), 900)),
+ temperature: payload.temperature ?? 0.35
+ });
+ chatText = sanitizeOutgoingAssistantText(chatResponse?.outputText ?? "", "Понял. Сформулируйте, что именно нужно по данным 1С, и я помогу по шагам.");
+ const scriptGuard = applyLivingChatScriptGuard(chatText, userMessage);
+ chatText = scriptGuard.text;
+ if (scriptGuard.applied) {
+ livingChatScriptGuardApplied = true;
+ livingChatScriptGuardReason = scriptGuard.reason;
+ livingChatSource = "llm_chat_script_guard";
+ }
+ }
+ if (!chatText) {
+ return null;
+ }
+ const debug = {
+ trace_id: `chat-${(0, nanoid_1.nanoid)(10)}`,
+ prompt_version: "living_chat_router_v1",
+ schema_version: "living_chat_router_v1",
+ fallback_type: "none",
+ detected_mode: "chat",
+ detected_mode_confidence: "high",
+ execution_lane: "living_chat",
+ living_router_mode: modeDecision?.mode ?? "chat",
+ living_router_reason: modeDecision?.reason ?? "living_chat_signal_detected",
+ living_chat_response_source: livingChatSource,
+ living_chat_script_guard_applied: livingChatScriptGuardApplied,
+ living_chat_script_guard_reason: livingChatScriptGuardReason,
+ living_chat_data_scope_probe_status: dataScopeProbe?.status ?? null,
+ living_chat_data_scope_probe_channel: dataScopeProbe?.channel ?? null,
+ living_chat_data_scope_probe_org_count: Array.isArray(dataScopeProbe?.organizations)
+ ? dataScopeProbe.organizations.length
+ : 0,
+ living_chat_data_scope_probe_error: dataScopeProbe?.error ?? null,
+ address_llm_predecompose_attempted: Boolean(addressRuntimeMeta?.attempted),
+ address_llm_predecompose_applied: Boolean(addressRuntimeMeta?.applied),
+ address_llm_predecompose_reason: addressRuntimeMeta?.reason ?? null,
+ address_llm_predecompose_contract: addressRuntimeMeta?.predecomposeContract ?? null,
+ orchestration_contract_v1: addressRuntimeMeta?.orchestrationContract ?? null,
+ tool_gate_decision: addressRuntimeMeta?.toolGateDecision ?? null,
+ tool_gate_reason: addressRuntimeMeta?.toolGateReason ?? null,
+ normalized: null,
+ normalizer_output: null
+ };
+ const assistantItem = {
+ message_id: `msg-${(0, nanoid_1.nanoid)(10)}`,
+ session_id: sessionId,
+ role: "assistant",
+ text: chatText,
+ reply_type: "factual_with_explanation",
+ 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_chat",
+ details: {
+ session_id: sessionId,
+ message_id: assistantItem.message_id,
+ user_message: userMessage,
+ living_router_mode: modeDecision?.mode ?? "chat",
+ living_router_reason: modeDecision?.reason ?? "living_chat_signal_detected",
+ assistant_reply: assistantItem.text,
+ reply_type: assistantItem.reply_type,
+ 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
+ };
+ }
+ catch (error) {
+ (0, log_1.logJson)({
+ timestamp: new Date().toISOString(),
+ level: "warn",
+ service: "assistant_loop",
+ message: "assistant_living_chat_failed_fallback_to_deep",
+ sessionId,
+ details: {
+ session_id: sessionId,
+ user_message: userMessage,
+ reason: error instanceof Error ? error.message : String(error)
+ }
+ });
+ return null;
+ }
+ };
let addressRuntimeMetaForDeep = null;
if (config_1.FEATURE_ASSISTANT_ADDRESS_QUERY_V1) {
const addressPreDecompose = config_1.FEATURE_ASSISTANT_ADDRESS_QUERY_LLM_PREDECOMPOSE_V1
@@ -3299,15 +4623,28 @@ export class AssistantService {
toolGateReason: null
};
const addressInputMessage = toNonEmptyString(addressPreDecompose?.effectiveMessage) ?? userMessage;
- const carryover = resolveAddressFollowupCarryoverContext(userMessage, session.items, addressInputMessage);
- const toolGate = resolveAddressToolGateDecision(addressInputMessage, carryover?.followupContext ?? null, addressPreDecompose);
+ const carryover = resolveAddressFollowupCarryoverContext(userMessage, session.items, addressInputMessage, addressPreDecompose);
+ const orchestrationDecision = resolveAssistantOrchestrationDecision({
+ rawUserMessage: userMessage,
+ effectiveAddressUserMessage: addressInputMessage,
+ followupContext: carryover?.followupContext ?? null,
+ llmPreDecomposeMeta: addressPreDecompose,
+ useMock: Boolean(payload.useMock)
+ });
+ const dialogContinuationContract = buildAddressDialogContinuationContractV2(userMessage, addressInputMessage, carryover, addressPreDecompose);
const addressRuntimeMeta = {
...addressPreDecompose,
- toolGateDecision: toolGate.decision,
- toolGateReason: toolGate.reason
+ toolGateDecision: orchestrationDecision.toolGateDecision,
+ toolGateReason: orchestrationDecision.toolGateReason,
+ dialogContinuationContract,
+ orchestrationContract: orchestrationDecision.orchestrationContract
};
addressRuntimeMetaForDeep = addressRuntimeMeta;
- if (!toolGate.runAddressLane) {
+ const livingModeDecision = {
+ mode: orchestrationDecision.livingMode,
+ reason: orchestrationDecision.livingReason
+ };
+ if (!orchestrationDecision.runAddressLane) {
(0, log_1.logJson)({
timestamp: new Date().toISOString(),
level: "info",
@@ -3323,6 +4660,7 @@ export class AssistantService {
address_llm_predecompose_reason: addressRuntimeMeta?.reason ?? null,
address_fallback_rule_hit: addressRuntimeMeta?.fallbackRuleHit ?? null,
address_sanitized_user_message: addressRuntimeMeta?.sanitizedUserMessage ?? null,
+ assistant_orchestration_contract_v1: addressRuntimeMeta?.orchestrationContract ?? null,
address_tool_gate_decision: addressRuntimeMeta?.toolGateDecision ?? null,
address_tool_gate_reason: addressRuntimeMeta?.toolGateReason ?? null,
address_llm_predecompose_contract_intent: addressRuntimeMeta?.predecomposeContract?.intent ?? null,
@@ -3330,29 +4668,104 @@ export class AssistantService {
address_llm_predecompose_contract_period_scope: addressRuntimeMeta?.predecomposeContract?.period?.scope ?? null
}
});
- }
- if (toolGate.runAddressLane) {
- const shouldPreferContextualLane = Boolean(carryover?.followupContext);
- if (shouldPreferContextualLane) {
- const contextualAddressLane = await this.addressQueryService.tryHandle(addressInputMessage, {
- followupContext: carryover.followupContext
- });
- if (contextualAddressLane?.handled) {
- return finalizeAddressLaneResponse(contextualAddressLane, addressInputMessage, carryover, addressRuntimeMeta);
+ if (livingModeDecision.mode === "chat") {
+ const chatHandled = await tryHandleLivingChat(livingModeDecision, addressRuntimeMeta);
+ if (chatHandled) {
+ return chatHandled;
+ }
}
}
- const primaryAddressLane = await this.addressQueryService.tryHandle(addressInputMessage);
- if (primaryAddressLane?.handled) {
- return finalizeAddressLaneResponse(primaryAddressLane, addressInputMessage, null, addressRuntimeMeta);
- }
- if (!shouldPreferContextualLane && carryover?.followupContext) {
- const contextualAddressLane = await this.addressQueryService.tryHandle(addressInputMessage, {
- followupContext: carryover.followupContext
+ if (orchestrationDecision.runAddressLane) {
+ const shouldPreferContextualLane = Boolean(carryover?.followupContext);
+ const canRetryWithRawUserMessage = compactWhitespace(String(addressInputMessage ?? "").toLowerCase()) !==
+ compactWhitespace(String(userMessage ?? "").toLowerCase());
+ const retryAudit = {
+ attempted: false,
+ reason: null,
+ initial_limited_category: null,
+ retry_message: null,
+ retry_used_followup_context: false,
+ retry_result_category: null
+ };
+ const withRetryMeta = () => ({
+ ...addressRuntimeMeta,
+ addressRetryAudit: { ...retryAudit }
});
- if (contextualAddressLane?.handled) {
- return finalizeAddressLaneResponse(contextualAddressLane, addressInputMessage, carryover, addressRuntimeMeta);
+ let pendingLimited = null;
+ const evaluateAddressLane = (addressLane, messageUsed, carryMeta) => {
+ if (!addressLane?.handled) {
+ return null;
+ }
+ if (!isRetryableAddressLimitedResult(addressLane)) {
+ return {
+ action: "return",
+ addressLane,
+ messageUsed,
+ carryMeta
+ };
+ }
+ if (!pendingLimited) {
+ pendingLimited = {
+ addressLane,
+ messageUsed,
+ carryMeta
+ };
+ }
+ return {
+ action: "continue"
+ };
+ };
+ const runAddressLaneAttempt = async (messageUsed, carryMeta) => {
+ if (carryMeta?.followupContext) {
+ return this.addressQueryService.tryHandle(messageUsed, {
+ followupContext: carryMeta.followupContext
+ });
+ }
+ return this.addressQueryService.tryHandle(messageUsed);
+ };
+ if (shouldPreferContextualLane) {
+ const contextualAddressLane = await runAddressLaneAttempt(addressInputMessage, carryover);
+ const decision = evaluateAddressLane(contextualAddressLane, addressInputMessage, carryover);
+ if (decision?.action === "return") {
+ return finalizeAddressLaneResponse(decision.addressLane, decision.messageUsed, decision.carryMeta, withRetryMeta());
+ }
+ }
+ const primaryAddressLane = await runAddressLaneAttempt(addressInputMessage, null);
+ const primaryDecision = evaluateAddressLane(primaryAddressLane, addressInputMessage, null);
+ if (primaryDecision?.action === "return") {
+ return finalizeAddressLaneResponse(primaryDecision.addressLane, primaryDecision.messageUsed, primaryDecision.carryMeta, withRetryMeta());
+ }
+ if (!shouldPreferContextualLane && carryover?.followupContext) {
+ const contextualAddressLane = await runAddressLaneAttempt(addressInputMessage, carryover);
+ const contextualDecision = evaluateAddressLane(contextualAddressLane, addressInputMessage, carryover);
+ if (contextualDecision?.action === "return") {
+ return finalizeAddressLaneResponse(contextualDecision.addressLane, contextualDecision.messageUsed, contextualDecision.carryMeta, withRetryMeta());
+ }
+ }
+ if (pendingLimited && canRetryWithRawUserMessage) {
+ retryAudit.attempted = true;
+ retryAudit.reason = "limited_result_retry_with_raw_message";
+ retryAudit.initial_limited_category = pendingLimited.addressLane?.debug?.limited_reason_category ?? null;
+ retryAudit.retry_message = userMessage;
+ if (carryover?.followupContext) {
+ retryAudit.retry_used_followup_context = true;
+ const rawContextualLane = await runAddressLaneAttempt(userMessage, carryover);
+ const rawContextualDecision = evaluateAddressLane(rawContextualLane, userMessage, carryover);
+ if (rawContextualDecision?.action === "return") {
+ retryAudit.retry_result_category = rawContextualDecision.addressLane?.debug?.limited_reason_category ?? null;
+ return finalizeAddressLaneResponse(rawContextualDecision.addressLane, rawContextualDecision.messageUsed, rawContextualDecision.carryMeta, withRetryMeta());
+ }
+ }
+ const rawPrimaryLane = await runAddressLaneAttempt(userMessage, null);
+ retryAudit.retry_result_category = rawPrimaryLane?.debug?.limited_reason_category ?? null;
+ const rawPrimaryDecision = evaluateAddressLane(rawPrimaryLane, userMessage, null);
+ if (rawPrimaryDecision?.action === "return") {
+ return finalizeAddressLaneResponse(rawPrimaryDecision.addressLane, rawPrimaryDecision.messageUsed, rawPrimaryDecision.carryMeta, withRetryMeta());
+ }
+ }
+ if (pendingLimited) {
+ return finalizeAddressLaneResponse(pendingLimited.addressLane, pendingLimited.messageUsed, pendingLimited.carryMeta, withRetryMeta());
}
- }
}
}
const followupBinding = config_1.FEATURE_ASSISTANT_INVESTIGATION_STATE_V1 &&
@@ -3564,7 +4977,7 @@ export class AssistantService {
enableProblemCentricAnswerV1: config_1.FEATURE_ASSISTANT_PROBLEM_CENTRIC_ANSWER_V1,
enableLifecycleAnswerV1: config_1.FEATURE_ASSISTANT_LIFECYCLE_ANSWER_V1
});
- const safeAssistantReplyBase = (0, answerComposer_1.sanitizeAssistantReplyForUserFacing)(composition.assistant_reply);
+ const safeAssistantReplyBase = sanitizeOutgoingAssistantText(composition.assistant_reply, "Нужны уточнения для надежного ответа.");
const safeAssistantReply = String(safeAssistantReplyBase ?? "")
.replace(/(?:^|\n)\s*#{0,6}\s*(?:debug_payload_json|technical_breakdown_json)\b[\s\S]*$/gi, "")
.replace(/\b(?:debug_payload_json|technical_breakdown_json)\b[\s\S]*$/gi, "")
@@ -3667,6 +5080,7 @@ export class AssistantService {
address_tool_gate_decision: addressRuntimeMetaForDeep?.toolGateDecision ?? null,
address_tool_gate_reason: addressRuntimeMetaForDeep?.toolGateReason ?? null,
address_llm_predecompose_contract: addressRuntimeMetaForDeep?.predecomposeContract ?? null,
+ orchestration_contract_v1: addressRuntimeMetaForDeep?.orchestrationContract ?? null,
answer_structure_v11: answerStructureV11,
investigation_state_snapshot: investigationStateSnapshot,
normalized: normalized.normalized
diff --git a/llm_normalizer/backend/src/services/openaiResponsesClient.ts b/llm_normalizer/backend/src/services/openaiResponsesClient.ts
index 02373cd..56e1b3b 100644
--- a/llm_normalizer/backend/src/services/openaiResponsesClient.ts
+++ b/llm_normalizer/backend/src/services/openaiResponsesClient.ts
@@ -203,6 +203,103 @@ function buildBaseUrlCandidates(config: OpenAIRequestConfig): string[] {
}
export class OpenAIResponsesClient {
+ public async chat(
+ config: OpenAIRequestConfig,
+ prompt: {
+ systemPrompt?: string;
+ developerPrompt?: string;
+ userMessage: string;
+ maxOutputTokens?: number;
+ temperature?: number;
+ }
+ ): Promise {
+ const responsesPayload = {
+ model: config.model,
+ temperature: prompt.temperature ?? config.temperature ?? 0.2,
+ max_output_tokens: prompt.maxOutputTokens ?? config.maxOutputTokens ?? 400,
+ input: [
+ ...(String(prompt.systemPrompt ?? "").trim().length > 0
+ ? [
+ {
+ role: "system",
+ content: [{ type: "input_text", text: String(prompt.systemPrompt ?? "").trim() }]
+ }
+ ]
+ : []),
+ ...(String(prompt.developerPrompt ?? "").trim().length > 0
+ ? [
+ {
+ role: "developer",
+ content: [{ type: "input_text", text: String(prompt.developerPrompt ?? "").trim() }]
+ }
+ ]
+ : []),
+ {
+ role: "user",
+ content: [{ type: "input_text", text: String(prompt.userMessage ?? "") }]
+ }
+ ]
+ };
+
+ const provider = resolveProvider(config);
+ if (provider === "openai") {
+ const raw = await this.postResponses(config, responsesPayload);
+ return {
+ raw,
+ outputText: extractOutputTextFromResponses(raw),
+ usage: extractUsage(raw)
+ };
+ }
+
+ 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: prompt.temperature ?? config.temperature ?? 0.2,
+ max_tokens: prompt.maxOutputTokens ?? config.maxOutputTokens ?? 400,
+ messages: [
+ ...(String(prompt.systemPrompt ?? "").trim().length > 0
+ ? [
+ {
+ role: "system",
+ content: String(prompt.systemPrompt ?? "").trim()
+ }
+ ]
+ : []),
+ ...(String(prompt.developerPrompt ?? "").trim().length > 0
+ ? [
+ {
+ role: "developer",
+ content: String(prompt.developerPrompt ?? "").trim()
+ }
+ ]
+ : []),
+ {
+ role: "user",
+ content: String(prompt.userMessage ?? "")
+ }
+ ]
+ };
+
+ const raw = await this.postChatCompletions(config, chatPayload);
+ return {
+ raw,
+ outputText: extractOutputTextFromChatCompletions(raw),
+ usage: extractUsage(raw)
+ };
+ }
+
public async listModels(config: OpenAIRequestConfig): Promise {
const payload = await this.getModels(config);
const data = Array.isArray(payload.data) ? payload.data : [];
diff --git a/llm_normalizer/backend/src/types/addressQuery.ts b/llm_normalizer/backend/src/types/addressQuery.ts
index 171bc8b..888cb52 100644
--- a/llm_normalizer/backend/src/types/addressQuery.ts
+++ b/llm_normalizer/backend/src/types/addressQuery.ts
@@ -9,6 +9,7 @@ export type AddressIntent =
| "customer_revenue_and_payments"
| "supplier_payouts_profile"
| "contract_usage_and_value"
+ | "vat_payable_forecast"
| "list_contracts_by_counterparty"
| "list_open_contracts"
| "list_payables_counterparties"
@@ -119,7 +120,8 @@ export interface AddressRecipeDefinition {
| "customer_revenue_profile"
| "supplier_payout_profile"
| "contract_value_profile"
- | "contracts_by_counterparty_profile";
+ | "contracts_by_counterparty_profile"
+ | "vat_payable_forecast_profile";
required_filters: Array;
optional_filters: Array;
default_limit: number;
diff --git a/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts b/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts
index f5f7a9c..8781dad 100644
--- a/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts
+++ b/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts
@@ -125,6 +125,11 @@ describe("address query shape classifier", () => {
expect(result.mode).toBe("address_query");
});
+ it("keeps top contract wording with 'контракт' in address lane", () => {
+ const result = detectAddressQuestionMode("по альтернативе вопрос. какой самый жирный контракт за всю историю у нее?");
+ expect(result.mode).toBe("address_query");
+ });
+
});
describe("address compose stage utf8 headers", () => {
@@ -1123,6 +1128,202 @@ describe("address compose stage utf8 headers", () => {
expect(reply.text).toContain("активных договоров с минимальным бюджетом");
expect(reply.text).toContain("1. Договор 02/20 | оборот: 100");
});
+
+ it("adds deterministic why-zero explanation for VAT forecast follow-up wording", () => {
+ const reply = composeFactualReply(
+ "vat_payable_forecast",
+ [
+ {
+ period: "2020-03-01T00:00:00Z",
+ registrator: "VAT_68_CREDIT",
+ account_dt: "68",
+ account_kt: "",
+ amount: 9126,
+ analytics: []
+ },
+ {
+ period: "2020-03-01T00:00:00Z",
+ registrator: "VAT_68_DEBIT",
+ account_dt: "68",
+ account_kt: "",
+ amount: 115342,
+ analytics: []
+ },
+ {
+ period: "2020-03-01T00:00:00Z",
+ registrator: "VAT_19_DEBIT",
+ account_dt: "19",
+ account_kt: "",
+ amount: 1602384,
+ analytics: []
+ },
+ {
+ period: "2020-03-01T00:00:00Z",
+ registrator: "VAT_19_CREDIT",
+ account_dt: "19",
+ account_kt: "",
+ amount: 0,
+ analytics: []
+ }
+ ],
+ { userMessage: "почему прогноз к уплате 0?" }
+ );
+
+ expect(reply.responseType).toBe("FACTUAL_SUMMARY");
+ expect(reply.text).toContain("Почему прогноз к уплате 0");
+ expect(reply.text).toContain("max(0, 68 Кт - 68 Дт)");
+ expect(reply.text).toContain("За период 68 Кт = 9126.00, 68 Дт = 115342.00, разница = -106216.00.");
+ expect(reply.text).toContain("Разница неположительная");
+ expect(reply.text).toContain("оперативный прогноз по оборотам НДС-субсчетов 68.02*/19*");
+ });
+
+ it("adds VAT declaration and payment deadlines for as-of-date forecast window", () => {
+ const reply = composeFactualReply(
+ "vat_payable_forecast",
+ [
+ {
+ period: "2020-03-01T00:00:00Z",
+ registrator: "VAT_68_CREDIT",
+ account_dt: "68",
+ account_kt: "",
+ amount: 300,
+ analytics: []
+ },
+ {
+ period: "2020-03-01T00:00:00Z",
+ registrator: "VAT_68_DEBIT",
+ account_dt: "68",
+ account_kt: "",
+ amount: 0,
+ analytics: []
+ }
+ ],
+ {
+ userMessage: "сколько НДС нужно заплатить по состоянию на 15 марта 2020 года",
+ periodFrom: "2020-01-01",
+ periodTo: "2020-03-15"
+ }
+ );
+
+ expect(reply.responseType).toBe("FACTUAL_SUMMARY");
+ expect(reply.text).toContain("Период расчета (срез обязательств): 01.01.2020..15.03.2020.");
+ expect(reply.text).toContain("Налоговый период: 1 кв. 2020.");
+ expect(reply.text).toContain("Срок сдачи декларации: до 25.04.2020.");
+ expect(reply.text).toContain("Сроки уплаты: 28.04.2020, 28.05.2020, 28.06.2020.");
+ expect(reply.text).toContain("Ориентир по долям к уплате: 100.00 / 100.00 / 100.00.");
+ });
+
+ it("builds VAT deadlines correctly for Q4 with next-year rollover", () => {
+ const reply = composeFactualReply(
+ "vat_payable_forecast",
+ [
+ {
+ period: "2020-12-31T00:00:00Z",
+ registrator: "VAT_68_CREDIT",
+ account_dt: "68",
+ account_kt: "",
+ amount: 90,
+ analytics: []
+ }
+ ],
+ {
+ userMessage: "прогноз НДС на 31 декабря 2020",
+ periodFrom: "2020-10-01",
+ periodTo: "2020-12-31"
+ }
+ );
+
+ expect(reply.responseType).toBe("FACTUAL_SUMMARY");
+ expect(reply.text).toContain("Налоговый период: 4 кв. 2020.");
+ expect(reply.text).toContain("Срок сдачи декларации: до 25.01.2021.");
+ expect(reply.text).toContain("Сроки уплаты: 28.01.2021, 28.02.2021, 28.03.2021.");
+ expect(reply.text).toContain("Ориентир по долям к уплате: 30.00 / 30.00 / 30.00.");
+ });
+
+ it("explains zero VAT as no-movements case when VAT turnovers are absent in window", () => {
+ const reply = composeFactualReply(
+ "vat_payable_forecast",
+ [
+ {
+ period: "2019-04-01T00:00:00Z",
+ registrator: "VAT_68_CREDIT",
+ account_dt: "68",
+ account_kt: "",
+ amount: 0,
+ analytics: []
+ },
+ {
+ period: "2019-04-01T00:00:00Z",
+ registrator: "VAT_68_DEBIT",
+ account_dt: "68",
+ account_kt: "",
+ amount: 0,
+ analytics: []
+ },
+ {
+ period: "2019-04-01T00:00:00Z",
+ registrator: "VAT_19_DEBIT",
+ account_dt: "19",
+ account_kt: "",
+ amount: 0,
+ analytics: []
+ },
+ {
+ period: "2019-04-01T00:00:00Z",
+ registrator: "VAT_19_CREDIT",
+ account_dt: "19",
+ account_kt: "",
+ amount: 0,
+ analytics: []
+ }
+ ],
+ {
+ userMessage: "какой прогноз оплаты ндс на 12-05-2019",
+ periodFrom: "2019-04-01",
+ periodTo: "2019-05-12"
+ }
+ );
+
+ expect(reply.responseType).toBe("FACTUAL_SUMMARY");
+ expect(reply.text).toContain("Прогноз НДС к уплате: 0.00.");
+ expect(reply.text).toContain("не найдено движений по НДС-субсчетам 68.02*/19*");
+ expect(reply.text).toContain("Чеклист проверки в 1С (почему к уплате 0):");
+ expect(reply.text).toContain("Проверьте наличие движений в РегистрБухгалтерии.Хозрасчетный");
+ });
+
+ it("explains zero VAT as offset case when VAT turnovers exist but net is near zero", () => {
+ const reply = composeFactualReply(
+ "vat_payable_forecast",
+ [
+ {
+ period: "2020-03-01T00:00:00Z",
+ registrator: "VAT_68_CREDIT",
+ account_dt: "68",
+ account_kt: "",
+ amount: 1000,
+ analytics: []
+ },
+ {
+ period: "2020-03-01T00:00:00Z",
+ registrator: "VAT_68_DEBIT",
+ account_dt: "68",
+ account_kt: "",
+ amount: 1000,
+ analytics: []
+ }
+ ],
+ {
+ userMessage: "какой прогноз оплаты ндс на 12-05-2020",
+ periodFrom: "2020-04-01",
+ periodTo: "2020-05-12"
+ }
+ );
+
+ expect(reply.responseType).toBe("FACTUAL_SUMMARY");
+ expect(reply.text).toContain("Прогноз НДС к уплате: 0.00.");
+ expect(reply.text).toContain("обороты по 68* взаимно перекрылись");
+ expect(reply.text).toContain("Чеклист проверки в 1С (почему к уплате 0):");
+ });
});
describe("address intent resolver expansion (M2.3a)", () => {
@@ -1468,6 +1669,11 @@ describe("address intent resolver expansion (M2.3a)", () => {
expect(result.intent).toBe("customer_revenue_and_payments");
});
+ it("resolves top counterparty slang wording into customer revenue intent", () => {
+ const result = resolveAddressIntent("какой самый жирный контрагент у нее? кто больше платит денег");
+ expect(result.intent).toBe("customer_revenue_and_payments");
+ });
+
it("resolves supplier payouts profile intent from slang wording", () => {
const result = resolveAddressIntent("кому мы больше всего сгрузили денег, топ-20 поставщиков");
expect(result.intent).toBe("supplier_payouts_profile");
@@ -1478,6 +1684,33 @@ describe("address intent resolver expansion (M2.3a)", () => {
expect(result.intent).toBe("contract_usage_and_value");
});
+ it("resolves top contract wording with 'контракт' into contract usage and value intent", () => {
+ const result = resolveAddressIntent("по альтернативе вопрос. какой самый жирный контракт за всю историю у нее?");
+ expect(result.intent).toBe("contract_usage_and_value");
+ });
+
+ it("resolves revenue-total slang wording into customer revenue intent", () => {
+ const result = resolveAddressIntent("скока денег альтернатива заработала за 22 год");
+ expect(result.intent).toBe("customer_revenue_and_payments");
+ });
+
+ it("resolves overall-turnover wording into customer revenue intent", () => {
+ const result = resolveAddressIntent("какие общие обороты за все время");
+ expect(result.intent).toBe("customer_revenue_and_payments");
+ });
+
+ it("resolves VAT payment forecast wording into dedicated VAT forecast intent", () => {
+ const result = resolveAddressIntent("какой прогноз оплаты ндс за 12 мая 2020");
+ expect(result.intent).toBe("vat_payable_forecast");
+ expect(result.reasons).toContain("forecast_tax_signal_detected");
+ });
+
+ it("resolves colloquial VAT payable estimate wording without explicit 'прогноз'", () => {
+ const result = resolveAddressIntent("мож прикинусь плиз скока ндс надо заплатить на 15 марта 2020 года");
+ expect(result.intent).toBe("vat_payable_forecast");
+ expect(result.reasons).toContain("forecast_tax_signal_detected");
+ });
+
it("resolves multi-contract counterparties wording into contract usage and value intent", () => {
const result = resolveAddressIntent("Покажи контрагентов с несколькими договорами и какие из договоров активны.");
expect(result.intent).toBe("contract_usage_and_value");
@@ -1525,6 +1758,7 @@ describe("address filter extraction for balance drilldown", () => {
"договоры по обороту ранкни и дай топ-20",
"contract_usage_and_value"
);
+ const vatForecast = extractAddressFilters("какой прогноз оплаты ндс за 12 мая 2020", "vat_payable_forecast");
expect(periodProfile.extracted_filters.limit).toBeUndefined();
expect(docSectionProfile.extracted_filters.limit).toBeUndefined();
expect(counterpartyProfile.extracted_filters.limit).toBeUndefined();
@@ -1533,6 +1767,7 @@ describe("address filter extraction for balance drilldown", () => {
expect(customerValue.extracted_filters.limit).toBe(20);
expect(supplierValue.extracted_filters.limit).toBe(20);
expect(contractValue.extracted_filters.limit).toBe(20);
+ expect(vatForecast.extracted_filters.limit).toBeUndefined();
expect(periodProfile.extracted_filters.period_to).toBeDefined();
expect(docSectionProfile.extracted_filters.period_to).toBeDefined();
expect(counterpartyProfile.extracted_filters.period_to).toBeDefined();
@@ -1541,6 +1776,8 @@ describe("address filter extraction for balance drilldown", () => {
expect(customerValue.extracted_filters.period_to).toBeDefined();
expect(supplierValue.extracted_filters.period_to).toBeDefined();
expect(contractValue.extracted_filters.period_to).toBeDefined();
+ expect(vatForecast.extracted_filters.period_from).toBe("2020-04-01");
+ expect(vatForecast.extracted_filters.period_to).toBe("2020-05-12");
expect(periodProfile.warnings).toContain("period_to_defaulted_today_for_management_profile");
expect(docSectionProfile.warnings).toContain("period_to_defaulted_today_for_management_profile");
expect(counterpartyProfile.warnings).toContain("period_to_defaulted_today_for_management_profile");
@@ -1549,6 +1786,10 @@ describe("address filter extraction for balance drilldown", () => {
expect(customerValue.warnings).toContain("period_to_defaulted_today_for_management_profile");
expect(supplierValue.warnings).toContain("period_to_defaulted_today_for_management_profile");
expect(contractValue.warnings).toContain("period_to_defaulted_today_for_management_profile");
+ expect(vatForecast.warnings).toContain("period_derived_from_month_phrase");
+ expect(vatForecast.warnings).toContain("period_from_derived_from_quarter_for_vat_forecast");
+ expect(vatForecast.warnings).toContain("period_to_derived_from_as_of_date_for_vat_forecast");
+ expect(vatForecast.warnings).not.toContain("period_to_defaulted_today_for_management_profile");
});
it("extracts short-year period for lifecycle customer list question", () => {
@@ -1560,6 +1801,45 @@ describe("address filter extraction for balance drilldown", () => {
expect(lifecycleShortYear.extracted_filters.period_to).toBe("2020-12-31");
});
+ it("drops noisy counterparty anchor in ranking question for customer revenue profile", () => {
+ const extracted = extractAddressFilters(
+ "какой самый жирный контрагент у нее? кто больше платит денег",
+ "customer_revenue_and_payments"
+ );
+ expect(extracted.extracted_filters.counterparty).toBeUndefined();
+ expect(extracted.warnings).toContain("counterparty_anchor_dropped_low_quality");
+ });
+
+ it("derives VAT forecast quarter-to-date window when plain date phrase is present", () => {
+ const extracted = extractAddressFilters(
+ "мож прикинусь плиз скока ндс надо заплатить на 15 марта 2020 года",
+ "vat_payable_forecast"
+ );
+ expect(extracted.extracted_filters.period_from).toBe("2020-01-01");
+ expect(extracted.extracted_filters.period_to).toBe("2020-03-15");
+ expect(extracted.warnings).toContain("period_to_derived_from_as_of_date_for_vat_forecast");
+ });
+
+ it("derives VAT forecast quarter-to-date window for explicit day+month+year phrase", () => {
+ const extracted = extractAddressFilters(
+ "сколько НДС нужно заплатить за 5 марта 2017 года",
+ "vat_payable_forecast"
+ );
+ expect(extracted.extracted_filters.period_from).toBe("2017-01-01");
+ expect(extracted.extracted_filters.period_to).toBe("2017-03-05");
+ expect(extracted.warnings).toContain("period_to_derived_from_as_of_date_for_vat_forecast");
+ });
+
+ it("derives VAT forecast quarter-to-date window when strict as-of cue is present", () => {
+ const extracted = extractAddressFilters(
+ "сколько НДС нужно заплатить по состоянию на 15 марта 2020 года",
+ "vat_payable_forecast"
+ );
+ expect(extracted.extracted_filters.period_from).toBe("2020-01-01");
+ expect(extracted.extracted_filters.period_to).toBe("2020-03-15");
+ expect(extracted.warnings).toContain("period_to_derived_from_as_of_date_for_vat_forecast");
+ });
+
it("defaults as_of_date for documents_forming_balance when date is omitted", () => {
const result = extractAddressFilters("which documents form balance for account 62", "documents_forming_balance");
expect(result.extracted_filters.account).toBe("62");
@@ -1633,6 +1913,16 @@ describe("address filter extraction for balance drilldown", () => {
expect(result.warnings).toContain("period_window_cleared_for_as_of_intent");
});
+ it("cuts report-date tail from counterparty anchor and keeps clean as_of filter", () => {
+ const result = extractAddressFilters(
+ "Показать незакрытые записи для контрагента 'СВК' на дату отчетности 2020-12-31",
+ "open_items_by_counterparty_or_contract"
+ );
+ expect(result.extracted_filters.counterparty).toBe("СВК");
+ expect(result.extracted_filters.as_of_date).toBe("2020-12-31");
+ expect(String(result.extracted_filters.counterparty ?? "").toLowerCase()).not.toContain("отчетности");
+ });
+
it("derives month period for balance snapshot from 'на май 2020'", () => {
const result = extractAddressFilters("Какой остаток по счету 60 на май 2020", "account_balance_snapshot");
expect(result.extracted_filters.account).toBe("60");
@@ -2042,6 +2332,18 @@ describe("address query limited taxonomy and stage diagnostics", () => {
expect(["FACTUAL_LIST", "LIMITED_WITH_REASON", "FACTUAL_SUMMARY"]).toContain(result?.response_type);
});
+ it("routes top counterparty slang wording into customer value aggregate recipe", async () => {
+ const service = new AddressQueryService();
+ const result = await service.tryHandle("какой самый жирный контрагент у нее? кто больше платит денег");
+ expect(result?.handled).toBe(true);
+ expect(result?.debug.detected_intent).toBe("customer_revenue_and_payments");
+ expect(result?.debug.selected_recipe).toBe("address_customer_revenue_and_payments_v1");
+ expect(result?.debug.extracted_filters.counterparty).toBeUndefined();
+ expect(result?.debug.limited_reason_category).not.toBe("unsupported");
+ expect(result?.debug.mcp_call_status).not.toBe("skipped");
+ expect(["FACTUAL_LIST", "LIMITED_WITH_REASON", "FACTUAL_SUMMARY"]).toContain(result?.response_type);
+ });
+
it("routes supplier payout question into dedicated aggregate recipe", async () => {
const service = new AddressQueryService();
const result = await service.tryHandle("кому мы больше всего сгрузили денег, топ-20 поставщиков");
@@ -2062,6 +2364,61 @@ describe("address query limited taxonomy and stage diagnostics", () => {
expect(["FACTUAL_LIST", "LIMITED_WITH_REASON", "FACTUAL_SUMMARY"]).toContain(result?.response_type);
});
+ it("routes top contract wording with 'контракт' into contract value aggregate recipe", async () => {
+ const service = new AddressQueryService();
+ const result = await service.tryHandle("по альтернативе вопрос. какой самый жирный контракт за всю историю у нее?");
+ expect(result?.handled).toBe(true);
+ expect(result?.debug.detected_intent).toBe("contract_usage_and_value");
+ expect(result?.debug.selected_recipe).toBe("address_contract_usage_and_value_v1");
+ expect(result?.debug.limited_reason_category).not.toBe("unsupported");
+ expect(result?.debug.mcp_call_status).not.toBe("skipped");
+ expect(["FACTUAL_LIST", "LIMITED_WITH_REASON", "FACTUAL_SUMMARY"]).toContain(result?.response_type);
+ });
+
+ it("routes revenue-total slang wording into customer value aggregate recipe (no account-missing fallback)", async () => {
+ const service = new AddressQueryService();
+ const result = await service.tryHandle("скока денег альтернатива заработала за 22 год");
+ expect(result?.handled).toBe(true);
+ expect(result?.debug.detected_intent).toBe("customer_revenue_and_payments");
+ expect(result?.debug.selected_recipe).toBe("address_customer_revenue_and_payments_v1");
+ expect(result?.debug.limited_reason_category).not.toBe("missing_anchor");
+ expect(result?.debug.missing_required_filters).not.toContain("account");
+ expect(result?.debug.mcp_call_status).not.toBe("skipped");
+ expect(["FACTUAL_LIST", "LIMITED_WITH_REASON", "FACTUAL_SUMMARY"]).toContain(result?.response_type);
+ });
+
+ it("routes overall-turnover wording into customer value aggregate recipe", async () => {
+ const service = new AddressQueryService();
+ const result = await service.tryHandle("какие общие обороты за все время");
+ expect(result?.handled).toBe(true);
+ expect(result?.debug.detected_intent).toBe("customer_revenue_and_payments");
+ expect(result?.debug.selected_recipe).toBe("address_customer_revenue_and_payments_v1");
+ expect(result?.debug.limited_reason_category).not.toBe("unsupported");
+ expect(result?.debug.mcp_call_status).not.toBe("skipped");
+ expect(["FACTUAL_LIST", "LIMITED_WITH_REASON", "FACTUAL_SUMMARY"]).toContain(result?.response_type);
+ });
+
+ it("routes VAT payment forecast wording into dedicated VAT forecast recipe", async () => {
+ const service = new AddressQueryService();
+ const result = await service.tryHandle("какой прогноз оплаты ндс за 12 мая 2020");
+ expect(result?.handled).toBe(true);
+ expect(result?.debug.detected_intent).toBe("vat_payable_forecast");
+ expect(result?.debug.selected_recipe).toBe("address_vat_payable_forecast_v1");
+ expect(result?.debug.limited_reason_category).not.toBe("unsupported");
+ expect(result?.debug.mcp_call_status).not.toBe("skipped");
+ expect(result?.debug.extracted_filters.counterparty).toBeUndefined();
+ });
+
+ it("routes colloquial VAT payable estimate wording into VAT forecast recipe", async () => {
+ const service = new AddressQueryService();
+ const result = await service.tryHandle("мож прикинусь плиз скока ндс надо заплатить на 15 марта 2020 года");
+ expect(result?.handled).toBe(true);
+ expect(result?.debug.detected_intent).toBe("vat_payable_forecast");
+ expect(result?.debug.selected_recipe).toBe("address_vat_payable_forecast_v1");
+ expect(result?.debug.limited_reason_category).not.toBe("unsupported");
+ expect(result?.debug.mcp_call_status).not.toBe("skipped");
+ });
+
it("routes customer lifecycle question into dedicated aggregate recipe", async () => {
const service = new AddressQueryService();
const result = await service.tryHandle("Какие заказчики работали с нами в 2020 году?");
@@ -2137,9 +2494,25 @@ describe("address query limited taxonomy and stage diagnostics", () => {
const service = new AddressQueryService();
const result = await service.tryHandle("покажи документы все по жуковке 51");
expect(result?.handled).toBe(true);
+ expect(String(result?.assistant_reply ?? "")).not.toContain("Точный якорь не подтвердился");
+ expect(String(result?.assistant_reply ?? "")).not.toContain("якорь не подтвердился");
if (result?.reply_type === "partial_coverage") {
- expect(String(result?.assistant_reply ?? "")).not.toContain("Точный якорь не подтвердился");
expect(result?.debug.rows_matched).toBe(0);
+ if (String(result?.debug.match_failure_reason ?? "").includes("counterparty_anchor_not_matched")) {
+ expect(String(result?.assistant_reply ?? "")).toContain("уточните точное имя контрагента");
+ }
+ }
+ });
+
+ it("does not keep report-date phrase inside open-items counterparty anchor", async () => {
+ const service = new AddressQueryService();
+ const result = await service.tryHandle("покажи хвосты по контрагенту СВК на 2020-12-31");
+ expect(result?.handled).toBe(true);
+ expect(String(result?.debug.extracted_filters.counterparty ?? "").toLowerCase()).toContain("свк");
+ expect(String(result?.debug.extracted_filters.counterparty ?? "").toLowerCase()).not.toContain("дата отчетности");
+ expect(String(result?.debug.anchor_value_raw ?? "").toLowerCase()).not.toContain("дата отчетности");
+ if (result?.reply_type === "partial_coverage") {
+ expect(result?.debug.limited_reason_category).not.toBe("missing_anchor");
}
});
@@ -2399,6 +2772,24 @@ describe("address decompose stage follow-up carryover", () => {
).toBe(true);
});
+ it("replaces 'кроме этого документа...' pseudo-anchor with previous counterparty from follow-up context", () => {
+ const result = runAddressDecomposeStage("кроме этого документа есть еще чтото?", {
+ previous_intent: "list_documents_by_counterparty",
+ previous_filters: {
+ counterparty: "ТСЖ \\Жуковка 51\\"
+ },
+ previous_anchor_type: "counterparty",
+ previous_anchor_value: "ТСЖ \\Жуковка 51\\"
+ });
+ expect(result).not.toBeNull();
+ expect(result?.intent.intent).toBe("list_documents_by_counterparty");
+ expect(result?.filters.extracted_filters.counterparty).toBe("ТСЖ \\Жуковка 51\\");
+ expect(
+ result?.baseReasons?.includes("counterparty_replaced_from_followup_context") ||
+ result?.baseReasons?.includes("counterparty_from_followup_context")
+ ).toBe(true);
+ });
+
it("promotes open-items intent from follow-up wording with inherited contract anchor", () => {
const result = runAddressDecomposeStage("а теперь открытые позиции по нему", {
previous_intent: "bank_operations_by_contract",
@@ -2452,6 +2843,27 @@ describe("address decompose stage follow-up carryover", () => {
expect(followup?.debug.limited_reason_category).not.toBe("missing_anchor");
}
});
+
+ it("keeps VAT explain follow-up in address lane and inherits previous period window", () => {
+ const result = runAddressDecomposeStage("почему прогноз к уплате 0?", {
+ previous_intent: "vat_payable_forecast",
+ previous_filters: {
+ period_from: "2020-03-01",
+ period_to: "2020-03-31"
+ },
+ previous_anchor_type: "unknown",
+ previous_anchor_value: null
+ });
+ expect(result).not.toBeNull();
+ expect(result?.mode.mode).toBe("address_query");
+ expect(result?.intent.intent).toBe("vat_payable_forecast");
+ expect(result?.filters.extracted_filters.period_from).toBe("2020-03-01");
+ expect(result?.filters.extracted_filters.period_to).toBe("2020-03-31");
+ expect(
+ result?.baseReasons?.includes("address_mode_from_followup_context") ||
+ result?.baseReasons?.includes("intent_from_followup_context")
+ ).toBe(true);
+ });
});
describe("address recipe catalog counterparty filtering", () => {
@@ -2694,5 +3106,24 @@ describe("address recipe catalog counterparty filtering", () => {
expect(plan.query).toContain("ПОДОБНО \"60.01%\"");
expect(plan.query).toContain("ПОДОБНО \"60.1%\"");
});
+
+ it("builds VAT forecast query with safe account-prefix checks instead of presentation-like clauses", () => {
+ const filters = extractAddressFilters(
+ "мож прикинусь плиз скока ндс надо заплатить на 15 марта 2020 года",
+ "vat_payable_forecast"
+ ).extracted_filters;
+ const selected = selectAddressRecipe("vat_payable_forecast", filters);
+ expect(selected.selected_recipe).toBeTruthy();
+ const plan = buildAddressRecipePlan(selected.selected_recipe!, filters);
+
+ expect(plan.query).toContain("ПОДСТРОКА(ЕСТЬNULL(Движения.СчетКт.Код, \"\"), 1, 5) = \"68.02\"");
+ expect(plan.query).toContain("ПОДСТРОКА(ЕСТЬNULL(Движения.СчетДт.Код, \"\"), 1, 5) = \"68.02\"");
+ expect(plan.query).toContain("ПОДСТРОКА(ЕСТЬNULL(Движения.СчетКт.Код, \"\"), 1, 4) = \"68.2\"");
+ expect(plan.query).toContain("ПОДСТРОКА(ЕСТЬNULL(Движения.СчетДт.Код, \"\"), 1, 4) = \"68.2\"");
+ expect(plan.query).toContain("ПОДСТРОКА(ЕСТЬNULL(Движения.СчетДт.Код, \"\"), 1, 2) = \"19\"");
+ expect(plan.query).toContain("ПОДСТРОКА(ЕСТЬNULL(Движения.СчетКт.Код, \"\"), 1, 2) = \"19\"");
+ expect(plan.query).not.toContain("ПРЕДСТАВЛЕНИЕ(Движения.СчетКт) ПОДОБНО");
+ expect(plan.query).not.toContain("ПРЕДСТАВЛЕНИЕ(Движения.СчетДт) ПОДОБНО");
+ });
});
diff --git a/llm_normalizer/backend/tests/assistantAddressFollowupContext.test.ts b/llm_normalizer/backend/tests/assistantAddressFollowupContext.test.ts
index 39d6a1b..ba435f6 100644
--- a/llm_normalizer/backend/tests/assistantAddressFollowupContext.test.ts
+++ b/llm_normalizer/backend/tests/assistantAddressFollowupContext.test.ts
@@ -56,6 +56,26 @@ function buildAddressLaneResult(overrides?: Record): any {
};
}
+function buildAddressLimitedLaneResult(
+ category: "missing_anchor" | "empty_match" = "missing_anchor",
+ overrides?: Record
+): any {
+ const base = buildAddressLaneResult();
+ return {
+ ...base,
+ reply_text: "Нужны уточнения по якорю.",
+ reply_type: "partial_coverage",
+ response_type: "LIMITED_WITH_REASON",
+ debug: {
+ ...base.debug,
+ response_type: "LIMITED_WITH_REASON",
+ limited_reason_category: category,
+ 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 }> = [];
@@ -186,13 +206,318 @@ describe("assistant address follow-up carryover", () => {
expect(second.reply_type).toBe("factual");
expect(calls).toHaveLength(2);
expect(calls[1].message).toBe(followupMessage);
- expect(calls[1].options?.followupContext?.previous_intent).toBe("list_documents_by_counterparty");
+ expect(calls[1].options?.followupContext?.previous_intent).toBe("bank_operations_by_counterparty");
expect(calls[1].options?.followupContext?.previous_anchor_type).toBe("counterparty");
expect(typeof calls[1].options?.followupContext?.previous_anchor_value).toBe("string");
expect(String(calls[1].options?.followupContext?.previous_anchor_value ?? "").length).toBeGreaterThan(0);
expect(normalizerService.normalize).not.toHaveBeenCalled();
});
+ it("treats short affirmative 'давай' as follow-up for previous address answer", async () => {
+ const calls: Array<{ message: string; options?: any }> = [];
+ const firstMessage = "\u043f\u043e\u043a\u0430\u0436\u0438 \u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u044b \u043f\u043e \u0441\u0432\u043a \u0437\u0430 2020";
+ const followupMessage = "\u0434\u0430\u0432\u0430\u0439";
+
+ const addressQueryService = {
+ tryHandle: vi.fn(async (message: string, options?: any) => {
+ calls.push({ message, options });
+ if (message === followupMessage && !options?.followupContext) {
+ return null;
+ }
+ if (message === followupMessage && options?.followupContext) {
+ return buildAddressLaneResult({
+ debug: {
+ ...buildAddressLaneResult().debug,
+ reasons: ["address_action_detected", "address_entity_detected", "address_followup_context_applied"]
+ }
+ });
+ }
+ return buildAddressLaneResult();
+ })
+ } 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-davai-${Date.now()}`;
+ const first = await service.handleMessage({
+ session_id: sessionId,
+ user_message: firstMessage,
+ 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: followupMessage,
+ useMock: true
+ } as any);
+ expect(second.ok).toBe(true);
+ expect(second.reply_type).toBe("factual");
+
+ expect(calls).toHaveLength(2);
+ expect(calls[1].message).toBe(followupMessage);
+ expect(calls[1].options?.followupContext?.previous_intent).toBe("bank_operations_by_counterparty");
+ expect(calls[1].options?.followupContext?.previous_anchor_type).toBe("counterparty");
+ expect(String(calls[1].options?.followupContext?.previous_anchor_value ?? "").length).toBeGreaterThan(0);
+ expect(normalizerService.normalize).not.toHaveBeenCalled();
+ });
+
+ it("treats typo imperative 'показывыай' as implicit continuation and switches to suggested follow-up intent", async () => {
+ const calls: Array<{ message: string; options?: any }> = [];
+ const firstMessage = "покажи документы по свк за 2020";
+ const followupMessage = "показывыай";
+
+ const addressQueryService = {
+ tryHandle: vi.fn(async (message: string, options?: any) => {
+ calls.push({ message, options });
+ if (message === followupMessage && !options?.followupContext) {
+ return null;
+ }
+ if (message === followupMessage && options?.followupContext) {
+ return buildAddressLaneResult({
+ debug: {
+ ...buildAddressLaneResult().debug,
+ reasons: ["address_action_detected", "address_entity_detected", "address_followup_context_applied"]
+ }
+ });
+ }
+ return buildAddressLaneResult();
+ })
+ } 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-pokazyvai-${Date.now()}`;
+ const first = await service.handleMessage({
+ session_id: sessionId,
+ user_message: firstMessage,
+ 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: followupMessage,
+ useMock: true
+ } as any);
+ expect(second.ok).toBe(true);
+ expect(second.reply_type).toBe("factual");
+
+ expect(calls).toHaveLength(2);
+ expect(calls[1].message).toBe(followupMessage);
+ expect(calls[1].options?.followupContext?.previous_intent).toBe("bank_operations_by_counterparty");
+ expect(calls[1].options?.followupContext?.previous_anchor_type).toBe("counterparty");
+ expect(String(calls[1].options?.followupContext?.previous_anchor_value ?? "").length).toBeGreaterThan(0);
+ expect(normalizerService.normalize).not.toHaveBeenCalled();
+ });
+
+ it("keeps previous counterparty context for referential follow-up 'кроме этого документа...'", async () => {
+ const calls: Array<{ message: string; options?: any }> = [];
+ const firstMessage = "покажи документы по жуковке 51";
+ const followupMessage = "кроме этого документа есть еще чтото?";
+
+ const addressQueryService = {
+ tryHandle: vi.fn(async (message: string, options?: any) => {
+ calls.push({ message, options });
+ if (message === followupMessage && !options?.followupContext) {
+ return null;
+ }
+ if (message === followupMessage && options?.followupContext) {
+ return buildAddressLaneResult({
+ debug: {
+ ...buildAddressLaneResult().debug,
+ anchor_value_raw: "кроме",
+ anchor_value_resolved: "ТСЖ \\Жуковка 51\\",
+ reasons: ["address_action_detected", "address_entity_detected", "address_followup_context_applied"]
+ }
+ });
+ }
+ return buildAddressLaneResult({
+ debug: {
+ ...buildAddressLaneResult().debug,
+ extracted_filters: {
+ sort: "period_desc",
+ limit: 20,
+ counterparty: "жуковке 51"
+ },
+ anchor_value_raw: "жуковке 51",
+ anchor_value_resolved: "ТСЖ \\Жуковка 51\\"
+ }
+ });
+ })
+ } 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-referential-${Date.now()}`;
+ const first = await service.handleMessage({
+ session_id: sessionId,
+ user_message: firstMessage,
+ 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: followupMessage,
+ useMock: true
+ } as any);
+ expect(second.ok).toBe(true);
+ expect(second.reply_type).toBe("factual");
+
+ expect(calls).toHaveLength(2);
+ expect(calls[1].options?.followupContext?.previous_anchor_type).toBe("counterparty");
+ expect(String(calls[1].options?.followupContext?.previous_anchor_value ?? "")).toContain("Жуковка 51");
+ expect(String(calls[1].options?.followupContext?.previous_filters?.counterparty ?? "")).toContain("жуковке 51");
+ expect(normalizerService.normalize).not.toHaveBeenCalled();
+ });
+
+ it("retries with raw user message after rewrite degraded anchor and returns factual follow-up result", async () => {
+ const calls: Array<{ message: string; options?: any }> = [];
+ const firstMessage = "покажи документы по жуковке 51";
+ const followupMessage = "кроме этого документа есть еще чтото?";
+
+ const addressQueryService = {
+ tryHandle: vi.fn(async (message: string, options?: any) => {
+ calls.push({ message, options });
+ const compact = String(message ?? "").trim().toLowerCase();
+ if (calls.length === 1) {
+ return buildAddressLaneResult({
+ debug: {
+ ...buildAddressLaneResult().debug,
+ extracted_filters: {
+ sort: "period_desc",
+ limit: 20,
+ counterparty: "жуковке 51"
+ },
+ anchor_value_raw: "жуковке 51",
+ anchor_value_resolved: "ТСЖ \\Жуковка 51\\"
+ }
+ });
+ }
+ if (compact === followupMessage && options?.followupContext) {
+ return buildAddressLaneResult({
+ debug: {
+ ...buildAddressLaneResult().debug,
+ extracted_filters: {
+ sort: "period_desc",
+ limit: 20,
+ counterparty: "жуковке 51"
+ },
+ anchor_value_raw: "жуковке 51",
+ anchor_value_resolved: "ТСЖ \\Жуковка 51\\",
+ reasons: ["address_action_detected", "address_entity_detected", "address_followup_context_applied"]
+ }
+ });
+ }
+ if (compact.startsWith("документы по контрагенту") && options?.followupContext) {
+ return buildAddressLimitedLaneResult("missing_anchor", {
+ debug: {
+ ...buildAddressLimitedLaneResult("missing_anchor").debug,
+ extracted_filters: {
+ sort: "period_desc",
+ limit: 20,
+ counterparty: "кроме"
+ },
+ anchor_value_raw: "кроме",
+ anchor_value_resolved: "кроме"
+ }
+ });
+ }
+ 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-safe-retry-${Date.now()}`;
+ const first = await service.handleMessage({
+ session_id: sessionId,
+ user_message: firstMessage,
+ 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: followupMessage,
+ useMock: true
+ } as any);
+ expect(second.ok).toBe(true);
+ expect(second.reply_type).toBe("factual");
+ expect(second.debug?.address_retry_audit?.attempted).toBe(true);
+ expect(second.debug?.address_retry_audit?.initial_limited_category).toBe("missing_anchor");
+ expect(second.debug?.address_retry_audit?.retry_message).toBe(followupMessage);
+
+ expect(calls.some((entry) => String(entry.message).toLowerCase().startsWith("документы по контрагенту"))).toBe(true);
+ expect(calls.some((entry) => String(entry.message).toLowerCase() === followupMessage)).toBe(true);
+ expect(normalizerService.normalize).not.toHaveBeenCalled();
+ });
+
it("reuses last real address context after intermediate clarification fallback", async () => {
const calls: Array<{ message: string; options?: any }> = [];
const lifecycleFollowupMessage = "А кто из них новые?";
@@ -279,4 +604,72 @@ describe("assistant address follow-up carryover", () => {
expect(String(contextualCall?.options?.followupContext?.previous_anchor_value ?? "").length).toBeGreaterThan(0);
expect(normalizerService.normalize).toHaveBeenCalledTimes(1);
});
+
+ it("does not carry address follow-up context into capability question", async () => {
+ const calls: Array<{ message: string; options?: any }> = [];
+ const firstMessage = "покажи документы по свк за 2020";
+ const capabilityMessage = "и 1с можешь настроить?";
+
+ const addressQueryService = {
+ tryHandle: vi.fn(async (message: string, options?: any) => {
+ calls.push({ message, options });
+ if (String(message).toLowerCase().includes("свк")) {
+ return buildAddressLaneResult();
+ }
+ 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 chatClient = {
+ chat: vi.fn().mockResolvedValue({
+ raw: { id: "chat-should-not-run" },
+ outputText: "unused",
+ usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 }
+ })
+ } as any;
+
+ const sessions = new AssistantSessionStore();
+ const service = new AssistantService(
+ normalizerService,
+ sessions as any,
+ {} as any,
+ { persistSession: vi.fn() } as any,
+ addressQueryService,
+ chatClient
+ );
+
+ const sessionId = `asst-address-followup-capability-${Date.now()}`;
+ const first = await service.handleMessage({
+ session_id: sessionId,
+ user_message: firstMessage,
+ llmProvider: "local",
+ model: "qwen2.5",
+ useMock: false
+ } as any);
+ expect(first.ok).toBe(true);
+ expect(first.reply_type).toBe("factual");
+
+ const second = await service.handleMessage({
+ session_id: sessionId,
+ user_message: capabilityMessage,
+ llmProvider: "local",
+ model: "qwen2.5",
+ useMock: false
+ } as any);
+ expect(second.ok).toBe(true);
+ expect(second.reply_type).toBe("factual_with_explanation");
+ expect(String(second.assistant_reply).toLowerCase()).toContain("не настраиваю 1с");
+
+ expect(calls).toHaveLength(1);
+ expect(String(calls[0].message).toLowerCase()).toContain("свк");
+ expect(chatClient.chat).toHaveBeenCalledTimes(0);
+ });
});
diff --git a/llm_normalizer/backend/tests/assistantAddressLlmPredecompose.test.ts b/llm_normalizer/backend/tests/assistantAddressLlmPredecompose.test.ts
index 2c842aa..82c1b6e 100644
--- a/llm_normalizer/backend/tests/assistantAddressLlmPredecompose.test.ts
+++ b/llm_normalizer/backend/tests/assistantAddressLlmPredecompose.test.ts
@@ -1054,7 +1054,7 @@ describe("assistant address llm pre-decompose candidate preference", () => {
expect(response.debug?.llm_decomposition_applied).toBe(true);
expect(response.debug?.llm_canonical_candidate_detected).toBe(true);
expect(response.debug?.tool_gate_decision).toBe("run_address_lane");
- expect(["llm_canonical_candidate_detected", "address_mode_classifier_detected"]).toContain(response.debug?.tool_gate_reason);
+ expect(["llm_canonical_candidate_detected", "llm_canonical_data_signal_detected", "address_mode_classifier_detected"]).toContain(response.debug?.tool_gate_reason);
});
it("normalizes short ordinal year like '20й' in noisy docs phrasing", async () => {
diff --git a/llm_normalizer/backend/tests/assistantDataScopeProbeParser.test.ts b/llm_normalizer/backend/tests/assistantDataScopeProbeParser.test.ts
new file mode 100644
index 0000000..c1f2b36
--- /dev/null
+++ b/llm_normalizer/backend/tests/assistantDataScopeProbeParser.test.ts
@@ -0,0 +1,66 @@
+import { describe, expect, it } from "vitest";
+import {
+ extractOrganizationFactsFromRowsForTests,
+ resolveOrganizationNamesByRefsForTests
+} from "../src/services/assistantService";
+
+describe("assistant data scope probe parser", () => {
+ it("extracts organization name and guid from object-ref style row", () => {
+ const rows = [
+ {
+ Organization: {
+ _objectRef: true,
+ Guid: "5b516757-45d0-11e1-8c52-001e5848397d",
+ ObjectType: "CatalogRef.Organization",
+ Presentation: "OOO Alternativa Plus"
+ }
+ }
+ ];
+
+ const facts = extractOrganizationFactsFromRowsForTests(rows as any);
+
+ expect(facts.names).toContain("OOO Alternativa Plus");
+ expect(facts.refs).toContain("5b516757-45d0-11e1-8c52-001e5848397d");
+ expect(
+ facts.pairs.some(
+ (item: any) =>
+ item.ref === "5b516757-45d0-11e1-8c52-001e5848397d" &&
+ item.name === "OOO Alternativa Plus"
+ )
+ ).toBe(true);
+ });
+
+ it("extracts guid from hard-key payload (name is best-effort)", () => {
+ const rows = [
+ {
+ payload: {
+ _objectRef: true,
+ f1: "5b516757-45d0-11e1-8c52-001e5848397d",
+ f2: "CatalogRef.Organization",
+ f3: "OOO Alternativa Plus"
+ }
+ }
+ ];
+
+ const facts = extractOrganizationFactsFromRowsForTests(rows as any);
+
+ expect(facts.refs).toContain("5b516757-45d0-11e1-8c52-001e5848397d");
+ });
+
+ it("resolves names by guid references from ref-name pairs", () => {
+ const names = resolveOrganizationNamesByRefsForTests(
+ ["5b516757-45d0-11e1-8c52-001e5848397d", "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"],
+ {
+ names: ["OOO Alternativa Plus", "OOO Drugaya"],
+ refs: ["5b516757-45d0-11e1-8c52-001e5848397d", "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"],
+ pairs: [
+ { ref: "5b516757-45d0-11e1-8c52-001e5848397d", name: "OOO Alternativa Plus" },
+ { ref: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", name: "OOO Drugaya" }
+ ]
+ } as any
+ );
+
+ expect(names).toEqual(["OOO Alternativa Plus"]);
+ });
+});
+
diff --git a/llm_normalizer/backend/tests/assistantLivingChatMode.test.ts b/llm_normalizer/backend/tests/assistantLivingChatMode.test.ts
new file mode 100644
index 0000000..0482e20
--- /dev/null
+++ b/llm_normalizer/backend/tests/assistantLivingChatMode.test.ts
@@ -0,0 +1,792 @@
+import { describe, expect, it, vi } from "vitest";
+import { AssistantService } from "../src/services/assistantService";
+import { AssistantSessionStore } from "../src/services/assistantSessionStore";
+
+describe("assistant living chat mode", () => {
+ it("handles casual greeting in chat mode without deep-pipeline pass", async () => {
+ const normalizer = {
+ normalize: vi.fn().mockResolvedValue({
+ ok: false,
+ trace_id: "norm-chat-1",
+ prompt_version: "normalizer_v2_0_2",
+ schema_version: "v2_0_2",
+ normalized: null,
+ validation: { passed: false, errors: ["mock"] },
+ route_hint_summary: null,
+ raw_model_output: {},
+ usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 },
+ latency_ms: 1,
+ request_count_for_case: 1
+ })
+ } as any;
+
+ const sessions = new AssistantSessionStore();
+ const addressQueryService = {
+ tryHandle: vi.fn().mockResolvedValue({ handled: false })
+ } as any;
+ const chatClient = {
+ chat: vi.fn().mockResolvedValue({
+ raw: { id: "chat-1" },
+ outputText: "Hello. We can chat freely.",
+ usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 }
+ })
+ } as any;
+
+ const service = new AssistantService(normalizer as any, sessions, undefined as any, undefined as any, addressQueryService, chatClient);
+
+ const response = await service.handleMessage({
+ session_id: "asst-living-chat-1",
+ user_message: "hello, how are you?",
+ llmProvider: "local",
+ model: "qwen2.5",
+ useMock: false
+ } as any);
+
+ expect(response.ok).toBe(true);
+ expect(response.reply_type).toBe("factual_with_explanation");
+ expect(String(response.assistant_reply).toLowerCase()).toContain("chat");
+ expect(chatClient.chat).toHaveBeenCalledTimes(1);
+ expect(normalizer.normalize).toHaveBeenCalledTimes(1);
+ expect(addressQueryService.tryHandle).toHaveBeenCalledTimes(0);
+ });
+
+ it("does not force address lane for unsupported low-confidence predecompose canonical", async () => {
+ const normalizer = {
+ normalize: vi.fn().mockResolvedValue({
+ ok: true,
+ trace_id: "norm-chat-yo",
+ prompt_version: "normalizer_v2_0_2",
+ schema_version: "v2_0_2",
+ normalized: {
+ schema_version: "normalized_query_v2_0_2",
+ fragments: [
+ {
+ fragment_id: "F1",
+ raw_fragment_text: "yo",
+ normalized_fragment_text: "yoft",
+ confidence: "low",
+ domain_relevance: "in_scope",
+ business_scope: "generic_accounting",
+ time_scope: { type: "missing", value: null, confidence: "low" },
+ candidate_labels: ["ambiguous_human_query"],
+ execution_readiness: "clarification_needed",
+ route_status: "no_route",
+ no_route_reason: "insufficient_specificity"
+ }
+ ]
+ },
+ validation: { passed: true, errors: [] },
+ route_hint_summary: null,
+ raw_model_output: {},
+ usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 },
+ latency_ms: 2,
+ request_count_for_case: 1
+ })
+ } as any;
+
+ const sessions = new AssistantSessionStore();
+ const addressQueryService = {
+ tryHandle: vi.fn().mockResolvedValue({ handled: false })
+ } as any;
+ const chatClient = {
+ chat: vi.fn().mockResolvedValue({
+ raw: { id: "chat-yo" },
+ outputText: "Yo. On call.",
+ usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 }
+ })
+ } as any;
+
+ const service = new AssistantService(normalizer as any, sessions, undefined as any, undefined as any, addressQueryService, chatClient);
+
+ const response = await service.handleMessage({
+ session_id: "asst-living-chat-yo",
+ user_message: "yo",
+ llmProvider: "local",
+ model: "qwen2.5",
+ useMock: false
+ } as any);
+
+ expect(response.ok).toBe(true);
+ expect(response.reply_type).toBe("factual_with_explanation");
+ expect(String(response.assistant_reply).toLowerCase()).toContain("yo");
+ expect(chatClient.chat).toHaveBeenCalledTimes(1);
+ expect(addressQueryService.tryHandle).toHaveBeenCalledTimes(0);
+ });
+
+ it("keeps casual 'че как' in chat mode when predecompose canonical is unsupported", async () => {
+ const normalizer = {
+ normalize: vi.fn().mockResolvedValue({
+ ok: true,
+ trace_id: "norm-chat-che-kak",
+ prompt_version: "normalizer_v2_0_2",
+ schema_version: "v2_0_2",
+ normalized: {
+ schema_version: "normalized_query_v2_0_2",
+ fragments: [
+ {
+ fragment_id: "F1",
+ raw_fragment_text: "\u0447\u0435 \u043a\u0430\u043a",
+ normalized_fragment_text: "\u041d\u0435\u044f\u0441\u043d\u044b\u0439 \u0437\u0430\u043f\u0440\u043e\u0441.",
+ confidence: "low",
+ domain_relevance: "in_scope",
+ business_scope: "generic_accounting",
+ time_scope: { type: "missing", value: null, confidence: "low" },
+ candidate_labels: ["ambiguous_human_query"],
+ execution_readiness: "clarification_needed",
+ route_status: "no_route",
+ no_route_reason: "insufficient_specificity"
+ }
+ ]
+ },
+ validation: { passed: true, errors: [] },
+ route_hint_summary: null,
+ raw_model_output: {},
+ usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 },
+ latency_ms: 2,
+ request_count_for_case: 1
+ })
+ } as any;
+
+ const sessions = new AssistantSessionStore();
+ const addressQueryService = {
+ tryHandle: vi.fn().mockResolvedValue({ handled: false })
+ } as any;
+ const chatClient = {
+ chat: vi.fn().mockResolvedValue({
+ raw: { id: "chat-che-kak" },
+ outputText: "\u041f\u0440\u0438\u0432\u0435\u0442. \u041d\u0430 \u0441\u0432\u044f\u0437\u0438.",
+ usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 }
+ })
+ } as any;
+
+ const service = new AssistantService(normalizer as any, sessions, undefined as any, undefined as any, addressQueryService, chatClient);
+
+ const response = await service.handleMessage({
+ session_id: "asst-living-chat-che-kak",
+ user_message: "\u0447\u0435 \u043a\u0430\u043a",
+ llmProvider: "local",
+ model: "qwen2.5",
+ useMock: false
+ } as any);
+
+ expect(response.ok).toBe(true);
+ expect(response.reply_type).toBe("factual_with_explanation");
+ expect(chatClient.chat).toHaveBeenCalledTimes(1);
+ expect(addressQueryService.tryHandle).toHaveBeenCalledTimes(0);
+ });
+
+ it("returns capability contract and avoids address lane for 'и 1с можешь настроить?'", async () => {
+ const normalizer = {
+ normalize: vi.fn().mockResolvedValue({
+ ok: false,
+ trace_id: "norm-capabilities",
+ prompt_version: "normalizer_v2_0_2",
+ schema_version: "v2_0_2",
+ normalized: null,
+ validation: { passed: false, errors: ["mock"] },
+ route_hint_summary: null,
+ raw_model_output: {},
+ usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 },
+ latency_ms: 1,
+ request_count_for_case: 1
+ })
+ } as any;
+
+ const sessions = new AssistantSessionStore();
+ const addressQueryService = {
+ tryHandle: vi.fn().mockResolvedValue({ handled: false })
+ } as any;
+ const chatClient = {
+ chat: vi.fn().mockResolvedValue({
+ raw: { id: "chat-capability-should-not-run" },
+ outputText: "unused",
+ usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 }
+ })
+ } as any;
+
+ const service = new AssistantService(normalizer as any, sessions, undefined as any, undefined as any, addressQueryService, chatClient);
+
+ const response = await service.handleMessage({
+ session_id: "asst-living-chat-capability",
+ user_message: "\u0438 1\u0441 \u043c\u043e\u0436\u0435\u0448\u044c \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c?",
+ llmProvider: "local",
+ model: "qwen2.5",
+ useMock: false
+ } as any);
+
+ expect(response.ok).toBe(true);
+ expect(response.reply_type).toBe("factual_with_explanation");
+ expect(String(response.assistant_reply).toLowerCase()).toContain("\u0440\u0435\u0436\u0438\u043c\u0435 \u0447\u0442\u0435\u043d\u0438\u044f");
+ expect(String(response.assistant_reply).toLowerCase()).toContain("\u043d\u0435 \u043d\u0430\u0441\u0442\u0440\u0430\u0438\u0432\u0430\u044e 1\u0441");
+ expect(response.debug?.tool_gate_reason).toBe("assistant_capability_query_detected");
+ expect(chatClient.chat).toHaveBeenCalledTimes(0);
+ expect(addressQueryService.tryHandle).toHaveBeenCalledTimes(0);
+ });
+
+ it("returns operational boundary reply for imperative setup request and avoids address lane", async () => {
+ const normalizer = {
+ normalize: vi.fn().mockResolvedValue({
+ ok: false,
+ trace_id: "norm-operational-boundary",
+ prompt_version: "normalizer_v2_0_2",
+ schema_version: "v2_0_2",
+ normalized: null,
+ validation: { passed: false, errors: ["mock"] },
+ route_hint_summary: null,
+ raw_model_output: {},
+ usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 },
+ latency_ms: 1,
+ request_count_for_case: 1
+ })
+ } as any;
+
+ const sessions = new AssistantSessionStore();
+ const addressQueryService = {
+ tryHandle: vi.fn().mockResolvedValue({ handled: false })
+ } as any;
+ const chatClient = {
+ chat: vi.fn().mockResolvedValue({
+ raw: { id: "chat-operational-boundary-should-not-run" },
+ outputText: "unused",
+ usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 }
+ })
+ } as any;
+
+ const service = new AssistantService(normalizer as any, sessions, undefined as any, undefined as any, addressQueryService, chatClient);
+
+ const response = await service.handleMessage({
+ session_id: "asst-living-chat-operational-boundary",
+ user_message: "\u043d\u0430\u0441\u0442\u0440\u043e\u0439 1\u0441 \u043f\u043b\u0438\u0437",
+ llmProvider: "local",
+ model: "qwen2.5",
+ useMock: false
+ } as any);
+
+ expect(response.ok).toBe(true);
+ expect(response.reply_type).toBe("factual_with_explanation");
+ expect(String(response.assistant_reply).toLowerCase()).toContain("\u043d\u0435 \u043c\u043e\u0433\u0443 \u0441\u0430\u043c \u043d\u0430\u0441\u0442\u0440\u0430\u0438\u0432\u0430\u0442\u044c 1\u0441");
+ expect(response.debug?.tool_gate_reason).toBe("assistant_capability_query_detected");
+ expect(response.debug?.living_chat_response_source).toBe("deterministic_operational_boundary");
+ expect(chatClient.chat).toHaveBeenCalledTimes(0);
+ expect(addressQueryService.tryHandle).toHaveBeenCalledTimes(0);
+ });
+
+ it("returns safety refusal for destructive request under coercion and avoids address lane", async () => {
+ const normalizer = {
+ normalize: vi.fn().mockResolvedValue({
+ ok: false,
+ trace_id: "norm-safety-refusal",
+ prompt_version: "normalizer_v2_0_2",
+ schema_version: "v2_0_2",
+ normalized: null,
+ validation: { passed: false, errors: ["mock"] },
+ route_hint_summary: null,
+ raw_model_output: {},
+ usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 },
+ latency_ms: 1,
+ request_count_for_case: 1
+ })
+ } as any;
+
+ const sessions = new AssistantSessionStore();
+ const addressQueryService = {
+ tryHandle: vi.fn().mockResolvedValue({ handled: false })
+ } as any;
+ const chatClient = {
+ chat: vi.fn().mockResolvedValue({
+ raw: { id: "chat-safety-refusal-should-not-run" },
+ outputText: "unused",
+ usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 }
+ })
+ } as any;
+
+ const service = new AssistantService(normalizer as any, sessions, undefined as any, undefined as any, addressQueryService, chatClient);
+
+ const response = await service.handleMessage({
+ session_id: "asst-living-chat-safety-refusal",
+ user_message: "\u043e\u043c\u043e\u043d \u043b\u043e\u043c\u0438\u0442\u0441\u044f - \u0443\u0434\u0430\u043b\u044f\u0439 \u0431\u0430\u0437\u0443 \u0438\u043b\u0438 \u043c\u0435\u043d\u044f \u0443\u0431\u044c\u044e\u0442",
+ llmProvider: "local",
+ model: "qwen2.5",
+ useMock: false
+ } as any);
+
+ expect(response.ok).toBe(true);
+ expect(response.reply_type).toBe("factual_with_explanation");
+ expect(String(response.assistant_reply).toLowerCase()).toContain("\u043d\u0435 \u043c\u043e\u0433\u0443 \u043f\u043e\u043c\u043e\u0433\u0430\u0442\u044c \u0441 \u0443\u0434\u0430\u043b\u0435\u043d\u0438\u0435\u043c \u0431\u0430\u0437\u044b");
+ expect(String(response.assistant_reply)).toContain("112");
+ expect(response.debug?.tool_gate_reason).toBe("assistant_capability_query_detected");
+ expect(response.debug?.living_chat_response_source).toBe("deterministic_safety_refusal");
+ expect(chatClient.chat).toHaveBeenCalledTimes(0);
+ expect(addressQueryService.tryHandle).toHaveBeenCalledTimes(0);
+ });
+
+ it("strips unexpected CJK fragments from live chat reply when user did not request CJK", async () => {
+ const normalizer = {
+ normalize: vi.fn().mockResolvedValue({
+ ok: false,
+ trace_id: "norm-chat-script-guard",
+ prompt_version: "normalizer_v2_0_2",
+ schema_version: "v2_0_2",
+ normalized: null,
+ validation: { passed: false, errors: ["mock"] },
+ route_hint_summary: null,
+ raw_model_output: {},
+ usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 },
+ latency_ms: 1,
+ request_count_for_case: 1
+ })
+ } as any;
+
+ const sessions = new AssistantSessionStore();
+ const addressQueryService = {
+ tryHandle: vi.fn().mockResolvedValue({ handled: false })
+ } as any;
+ const chatClient = {
+ chat: vi.fn().mockResolvedValue({
+ raw: { id: "chat-script-guard" },
+ outputText: "Прошу прощения, но я не могу продолжать этот разговор. 随时关注。",
+ usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 }
+ })
+ } as any;
+
+ const service = new AssistantService(normalizer as any, sessions, undefined as any, undefined as any, addressQueryService, chatClient);
+
+ const response = await service.handleMessage({
+ session_id: "asst-living-chat-script-guard",
+ user_message: "че как",
+ llmProvider: "local",
+ model: "qwen2.5",
+ useMock: false
+ } as any);
+
+ expect(response.ok).toBe(true);
+ expect(response.reply_type).toBe("factual_with_explanation");
+ expect(String(response.assistant_reply)).toContain("Прошу прощения");
+ expect(/[\u3400-\u9FFF\uF900-\uFAFF]/u.test(String(response.assistant_reply))).toBe(false);
+ expect(response.debug?.living_chat_response_source).toBe("llm_chat_script_guard");
+ expect(response.debug?.living_chat_script_guard_applied).toBe(true);
+ expect(chatClient.chat).toHaveBeenCalledTimes(1);
+ expect(addressQueryService.tryHandle).toHaveBeenCalledTimes(0);
+ });
+
+ it("handles mojibake capability query and avoids address clarification flow", async () => {
+ const normalizer = {
+ normalize: vi.fn().mockResolvedValue({
+ ok: false,
+ trace_id: "norm-mojibake-capability",
+ prompt_version: "normalizer_v2_0_2",
+ schema_version: "v2_0_2",
+ normalized: null,
+ validation: { passed: false, errors: ["mock"] },
+ route_hint_summary: null,
+ raw_model_output: {},
+ usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 },
+ latency_ms: 1,
+ request_count_for_case: 1
+ })
+ } as any;
+
+ const sessions = new AssistantSessionStore();
+ const addressQueryService = {
+ tryHandle: vi.fn().mockResolvedValue({ handled: false })
+ } as any;
+ const chatClient = {
+ chat: vi.fn().mockResolvedValue({
+ raw: { id: "chat-mojibake-capability-should-not-run" },
+ outputText: "unused",
+ usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 }
+ })
+ } as any;
+
+ const service = new AssistantService(normalizer as any, sessions, undefined as any, undefined as any, addressQueryService, chatClient);
+
+ const response = await service.handleMessage({
+ session_id: "asst-living-chat-mojibake-capability",
+ user_message: "ок - что можешь по 1с",
+ llmProvider: "local",
+ model: "qwen2.5",
+ useMock: false
+ } as any);
+
+ expect(response.ok).toBe(true);
+ expect(response.reply_type).toBe("factual_with_explanation");
+ expect(String(response.assistant_reply).toLowerCase()).toContain("\u0440\u0435\u0436\u0438\u043c\u0435 \u0447\u0442\u0435\u043d\u0438\u044f");
+ expect(response.debug?.tool_gate_reason).toBe("assistant_capability_query_detected");
+ expect(chatClient.chat).toHaveBeenCalledTimes(0);
+ expect(addressQueryService.tryHandle).toHaveBeenCalledTimes(0);
+ });
+
+ it("handles mojibake feature-capability wording and avoids free-form llm chat", async () => {
+ const normalizer = {
+ normalize: vi.fn().mockResolvedValue({
+ ok: false,
+ trace_id: "norm-mojibake-feature-capability",
+ prompt_version: "normalizer_v2_0_2",
+ schema_version: "v2_0_2",
+ normalized: null,
+ validation: { passed: false, errors: ["mock"] },
+ route_hint_summary: null,
+ raw_model_output: {},
+ usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 },
+ latency_ms: 1,
+ request_count_for_case: 1
+ })
+ } as any;
+
+ const sessions = new AssistantSessionStore();
+ const addressQueryService = {
+ tryHandle: vi.fn().mockResolvedValue({ handled: false })
+ } as any;
+ const chatClient = {
+ chat: vi.fn().mockResolvedValue({
+ raw: { id: "chat-mojibake-feature-capability-should-not-run" },
+ outputText: "unused",
+ usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 }
+ })
+ } as any;
+
+ const service = new AssistantService(normalizer as any, sessions, undefined as any, undefined as any, addressQueryService, chatClient);
+
+ const response = await service.handleMessage({
+ session_id: "asst-living-chat-mojibake-feature-capability",
+ user_message: "а какие фичи по работе с 1с у тебя отработаны максималльно?",
+ llmProvider: "local",
+ model: "qwen2.5",
+ useMock: false
+ } as any);
+
+ expect(response.ok).toBe(true);
+ expect(response.reply_type).toBe("factual_with_explanation");
+ expect(String(response.assistant_reply).toLowerCase()).toContain("режиме чтения");
+ expect(response.debug?.tool_gate_reason).toBe("assistant_capability_query_detected");
+ expect(response.debug?.living_chat_response_source).toBe("deterministic_capability_contract");
+ expect(chatClient.chat).toHaveBeenCalledTimes(0);
+ expect(addressQueryService.tryHandle).toHaveBeenCalledTimes(0);
+ });
+
+ it("handles data-scope meta question as deterministic chat contract", async () => {
+ const normalizer = {
+ normalize: vi.fn().mockResolvedValue({
+ ok: false,
+ trace_id: "norm-data-scope-meta",
+ prompt_version: "normalizer_v2_0_2",
+ schema_version: "v2_0_2",
+ normalized: null,
+ validation: { passed: false, errors: ["mock"] },
+ route_hint_summary: null,
+ raw_model_output: {},
+ usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 },
+ latency_ms: 1,
+ request_count_for_case: 1
+ })
+ } as any;
+
+ const sessions = new AssistantSessionStore();
+ const addressQueryService = {
+ tryHandle: vi.fn().mockResolvedValue({ handled: false })
+ } as any;
+ const chatClient = {
+ chat: vi.fn().mockResolvedValue({
+ raw: { id: "chat-data-scope-should-not-run" },
+ outputText: "unused",
+ usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 }
+ })
+ } as any;
+
+ const service = new AssistantService(normalizer as any, sessions, undefined as any, undefined as any, addressQueryService, chatClient);
+
+ const response = await service.handleMessage({
+ session_id: "asst-living-chat-data-scope",
+ user_message: "по какой компании мы можем работать?",
+ llmProvider: "local",
+ model: "qwen2.5",
+ useMock: false
+ } as any);
+
+ expect(response.ok).toBe(true);
+ expect(response.reply_type).toBe("factual_with_explanation");
+ expect(String(response.assistant_reply).toLowerCase()).toContain("mcp-канал");
+ expect(response.debug?.tool_gate_reason).toBe("assistant_data_scope_query_detected");
+ expect(response.debug?.living_chat_response_source).toBe("deterministic_data_scope_contract");
+ expect(chatClient.chat).toHaveBeenCalledTimes(0);
+ expect(addressQueryService.tryHandle).toHaveBeenCalledTimes(0);
+ });
+ it("handles 'какая база подрублена?' as deterministic data-scope contract", async () => {
+ const normalizer = {
+ normalize: vi.fn().mockResolvedValue({
+ ok: false,
+ trace_id: "norm-data-scope-podrublena",
+ prompt_version: "normalizer_v2_0_2",
+ schema_version: "v2_0_2",
+ normalized: null,
+ validation: { passed: false, errors: ["mock"] },
+ route_hint_summary: null,
+ raw_model_output: {},
+ usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 },
+ latency_ms: 1,
+ request_count_for_case: 1
+ })
+ } as any;
+
+ const sessions = new AssistantSessionStore();
+ const addressQueryService = {
+ tryHandle: vi.fn().mockResolvedValue({ handled: false })
+ } as any;
+ const chatClient = {
+ chat: vi.fn().mockResolvedValue({
+ raw: { id: "chat-data-scope-podrublena-should-not-run" },
+ outputText: "unused",
+ usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 }
+ })
+ } as any;
+
+ const service = new AssistantService(normalizer as any, sessions, undefined as any, undefined as any, addressQueryService, chatClient);
+
+ const response = await service.handleMessage({
+ session_id: "asst-living-chat-data-scope-podrublena",
+ user_message: "какая база подрублена?",
+ llmProvider: "local",
+ model: "qwen2.5",
+ useMock: false
+ } as any);
+
+ expect(response.ok).toBe(true);
+ expect(response.reply_type).toBe("factual_with_explanation");
+ expect(String(response.assistant_reply).toLowerCase()).toContain("read-only");
+ expect(response.debug?.tool_gate_reason).toBe("assistant_data_scope_query_detected");
+ expect(response.debug?.living_chat_response_source).toBe("deterministic_data_scope_contract");
+ expect(chatClient.chat).toHaveBeenCalledTimes(0);
+ expect(addressQueryService.tryHandle).toHaveBeenCalledTimes(0);
+ });
+ it("handles typo data-scope query with misspelled company token as deterministic contract", async () => {
+ const normalizer = {
+ normalize: vi.fn().mockResolvedValue({
+ ok: false,
+ trace_id: "norm-data-scope-typo-company",
+ prompt_version: "normalizer_v2_0_2",
+ schema_version: "v2_0_2",
+ normalized: null,
+ validation: { passed: false, errors: ["mock"] },
+ route_hint_summary: null,
+ raw_model_output: {},
+ usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 },
+ latency_ms: 1,
+ request_count_for_case: 1
+ })
+ } as any;
+
+ const sessions = new AssistantSessionStore();
+ const addressQueryService = {
+ tryHandle: vi.fn().mockResolvedValue({ handled: false })
+ } as any;
+ const chatClient = {
+ chat: vi.fn().mockResolvedValue({
+ raw: { id: "chat-data-scope-typo-company-should-not-run" },
+ outputText: "unused",
+ usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 }
+ })
+ } as any;
+
+ const service = new AssistantService(normalizer as any, sessions, undefined as any, undefined as any, addressQueryService, chatClient);
+
+ const response = await service.handleMessage({
+ session_id: "asst-living-chat-data-scope-typo-company",
+ user_message: "подскажи плиз с какой компинией можем поработать?",
+ llmProvider: "local",
+ model: "qwen2.5",
+ useMock: false
+ } as any);
+
+ expect(response.ok).toBe(true);
+ expect(response.reply_type).toBe("factual_with_explanation");
+ expect(response.debug?.tool_gate_reason).toBe("assistant_data_scope_query_detected");
+ expect(response.debug?.living_chat_response_source).toBe("deterministic_data_scope_contract");
+ expect(chatClient.chat).toHaveBeenCalledTimes(0);
+ expect(addressQueryService.tryHandle).toHaveBeenCalledTimes(0);
+ });
+
+ it("handles no-question-mark data-scope phrase with interrogative token as deterministic contract", async () => {
+ const normalizer = {
+ normalize: vi.fn().mockResolvedValue({
+ ok: false,
+ trace_id: "norm-data-scope-no-qmark",
+ prompt_version: "normalizer_v2_0_2",
+ schema_version: "v2_0_2",
+ normalized: null,
+ validation: { passed: false, errors: ["mock"] },
+ route_hint_summary: null,
+ raw_model_output: {},
+ usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 },
+ latency_ms: 1,
+ request_count_for_case: 1
+ })
+ } as any;
+
+ const sessions = new AssistantSessionStore();
+ const addressQueryService = {
+ tryHandle: vi.fn().mockResolvedValue({ handled: false })
+ } as any;
+ const chatClient = {
+ chat: vi.fn().mockResolvedValue({
+ raw: { id: "chat-data-scope-no-qmark-should-not-run" },
+ outputText: "unused",
+ usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 }
+ })
+ } as any;
+
+ const service = new AssistantService(normalizer as any, sessions, undefined as any, undefined as any, addressQueryService, chatClient);
+
+ const response = await service.handleMessage({
+ session_id: "asst-living-chat-data-scope-no-qmark",
+ user_message: "каза какой компании подключена к 1с",
+ llmProvider: "local",
+ model: "qwen2.5",
+ useMock: false
+ } as any);
+
+ expect(response.ok).toBe(true);
+ expect(response.reply_type).toBe("factual_with_explanation");
+ expect(response.debug?.tool_gate_reason).toBe("assistant_data_scope_query_detected");
+ expect(response.debug?.living_chat_response_source).toBe("deterministic_data_scope_contract");
+ expect(chatClient.chat).toHaveBeenCalledTimes(0);
+ expect(addressQueryService.tryHandle).toHaveBeenCalledTimes(0);
+ });
+
+ it("does not misroute contract ranking query to data-scope when canonical text contains 'компании'", async () => {
+ const normalizer = {
+ normalize: vi.fn().mockResolvedValue({
+ ok: true,
+ trace_id: "norm-no-datascope-contract-ranking",
+ prompt_version: "normalizer_v2_0_2",
+ schema_version: "v2_0_2",
+ normalized: {
+ schema_version: "normalized_query_v2_0_2",
+ user_message_raw: "по альтернативе вопрос. какой самый жирный контракт за всю историю у нее?",
+ message_in_scope: true,
+ scope_confidence: "high",
+ contains_multiple_tasks: false,
+ fragments: [
+ {
+ fragment_id: "F1",
+ raw_fragment_text: "по альтернативе вопрос. какой самый жирный контракт за всю историю у нее?",
+ normalized_fragment_text: "Какой самый крупный договор в истории компании?",
+ domain_relevance: "in_scope",
+ business_scope: "company_specific_accounting",
+ entity_hints: [],
+ account_hints: [],
+ document_hints: ["договор"],
+ register_hints: [],
+ time_scope: {
+ type: "explicit",
+ value: "all_time",
+ confidence: "medium"
+ },
+ flags: {
+ has_multi_entity_scope: false,
+ asks_for_chain_explanation: false,
+ asks_for_ranking_or_top: true,
+ asks_for_period_summary: false,
+ asks_for_rule_check: false,
+ asks_for_anomaly_scan: false,
+ asks_for_exact_object_trace: false,
+ asks_for_evidence: false,
+ mentions_period_close_context: false
+ },
+ candidate_labels: ["simple_factual"],
+ confidence: "medium",
+ execution_readiness: "executable",
+ clarification_reason: null,
+ soft_assumption_used: [],
+ route_status: "routed",
+ no_route_reason: null
+ }
+ ],
+ discarded_fragments: [],
+ global_notes: {
+ needs_clarification: false,
+ clarification_reason: null
+ }
+ },
+ validation: { passed: true, errors: [] },
+ route_hint_summary: null,
+ raw_model_output: {},
+ usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 },
+ latency_ms: 10,
+ request_count_for_case: 1
+ })
+ } as any;
+
+ const sessions = new AssistantSessionStore();
+ const addressQueryService = {
+ tryHandle: vi.fn().mockResolvedValue({
+ 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: "contract_usage_and_value",
+ detected_intent_confidence: "high",
+ extracted_filters: { sort: "period_desc", limit: 20 },
+ missing_required_filters: [],
+ selected_recipe: "address_contract_usage_and_value_v1",
+ mcp_call_status_legacy: "matched_non_empty",
+ account_scope_mode: "preferred",
+ account_scope_fallback_applied: false,
+ anchor_type: "unknown",
+ anchor_value_raw: null,
+ anchor_value_resolved: null,
+ 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: 20,
+ rows_after_recipe_filter: 20,
+ rows_materialized: 20,
+ rows_matched: 20,
+ 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: ["contract_usage_and_value_signal_detected"]
+ }
+ })
+ } as any;
+ const chatClient = {
+ chat: vi.fn().mockResolvedValue({
+ raw: { id: "chat-should-not-run-for-contract-ranking" },
+ outputText: "unused",
+ usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 }
+ })
+ } as any;
+
+ const service = new AssistantService(normalizer as any, sessions, undefined as any, undefined as any, addressQueryService, chatClient);
+
+ const response = await service.handleMessage({
+ session_id: "asst-living-chat-no-datascope-contract-ranking",
+ user_message: "по альтернативе вопрос. какой самый жирный контракт за всю историю у нее?",
+ llmProvider: "local",
+ model: "qwen2.5",
+ useMock: false
+ } as any);
+
+ expect(response.ok).toBe(true);
+ expect(response.reply_type).toBe("factual");
+ expect(response.debug?.tool_gate_reason).not.toBe("assistant_data_scope_query_detected");
+ expect(addressQueryService.tryHandle).toHaveBeenCalledTimes(1);
+ expect(chatClient.chat).toHaveBeenCalledTimes(0);
+ });
+});
diff --git a/llm_normalizer/backend/tests/assistantLivingRouter.test.ts b/llm_normalizer/backend/tests/assistantLivingRouter.test.ts
new file mode 100644
index 0000000..e55d0f6
--- /dev/null
+++ b/llm_normalizer/backend/tests/assistantLivingRouter.test.ts
@@ -0,0 +1,237 @@
+import { describe, expect, it } from "vitest";
+import { resolveAssistantOrchestrationDecision, resolveLivingAssistantModeDecision } from "../src/services/assistantService";
+
+describe("assistant living router mode decision", () => {
+ it("returns address_data when address lane already triggered", () => {
+ const decision = resolveLivingAssistantModeDecision({
+ userMessage: "давай",
+ addressLaneTriggered: true,
+ useMock: false,
+ predecomposeMode: "address_query",
+ predecomposeModeConfidence: "high"
+ });
+ expect(decision.mode).toBe("address_data");
+ expect(decision.reason).toBe("address_lane_triggered");
+ });
+
+ it("keeps deep pipeline in mock mode to avoid test-env network calls", () => {
+ const decision = resolveLivingAssistantModeDecision({
+ userMessage: "привет",
+ addressLaneTriggered: false,
+ useMock: true,
+ predecomposeMode: "unsupported",
+ predecomposeModeConfidence: "low"
+ });
+ expect(decision.mode).toBe("deep_analysis");
+ expect(decision.reason).toBe("mock_mode_keeps_deep_pipeline");
+ });
+
+ it("routes casual non-data phrase to chat mode", () => {
+ const decision = resolveLivingAssistantModeDecision({
+ userMessage: "привет, как дела?",
+ addressLaneTriggered: false,
+ useMock: false,
+ predecomposeMode: "unsupported",
+ predecomposeModeConfidence: "low"
+ });
+ expect(decision.mode).toBe("chat");
+ });
+
+ it("keeps deep mode for strong data signal", () => {
+ const decision = resolveLivingAssistantModeDecision({
+ userMessage: "покажи документы по свк за 2020",
+ addressLaneTriggered: false,
+ useMock: false,
+ predecomposeMode: "unsupported",
+ predecomposeModeConfidence: "low"
+ });
+ expect(decision.mode).toBe("deep_analysis");
+ expect(decision.reason).toBe("strong_data_signal_detected");
+ });
+ it("routes capability question to chat even when phrase contains 1С", () => {
+ const decision = resolveLivingAssistantModeDecision({
+ userMessage: "и 1с можешь настроить?",
+ addressLaneTriggered: false,
+ useMock: false,
+ predecomposeMode: "unsupported",
+ predecomposeModeConfidence: "low"
+ });
+ expect(decision.mode).toBe("chat");
+ expect(decision.reason).toBe("assistant_capability_query_detected");
+ });
+ it("routes capability question 'ok - what can you do in 1c' to chat", () => {
+ const decision = resolveLivingAssistantModeDecision({
+ userMessage: "\u043e\u043a - \u0447\u0442\u043e \u043c\u043e\u0436\u0435\u0448\u044c \u043f\u043e 1\u0441",
+ addressLaneTriggered: false,
+ useMock: false,
+ predecomposeMode: "unsupported",
+ predecomposeModeConfidence: "low"
+ });
+ expect(decision.mode).toBe("chat");
+ expect(decision.reason).toBe("assistant_capability_query_detected");
+ });
+ it("routes feature-capability wording to chat", () => {
+ const decision = resolveLivingAssistantModeDecision({
+ userMessage: "а какие фичи по работе с 1с у тебя отработаны максимально?",
+ addressLaneTriggered: false,
+ useMock: false,
+ predecomposeMode: "unsupported",
+ predecomposeModeConfidence: "low"
+ });
+ expect(decision.mode).toBe("chat");
+ expect(decision.reason).toBe("assistant_capability_query_detected");
+ });
+ it("routes data-scope question to chat instead of address lane", () => {
+ const decision = resolveLivingAssistantModeDecision({
+ userMessage: "по какой компании мы можем работать?",
+ addressLaneTriggered: false,
+ useMock: false,
+ predecomposeMode: "unsupported",
+ predecomposeModeConfidence: "low"
+ });
+ expect(decision.mode).toBe("chat");
+ expect(decision.reason).toBe("assistant_data_scope_query_detected");
+ });
+ it("routes 'whose base is this' style question to chat", () => {
+ const decision = resolveLivingAssistantModeDecision({
+ userMessage: "ну база в тебе чья? как называется контора?",
+ addressLaneTriggered: false,
+ useMock: false,
+ predecomposeMode: "unsupported",
+ predecomposeModeConfidence: "low"
+ });
+ expect(decision.mode).toBe("chat");
+ expect(decision.reason).toBe("assistant_data_scope_query_detected");
+ });
+ it("routes 'какая база подрублена?' to data-scope chat mode", () => {
+ const decision = resolveLivingAssistantModeDecision({
+ userMessage: "какая база подрублена?",
+ addressLaneTriggered: false,
+ useMock: false,
+ predecomposeMode: "unsupported",
+ predecomposeModeConfidence: "low"
+ });
+ expect(decision.mode).toBe("chat");
+ expect(decision.reason).toBe("assistant_data_scope_query_detected");
+ });
+ it("routes typo data-scope wording with misspelled company token to chat", () => {
+ const decision = resolveLivingAssistantModeDecision({
+ userMessage: "подскажи плиз с какой компинией можем поработать?",
+ addressLaneTriggered: false,
+ useMock: false,
+ predecomposeMode: "unsupported",
+ predecomposeModeConfidence: "low"
+ });
+ expect(decision.mode).toBe("chat");
+ expect(decision.reason).toBe("assistant_data_scope_query_detected");
+ });
+
+ it("routes data-scope wording without question mark when interrogative token is present", () => {
+ const decision = resolveLivingAssistantModeDecision({
+ userMessage: "каза какой компании подключена к 1с",
+ addressLaneTriggered: false,
+ useMock: false,
+ predecomposeMode: "unsupported",
+ predecomposeModeConfidence: "low"
+ });
+ expect(decision.mode).toBe("chat");
+ expect(decision.reason).toBe("assistant_data_scope_query_detected");
+ });
+
+ it("does not treat contract ranking data query as data-scope meta question", () => {
+ const decision = resolveLivingAssistantModeDecision({
+ userMessage: "по альтернативе вопрос. какой самый жирный контракт за всю историю у нее?",
+ addressLaneTriggered: false,
+ useMock: false,
+ predecomposeMode: "unsupported",
+ predecomposeModeConfidence: "low"
+ });
+ expect(decision.mode).toBe("deep_analysis");
+ expect(decision.reason).toBe("strong_data_signal_detected");
+ });
+});
+
+describe("assistant orchestration contract", () => {
+ it("keeps VAT payable forecast query in address lane", () => {
+ const decision = resolveAssistantOrchestrationDecision({
+ rawUserMessage: "какой прогноз оплаты ндс за 12 мая 2020",
+ effectiveAddressUserMessage: "какой прогноз оплаты ндс за 12 мая 2020",
+ followupContext: null,
+ llmPreDecomposeMeta: null,
+ useMock: false
+ });
+
+ expect(decision.runAddressLane).toBe(true);
+ expect(decision.toolGateDecision).toBe("run_address_lane");
+ expect(decision.toolGateReason).toBe("address_mode_classifier_detected");
+ expect(decision.livingMode).toBe("address_data");
+ expect(decision.livingReason).toBe("address_lane_triggered");
+ expect(decision.orchestrationContract?.unsupported_address_intent_fallback_to_deep).toBe(false);
+ });
+
+ it("keeps supported contract analytics query in address lane", () => {
+ const decision = resolveAssistantOrchestrationDecision({
+ rawUserMessage: "по альтернативе вопрос. какой самый жирный контракт за всю историю у нее?",
+ effectiveAddressUserMessage: "по альтернативе вопрос. какой самый жирный контракт за всю историю у нее?",
+ followupContext: null,
+ llmPreDecomposeMeta: null,
+ useMock: false
+ });
+
+ expect(decision.runAddressLane).toBe(true);
+ expect(decision.toolGateDecision).toBe("run_address_lane");
+ expect(decision.toolGateReason).toBe("address_mode_classifier_detected");
+ expect(decision.livingMode).toBe("address_data");
+ expect(decision.livingReason).toBe("address_lane_triggered");
+ });
+
+ it("keeps VAT explain follow-up in address lane when followup context is present", () => {
+ const decision = resolveAssistantOrchestrationDecision({
+ rawUserMessage: "почему прогноз к уплате 0?",
+ effectiveAddressUserMessage: "почему прогноз к уплате 0?",
+ followupContext: {
+ previous_intent: "vat_payable_forecast",
+ previous_filters: {
+ period_from: "2020-03-01",
+ period_to: "2020-03-31"
+ },
+ previous_anchor_type: "unknown",
+ previous_anchor_value: null
+ },
+ llmPreDecomposeMeta: null,
+ useMock: false
+ });
+
+ expect(decision.runAddressLane).toBe(true);
+ expect(decision.toolGateDecision).toBe("run_address_lane");
+ expect(decision.toolGateReason).toBe("followup_context_detected");
+ expect(decision.livingMode).toBe("address_data");
+ expect(decision.livingReason).toBe("address_lane_triggered");
+ });
+
+ it("does not force address lane for deep-analysis unknown intent query with date-like token", () => {
+ const decision = resolveAssistantOrchestrationDecision({
+ rawUserMessage: "найди какие либо ошибки на 21 мая 2022 года",
+ effectiveAddressUserMessage: "Найти ошибки в бухгалтерии за 21 мая 2022 года.",
+ followupContext: null,
+ llmPreDecomposeMeta: {
+ applied: true,
+ llmCanonicalCandidateDetected: true,
+ predecomposeContract: {
+ mode: "deep_analysis",
+ mode_confidence: "high",
+ intent: "unknown",
+ intent_confidence: "low"
+ }
+ } as any,
+ useMock: false
+ });
+
+ expect(decision.runAddressLane).toBe(false);
+ expect(decision.toolGateDecision).toBe("skip_address_lane");
+ expect(decision.livingMode).toBe("deep_analysis");
+ expect(["address_signal_unsupported_intent_fallback_to_deep", "no_address_signal_after_l0"]).toContain(
+ decision.toolGateReason
+ );
+ });
+});
diff --git a/llm_normalizer/backend/tests/assistantOutgoingEncodingRepair.test.ts b/llm_normalizer/backend/tests/assistantOutgoingEncodingRepair.test.ts
new file mode 100644
index 0000000..658820b
--- /dev/null
+++ b/llm_normalizer/backend/tests/assistantOutgoingEncodingRepair.test.ts
@@ -0,0 +1,140 @@
+import { describe, expect, it, vi } from "vitest";
+import { AssistantService } from "../src/services/assistantService";
+import { AssistantSessionStore } from "../src/services/assistantSessionStore";
+
+function buildFailedNormalizer(traceId: string) {
+ return {
+ normalize: vi.fn().mockResolvedValue({
+ ok: false,
+ trace_id: traceId,
+ prompt_version: "normalizer_v2_0_2",
+ schema_version: "v2_0_2",
+ normalized: null,
+ validation: { passed: false, errors: ["mock"] },
+ route_hint_summary: null,
+ raw_model_output: {},
+ usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 },
+ latency_ms: 1,
+ request_count_for_case: 1
+ })
+ } as any;
+}
+
+function buildHandledAddressLaneMojibakeReply() {
+ return {
+ handled: true,
+ reply_text: "Найдено документов: 5.",
+ reply_type: "factual",
+ response_type: "FACTUAL_LIST",
+ debug: {
+ detected_mode: "address_query",
+ detected_mode_confidence: "high",
+ query_shape: "DOCUMENT_LIST",
+ query_shape_confidence: "high",
+ detected_intent: "list_documents_by_counterparty",
+ detected_intent_confidence: "high",
+ extracted_filters: {
+ sort: "period_desc",
+ limit: 20,
+ counterparty: "svk"
+ },
+ 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: "svk",
+ anchor_value_resolved: "СВК",
+ resolver_confidence: "high",
+ ambiguity_count: 0,
+ match_failure_stage: "none",
+ match_failure_reason: null,
+ mcp_call_status: "matched_non_empty",
+ rows_fetched: 5,
+ raw_rows_received: 5,
+ rows_after_account_scope: 5,
+ rows_after_recipe_filter: 5,
+ rows_materialized: 5,
+ rows_matched: 5,
+ 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",
+ reasons: ["address_action_detected", "address_entity_detected", "document_list_signal_detected"]
+ }
+ } as any;
+}
+
+describe("assistant outgoing encoding repair", () => {
+ it("repairs mojibake in address-lane replies before returning to user", async () => {
+ const normalizer = buildFailedNormalizer("norm-address-mojibake-out");
+ const sessions = new AssistantSessionStore();
+ const addressQueryService = {
+ tryHandle: vi.fn().mockResolvedValue(buildHandledAddressLaneMojibakeReply())
+ } as any;
+ const chatClient = {
+ chat: vi.fn().mockResolvedValue({
+ raw: { id: "chat-should-not-run" },
+ outputText: "unused",
+ usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 }
+ })
+ } as any;
+
+ const service = new AssistantService(normalizer as any, sessions, undefined as any, undefined as any, addressQueryService, chatClient);
+
+ const response = await service.handleMessage({
+ session_id: "asst-address-mojibake-out",
+ user_message: "покажи документы по свк за 2020",
+ llmProvider: "local",
+ model: "qwen2.5",
+ useMock: false
+ } as any);
+
+ expect(response.ok).toBe(true);
+ expect(response.reply_type).toBe("factual");
+ expect(String(response.assistant_reply)).toContain("Найдено документов");
+ expect(String(response.assistant_reply)).not.toContain("РќР°");
+ expect(addressQueryService.tryHandle).toHaveBeenCalledTimes(1);
+ expect(chatClient.chat).toHaveBeenCalledTimes(0);
+ });
+
+ it("repairs mojibake in living-chat LLM replies before script guard", async () => {
+ const normalizer = buildFailedNormalizer("norm-chat-mojibake-out");
+ const sessions = new AssistantSessionStore();
+ const addressQueryService = {
+ tryHandle: vi.fn().mockResolvedValue({ handled: false })
+ } as any;
+ const chatClient = {
+ chat: vi.fn().mockResolvedValue({
+ raw: { id: "chat-mojibake-out" },
+ outputText: "Привет! Готов помочь.",
+ usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 }
+ })
+ } as any;
+
+ const service = new AssistantService(normalizer as any, sessions, undefined as any, undefined as any, addressQueryService, chatClient);
+
+ const response = await service.handleMessage({
+ session_id: "asst-chat-mojibake-out",
+ user_message: "че как",
+ llmProvider: "local",
+ model: "qwen2.5",
+ useMock: false
+ } as any);
+
+ expect(response.ok).toBe(true);
+ expect(response.reply_type).toBe("factual_with_explanation");
+ expect(String(response.assistant_reply)).toContain("Привет");
+ expect(String(response.assistant_reply)).not.toContain("РџС");
+ expect(chatClient.chat).toHaveBeenCalledTimes(1);
+ expect(addressQueryService.tryHandle).toHaveBeenCalledTimes(0);
+ });
+});
+
diff --git a/llm_normalizer/start-dev-mcp.cmd b/llm_normalizer/start-dev-mcp.cmd
index c529e33..42ac3fa 100644
--- a/llm_normalizer/start-dev-mcp.cmd
+++ b/llm_normalizer/start-dev-mcp.cmd
@@ -1,6 +1,23 @@
@echo off
+setlocal
cd /d %~dp0
set FEATURE_ASSISTANT_MCP_RUNTIME_V1=1
set ASSISTANT_MCP_PROXY_URL=http://127.0.0.1:6003
set ASSISTANT_MCP_CHANNEL=default
-call npm.cmd run dev:all
+
+set "MCP_PROXY_ROOT=%~dp0..\external\1c-mcp-toolkit"
+set "MCP_PROXY_PY=%MCP_PROXY_ROOT%\.venv\Scripts\python.exe"
+
+if not exist "%MCP_PROXY_ROOT%" (
+ echo [WARN] MCP proxy root not found: %MCP_PROXY_ROOT%
+) else (
+ if not exist "%MCP_PROXY_PY%" (
+ echo [WARN] MCP proxy python not found: %MCP_PROXY_PY%
+ echo [WARN] Start proxy manually: cd /d "%MCP_PROXY_ROOT%" ^&^& python -m onec_mcp_toolkit_proxy
+ ) else (
+ echo [INFO] Starting 1C MCP proxy on http://127.0.0.1:6003 ...
+ start "1C MCP Proxy 6003" cmd /k "cd /d ""%MCP_PROXY_ROOT%"" && set PYTHONUTF8=1 && ""%MCP_PROXY_PY%"" -m onec_mcp_toolkit_proxy"
+ )
+)
+
+call npm.cmd run dev:all:mcp