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