Compare commits

...

10 Commits

69 changed files with 3391 additions and 642 deletions

View File

@ -0,0 +1,138 @@
{
"schema_version": "domain_truth_harness_spec_v1",
"scenario_id": "assistant_stage1_hm_vat_period_oldstock_regression_20260525",
"domain": "assistant_stage1_hm_vat_period_oldstock_regression",
"title": "GUI Hm VAT explicit period and factual old-stock replay",
"description": "Focused replay for assistant-stage1-HmCqi80Vkb: explicit VAT month questions after an inventory purchase-date bridge must not reuse the old purchase date, and old-stock aging must be grounded in factual positive stock rather than earliest historical purchases.",
"bindings": {
"organization": "ООО \"Альтернатива Плюс\"",
"focus_item": "Рабочая станция универсального специалиста (индивидуальное изготовление)"
},
"steps": [
{
"step_id": "s01_current_stock_for_org",
"title": "Current inventory snapshot for organization",
"question": "Какие остатки на складе у ООО \"Альтернатива Плюс\"?",
"allowed_reply_types": ["factual"],
"expected_intents": ["inventory_on_hand_as_of_date"],
"expected_recipe": "address_inventory_on_hand_as_of_date_v1",
"required_direct_answer_patterns_any": ["остат", "склад"],
"forbidden_answer_patterns": ["(?m)^Коротко:", "(?i)capability_id", "(?i)mcp_discovery"],
"criticality": "critical",
"semantic_tags": ["inventory", "organization_scope"]
},
{
"step_id": "s02_historical_stock_march_2016",
"title": "Historical inventory snapshot from Hm run",
"question": "март 2016",
"allowed_reply_types": ["factual"],
"expected_intents": ["inventory_on_hand_as_of_date"],
"expected_recipe": "address_inventory_on_hand_as_of_date_v1",
"required_filters": {
"period_from": "2016-03-01",
"period_to": "2016-03-31",
"as_of_date": "2016-03-31"
},
"required_direct_answer_patterns_any": ["31\\.03\\.2016", "Рабочая станция"],
"forbidden_answer_patterns": ["(?m)^Коротко:", "(?i)capability_id", "(?i)mcp_discovery"],
"criticality": "critical",
"semantic_tags": ["inventory", "historical_snapshot", "date_carryover"]
},
{
"step_id": "s03_selected_item_purchase_provenance",
"title": "Selected workstation purchase provenance",
"question": "По выбранному объекту \"Рабочая станция универсального специалиста (индивидуальное изготовление)\": где взяли это?",
"allowed_reply_types": ["factual", "partial_coverage"],
"expected_intents": ["inventory_purchase_provenance_for_item"],
"expected_recipe": "address_inventory_purchase_provenance_for_item_v1",
"required_filters": {
"item": "Рабочая станция универсального специалиста (индивидуальное изготовление)",
"as_of_date": "2016-03-31"
},
"required_direct_answer_patterns_any": ["Рабочая станция", "поставщик|закуп"],
"forbidden_answer_patterns": ["(?m)^Коротко:", "(?i)capability_id", "(?i)mcp_discovery", "контрагент:\\s*Рабочая станция универсального специалиста"],
"criticality": "critical",
"semantic_tags": ["inventory", "selected_object", "purchase_provenance"]
},
{
"step_id": "s04_selected_item_sale_trace",
"title": "Selected workstation sale trace follow-up",
"question": "а кому продали?",
"allowed_reply_types": ["factual", "partial_coverage"],
"expected_intents": ["inventory_sale_trace_for_item"],
"expected_recipe": "address_inventory_sale_trace_for_item_v1",
"required_direct_answer_patterns_any": ["Комитет государственных услуг", "без партионного учета"],
"forbidden_answer_patterns": ["(?m)^Коротко:", "(?i)capability_id", "(?i)mcp_discovery", "контрагент:\\s*Рабочая станция универсального специалиста"],
"criticality": "critical",
"semantic_tags": ["inventory", "selected_object", "sale_trace"]
},
{
"step_id": "s05_item_field_truth_meta",
"title": "Item versus counterparty field-truth meta check",
"question": "у тебя написано кто контрагент: рабочая станция - это ошибка?",
"allowed_reply_types": ["chat", "factual", "factual_with_explanation", "partial_coverage"],
"required_direct_answer_patterns_any": ["товар", "не контрагент"],
"forbidden_answer_patterns": ["(?m)^Коротко:", "(?i)capability_id", "(?i)mcp_discovery"],
"criticality": "warning",
"semantic_tags": ["field_truth", "item_vs_counterparty"]
},
{
"step_id": "s06_vat_purchase_date_bridge",
"title": "VAT by selected item purchase date bridge",
"question": "ндс можешь прикинуть на дату покупки рабочей станции?",
"allowed_reply_types": ["factual", "partial_coverage"],
"expected_intents": ["vat_liability_confirmed_for_tax_period"],
"expected_recipe": "address_vat_liability_confirmed_tax_period_v1",
"required_direct_answer_patterns_any": ["НДС", "05\\.02\\.2015", "дата закупки|дата покупки"],
"forbidden_answer_patterns": ["(?m)^Коротко:", "(?i)capability_id", "(?i)mcp_discovery"],
"criticality": "critical",
"semantic_tags": ["vat", "purchase_date_bridge", "temporal_honesty"]
},
{
"step_id": "s07_vat_explicit_march_2020",
"title": "Explicit March 2020 VAT must override purchase-date bridge",
"question": "а какой ндс мы должны сгрузить на март 2020?",
"allowed_reply_types": ["factual", "partial_coverage"],
"expected_intents": ["vat_liability_confirmed_for_tax_period"],
"expected_recipe": "address_vat_liability_confirmed_tax_period_v1",
"required_direct_answer_patterns_any": ["НДС", "2020"],
"forbidden_answer_patterns": ["(?m)^Коротко:", "05\\.02\\.2015", "дата закупки", "дата покупки", "(?i)capability_id", "(?i)mcp_discovery"],
"forbidden_filter_values": {
"period_from": ["2015-01-01", "2015-02-01"],
"period_to": ["2015-03-31", "2015-02-28"],
"purchase_date_bridge_selected": ["2015-02-05"]
},
"criticality": "critical",
"semantic_tags": ["vat", "explicit_period_override", "temporal_honesty"]
},
{
"step_id": "s08_vat_explicit_february_2017",
"title": "Explicit February 2017 VAT must override purchase-date bridge",
"question": "прикинь какой ндс нам надо заплатить на февраль 2017",
"allowed_reply_types": ["factual", "partial_coverage"],
"expected_intents": ["vat_liability_confirmed_for_tax_period"],
"expected_recipe": "address_vat_liability_confirmed_tax_period_v1",
"required_direct_answer_patterns_any": ["НДС", "2017"],
"forbidden_answer_patterns": ["(?m)^Коротко:", "05\\.02\\.2015", "дата закупки", "дата покупки", "(?i)capability_id", "(?i)mcp_discovery"],
"forbidden_filter_values": {
"period_from": ["2015-01-01", "2015-02-01"],
"period_to": ["2015-03-31", "2015-02-28"],
"purchase_date_bridge_selected": ["2015-02-05"]
},
"criticality": "critical",
"semantic_tags": ["vat", "explicit_period_override", "temporal_honesty"]
},
{
"step_id": "s09_old_stock_factual_positive_stock",
"title": "Old stock aging must be based on factual positive stock",
"question": "Есть ли у ООО \"Альтернатива Плюс\" остатки товара, которые закупались очень давно",
"allowed_reply_types": ["factual", "partial_coverage"],
"expected_intents": ["inventory_aging_by_purchase_date"],
"expected_recipe": "address_inventory_aging_by_purchase_date_v1",
"required_direct_answer_patterns_any": ["положительн", "остат", "давно закуп"],
"forbidden_answer_patterns": ["(?m)^Коротко:", "Позиций в выборке:\\s*390", "(?i)capability_id", "(?i)mcp_discovery"],
"criticality": "critical",
"semantic_tags": ["inventory", "old_stock", "positive_stock_basis"]
}
]
}

View File

@ -0,0 +1,135 @@
{
"schema_version": "domain_scenario_pack_v1",
"pack_id": "assistant_stage1_y9_inventory_selected_object_regression_20260525",
"domain": "inventory_selected_object_reliability",
"title": "GUI Y9 inventory selected-object regression proof",
"description": "Focused replay for assistant-stage1-Y9qmtnz66r: inventory snapshot, selected item provenance, sale trace, field-truth meta question, VAT carryover from ambiguous purchase dates, and old-stock aging.",
"analysis_context": {
"as_of_date": "2026-05-25",
"source": "assistant-stage1-Y9qmtnz66r"
},
"bindings": {
"organization_alias": "АЛЬТЕРНАТИВА",
"organization": "ООО \"Альтернатива Плюс\"",
"historical_month": "март 2016",
"historical_as_of_date": "2016-03-31",
"focus_item": "Рабочая станция универсального специалиста (индивидуальное изготовление)",
"known_buyer": "Комитет государственных услуг г. Москвы"
},
"scenarios": [
{
"scenario_id": "inventory_selected_item_provenance_chain",
"title": "Inventory selected item provenance chain from GUI run Y9",
"description": "Validates that the assistant keeps organization/date/item carryover from a historical stock snapshot and answers selected-object provenance without confusing item, counterparty, lot trace, and purchase-date VAT semantics.",
"steps": [
{
"step_id": "s01_stock_now_for_org",
"title": "Current stock for explicit organization",
"node_role": "root",
"question": "Какие остатки на складе у {{bindings.organization}}?",
"semantic_tags": ["inventory", "organization_scope"],
"required_answer_shape": "direct_answer_first",
"required_answer_patterns_all": ["остат", "Альтернатива"],
"forbidden_answer_patterns": ["(?i)capability_id", "(?i)runtime_", "(?i)mcp_discovery"]
},
{
"step_id": "s02_historical_stock_march_2016",
"title": "Historical stock snapshot",
"question": "остатки на {{bindings.historical_month}}",
"depends_on": ["s01_stock_now_for_org"],
"semantic_tags": ["inventory", "date_carryover", "historical_snapshot"],
"required_answer_shape": "direct_answer_first",
"required_answer_patterns_all": ["31.03.2016", "Рабочая станция"],
"forbidden_answer_patterns": ["(?i)capability_id", "(?i)runtime_", "(?i)mcp_discovery"]
},
{
"step_id": "s03_selected_item_purchase_provenance",
"title": "Selected item purchase provenance",
"question": "По выбранному объекту \"{{bindings.focus_item}}\": где взяли это?",
"depends_on": ["s02_historical_stock_march_2016"],
"semantic_tags": ["inventory", "selected_object", "purchase_provenance", "field_truth"],
"required_answer_shape": "direct_answer_first",
"required_answer_patterns_all": ["Рабочая станция", "Поставщики", "05.02.2015"],
"forbidden_answer_patterns": ["(?i)capability_id", "(?i)runtime_", "(?i)mcp_discovery", "контрагент:\\s*{{bindings.focus_item}}"]
},
{
"step_id": "s04_selected_item_sale_trace",
"title": "Selected item sale trace follow-up",
"question": "а кому продали?",
"depends_on": ["s03_selected_item_purchase_provenance"],
"semantic_tags": ["inventory", "selected_object", "sale_trace", "pronoun_followup"],
"required_answer_shape": "direct_answer_first",
"required_answer_patterns_all": ["Рабочая станция", "Комитет государственных услуг", "без партионного учета"],
"forbidden_answer_patterns": ["(?i)capability_id", "(?i)runtime_", "(?i)mcp_discovery", "контрагент:\\s*{{bindings.focus_item}}"]
},
{
"step_id": "s05_item_counterparty_field_truth_meta",
"title": "Field-truth correction for item versus counterparty",
"question": "у тебя написано кто контрагент: рабочая станция - это ошибка?",
"depends_on": ["s04_selected_item_sale_trace"],
"semantic_tags": ["field_truth", "item_vs_counterparty", "answer_inspection"],
"required_answer_shape": "direct_answer_first",
"required_answer_patterns_all": ["товар", "не контрагент"],
"forbidden_answer_patterns": ["(?i)capability_id", "(?i)runtime_", "(?i)mcp_discovery"]
},
{
"step_id": "s06_vat_by_ambiguous_purchase_date",
"title": "VAT period from ambiguous purchase date must disclose basis",
"question": "ндс можешь прикинуть на дату покупки рабочей станции?",
"depends_on": ["s03_selected_item_purchase_provenance", "s05_item_counterparty_field_truth_meta"],
"semantic_tags": ["vat", "date_carryover", "purchase_date_carryover", "field_truth", "temporal_honesty"],
"required_answer_shape": "direct_answer_first",
"required_answer_patterns_all": ["НДС", "первую подтвержденную дату закупки", "05.02.2015", "несколько подтвержденных дат закупки"],
"forbidden_answer_patterns": ["(?i)capability_id", "(?i)runtime_", "(?i)mcp_discovery", "это единственная возможная дата"]
},
{
"step_id": "s07_vat_first_confirmed_purchase",
"title": "VAT recalculation by first confirmed purchase date",
"question": "если у нее несколько закупок, посчитай по первой подтвержденной покупке",
"depends_on": ["s06_vat_by_ambiguous_purchase_date"],
"semantic_tags": ["vat", "purchase_date_carryover", "first_purchase"],
"required_answer_shape": "direct_answer_first",
"required_answer_patterns_all": ["НДС", "первую подтвержденную дату закупки", "05.02.2015", "01.02.2015", "28.02.2015"],
"forbidden_answer_patterns": ["(?i)capability_id", "(?i)runtime_", "(?i)mcp_discovery"]
},
{
"step_id": "s08_vat_last_confirmed_purchase",
"title": "VAT recalculation by last confirmed purchase date",
"question": "а теперь по последней подтвержденной покупке",
"depends_on": ["s07_vat_first_confirmed_purchase"],
"semantic_tags": ["vat", "purchase_date_carryover", "last_purchase"],
"required_answer_shape": "direct_answer_first",
"required_answer_patterns_all": ["НДС", "последнюю подтвержденную дату закупки", "22.07.2015", "01.07.2015", "31.07.2015"],
"forbidden_answer_patterns": ["(?i)capability_id", "(?i)runtime_", "(?i)mcp_discovery"]
}
]
},
{
"scenario_id": "inventory_old_stock_aging_from_y9",
"title": "Old stock aging question from GUI run Y9",
"description": "Validates old-stock answer shape, date basis, and supplier/item field truth for broad aging question.",
"steps": [
{
"step_id": "s01_old_stock_aging_for_org",
"title": "Old stock aging answer for explicit organization",
"node_role": "root",
"question": "Есть ли у {{bindings.organization}} остатки товара, которые закупались очень давно",
"semantic_tags": ["inventory", "aging", "supplier_provenance", "field_truth"],
"required_answer_shape": "direct_answer_first",
"required_answer_patterns_all": ["самым старым закупкам", "05.11.2014", "Альтернатива"],
"forbidden_answer_patterns": ["(?i)capability_id", "(?i)runtime_", "(?i)mcp_discovery", "контрагент:\\s*Стенд информационный"]
},
{
"step_id": "s02_old_stock_same_date_followup",
"title": "Old stock same-date follow-up should keep inventory date basis",
"question": "какие остатки по товарам на эту дату относятся к старым закупкам?",
"depends_on": ["s01_old_stock_aging_for_org"],
"semantic_tags": ["inventory", "aging", "followup_date_carryover"],
"required_answer_shape": "direct_answer_first",
"required_answer_patterns_all": ["самым старым закупкам", "05.11.2014", "Альтернатива"],
"forbidden_answer_patterns": ["(?i)capability_id", "(?i)runtime_", "(?i)mcp_discovery"]
}
]
}
]
}

View File

@ -0,0 +1,83 @@
{
"schema_version": "domain_truth_harness_spec_v1",
"scenario_id": "hm_business_overview_cashflow_limits_20260525",
"domain": "assistant_stage1_business_overview",
"title": "Hm business overview cashflow limit honesty",
"description": "Focused live replay for the Hm business overview chain: all-time earnings wording must not present limited cashflow probes as confirmed all-time accounting profit.",
"bindings": {},
"steps": [
{
"step_id": "s01_top_year",
"title": "Top revenue year",
"question": "\u043a\u0430\u043a\u043e\u0439 \u0443 \u041e\u041e\u041e \"\u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441\" \u0441\u0430\u043c\u044b\u0439 \u0434\u043e\u0445\u043e\u0434\u043d\u044b\u0439 \u0433\u043e\u0434",
"allowed_reply_types": [
"factual",
"factual_with_explanation",
"partial_coverage"
],
"required_direct_answer_patterns_any": [
"2015",
"\u0432\u0445\u043e\u0434\u044f\u0449"
],
"forbidden_answer_patterns": [
"\u041a\u043e\u0440\u043e\u0442\u043a\u043e:",
"capability_id",
"mcp_discovery"
],
"criticality": "critical",
"semantic_tags": [
"business_overview",
"yearly_ranking"
]
},
{
"step_id": "s02_earnings_2017",
"title": "2017 accounting earnings",
"question": "\u0430 \u0437\u0430 2017 \u043c\u044b \u0441\u043a\u043e\u043a \u0437\u0430\u0440\u0430\u0431\u043e\u0442\u0430\u043b\u0438?",
"allowed_reply_types": [
"factual",
"factual_with_explanation"
],
"required_direct_answer_patterns_any": [
"\u0443\u0447\u0435\u0442\u043d\u0430\u044f \u043f\u0440\u0438\u0431\u044b\u043b\u044c",
"116 938,23"
],
"forbidden_answer_patterns": [
"\u041a\u043e\u0440\u043e\u0442\u043a\u043e:",
"capability_id",
"mcp_discovery"
],
"criticality": "critical",
"semantic_tags": [
"business_overview",
"accounting_result"
]
},
{
"step_id": "s03_all_time_earnings_limit",
"title": "All-time earnings must disclose limited cashflow coverage",
"question": "\u0441\u043a\u043e\u043b\u044c\u043a\u043e \u0432\u043e\u043e\u0431\u0449\u0435 \u0434\u0435\u043d\u0435\u0433 \u043c\u044b \u0437\u0430\u0440\u0430\u0431\u043e\u0442\u0430\u043b\u0438 \u0437\u0430 \u0432\u0441\u0435 \u0432\u0440\u0435\u043c\u044f?",
"allowed_reply_types": [
"factual",
"factual_with_explanation"
],
"required_answer_patterns_all": [
"\u0442\u043e\u0447\u043d\u0443\u044e \u0443\u0447\u0435\u0442\u043d\u0443\u044e \u043f\u0440\u0438\u0431\u044b\u043b\u044c",
"\u043d\u0435 \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0430\u044e",
"\u043e\u0433\u0440\u0430\u043d\u0438\u0447\u0435\u043d\u043d",
"\u043d\u0435 \u0441\u0447\u0438\u0442\u0430\u044e \u043f\u043e\u043b\u043d\u044b\u043c \u0431\u0443\u0445\u0433\u0430\u043b\u0442\u0435\u0440\u0441\u043a\u0438\u043c \u043e\u0431\u043e\u0440\u043e\u0442\u043e\u043c"
],
"forbidden_answer_patterns": [
"\u041a\u043e\u0440\u043e\u0442\u043a\u043e:",
"capability_id",
"mcp_discovery"
],
"criticality": "critical",
"semantic_tags": [
"business_overview",
"coverage_honesty",
"all_time_cashflow"
]
}
]
}

View File

@ -0,0 +1,186 @@
{
"schema_version": "domain_truth_harness_spec_v1",
"scenario_id": "hm_business_profile_dependency_mini_pack_20260526",
"domain": "assistant_stage1_business_overview",
"title": "Hm business profile dependency mini pack",
"description": "Mini semantic replay for broad HM business profile: company evaluation, profit boundary, customer/supplier dependency signals, and next checks must stay business-useful, evidence-bounded, and free of internal MCP mechanics.",
"bindings": {},
"steps": [
{
"step_id": "s01_establish_company_top_year",
"title": "Establish company context with a top-year question",
"question": "какой у ООО \"Альтернатива Плюс\" самый доходный год",
"allowed_reply_types": [
"factual",
"factual_with_explanation",
"partial_coverage"
],
"required_answer_patterns_all": [
"2015",
"136 723 459,73",
"не полный бухгалтерский рейтинг"
],
"forbidden_answer_patterns": [
"Коротко:",
"capability_id",
"mcp_discovery",
"query_movements"
],
"criticality": "critical",
"semantic_tags": [
"business_overview",
"company_context",
"yearly_ranking"
]
},
{
"step_id": "s02_broad_company_evaluation",
"title": "Broad company evaluation keeps the evidence limit in the first layer",
"question": "Как ты оценишь деятельность компании?",
"allowed_reply_types": [
"factual_with_explanation",
"partial_coverage"
],
"required_answer_patterns_all": [
"ограниченн",
"не аудиторское",
"не подтвержден",
"чист",
"прибыл",
"Комитет государственных услуг",
"зависим"
],
"forbidden_answer_patterns": [
"Коротко:",
"capability_id",
"mcp_discovery",
"query_movements",
"business_overview_route_template_v1"
],
"criticality": "critical",
"semantic_tags": [
"business_overview",
"broad_business_evaluation",
"coverage_honesty",
"profit_boundary"
]
},
{
"step_id": "s03_profitability_boundary",
"title": "Profitability follow-up does not convert cashflow net into profit",
"question": "то есть бизнес прибыльный или это только обороты?",
"allowed_reply_types": [
"factual_with_explanation",
"partial_coverage"
],
"required_answer_patterns_all": [
"не подтверждаю",
"чист",
"прибыл",
"денежн",
"нетто",
"не полный отчет"
],
"forbidden_answer_patterns": [
"Коротко:",
"capability_id",
"mcp_discovery",
"это прибыль"
],
"criticality": "critical",
"semantic_tags": [
"business_overview",
"profitability_boundary",
"cashflow_not_profit"
]
},
{
"step_id": "s04_customer_dependency_signal",
"title": "Customer dependency signal stays bounded to incoming money evidence",
"question": "от кого мы больше всего зависим по входящим деньгам?",
"allowed_reply_types": [
"factual",
"factual_with_explanation",
"partial_coverage"
],
"required_answer_patterns_all": [
"Комитет государственных услуг",
"133.839.880,82",
"концентрац",
"входящ",
"не чистая прибыль"
],
"forbidden_answer_patterns": [
"Коротко:",
"capability_id",
"mcp_discovery",
"query_movements"
],
"criticality": "critical",
"semantic_tags": [
"business_overview",
"customer_dependency",
"incoming_counterparty_concentration"
]
},
{
"step_id": "s05_supplier_dependency_signal",
"title": "Supplier dependency signal stays bounded to outgoing money evidence",
"question": "а по поставщикам и исходящим платежам от кого зависимость?",
"allowed_reply_types": [
"factual",
"factual_with_explanation",
"partial_coverage"
],
"required_answer_patterns_all": [
"ГТК-Интер",
"44 943 460",
"32.58",
"исходящ",
"не аудит"
],
"forbidden_answer_patterns": [
"Коротко:",
"capability_id",
"mcp_discovery",
"query_movements"
],
"criticality": "critical",
"semantic_tags": [
"business_overview",
"supplier_dependency",
"outgoing_counterparty_concentration"
]
},
{
"step_id": "s06_next_checks",
"title": "Next-check answer gives concrete analyst path instead of vague advice",
"question": "что дальше проверять в первую очередь?",
"allowed_reply_types": [
"factual_with_explanation",
"partial_coverage"
],
"required_answer_patterns_all": [
"чист",
"прибыл",
"90/91/99",
"топ-3",
"склад",
"долг"
],
"forbidden_answer_patterns": [
"Коротко:",
"capability_id",
"mcp_discovery",
"query_movements",
"business_overview_route_template_v1"
],
"criticality": "critical",
"semantic_tags": [
"business_overview",
"next_checks",
"analyst_action_plan"
]
}
]
}

View File

@ -0,0 +1,96 @@
{
"schema_version": "domain_truth_harness_spec_v1",
"scenario_id": "hm_business_profile_evaluation_limits_20260525",
"domain": "assistant_stage1_business_overview",
"title": "Hm business profile evaluation coverage honesty",
"description": "Focused live replay for broad company evaluation: the assistant must give a useful business reading while clearly presenting cashflow probes as a limited checked 1C slice, not a full audit, clean profit, or complete accounting turnover.",
"bindings": {},
"steps": [
{
"step_id": "s01_top_year_establish_company",
"title": "Establish company context through top-year question",
"question": "какой у ООО \"Альтернатива Плюс\" самый доходный год",
"allowed_reply_types": [
"factual",
"factual_with_explanation",
"partial_coverage"
],
"required_direct_answer_patterns_any": [
"2015",
"входящ"
],
"forbidden_answer_patterns": [
"Коротко:",
"capability_id",
"mcp_discovery"
],
"criticality": "critical",
"semantic_tags": [
"business_overview",
"company_context",
"yearly_ranking"
]
},
{
"step_id": "s02_broad_company_evaluation",
"title": "Broad company evaluation must lead with limited-evidence framing",
"question": "Как ты оценишь деятельность компании?",
"allowed_reply_types": [
"factual_with_explanation",
"partial_coverage"
],
"required_answer_patterns_all": [
"ограниченн",
"денежн",
"не аудиторское",
"не подтвержден",
"чист",
"прибыл",
"Комитет государственных услуг",
"зависим"
],
"forbidden_answer_patterns": [
"Коротко:",
"capability_id",
"mcp_discovery",
"query_movements",
"business_overview_route_template_v1"
],
"criticality": "critical",
"semantic_tags": [
"business_overview",
"broad_business_evaluation",
"coverage_honesty",
"profit_boundary"
]
},
{
"step_id": "s03_profitability_boundary",
"title": "Profitability follow-up must not convert net cashflow into profit",
"question": "то есть бизнес прибыльный или это только обороты?",
"allowed_reply_types": [
"factual_with_explanation",
"partial_coverage"
],
"required_answer_patterns_all": [
"не подтверждаю",
"прибыл",
"денежн",
"нетто",
"не полн"
],
"forbidden_answer_patterns": [
"Коротко:",
"capability_id",
"mcp_discovery",
"это прибыль"
],
"criticality": "critical",
"semantic_tags": [
"business_overview",
"profitability_boundary",
"cashflow_not_profit"
]
}
]
}

View File

@ -2302,91 +2302,7 @@ function buildAutoRunsRouter(services, openaiClient = new openaiResponsesClient_
});
router.post("/api/autoruns/autogen/generate", async (req, res, next) => {
try {
const body = toRecord(req.body);
if (!body) {
throw new http_1.ApiError("INVALID_AUTOGEN_PAYLOAD", "JSON body is required", 400);
}
const mode = parseAutoGenMode(body.mode);
const count = parseAutogenCount(body.count);
const domain = parseAutogenDomain(body.domain);
const persistCaseSet = toBooleanSafe(body.persist_to_eval_cases) ?? true;
const generatedBy = parseAnnotationAuthor(body.generated_by);
const context = toRecord(body.context);
const llmConfig = parseAutogenLlmRuntimeConfig(body, context);
const personalityPrompt = toStringSafe(context?.autogen_personality_prompt);
if (mode === "saved_user_sessions") {
throw new http_1.ApiError("AUTOGEN_MODE_NOT_SUPPORTED", "Use `/api/autoruns/autogen/save-assistant-session` to save user sessions.", 400);
}
let questions = [];
if (mode === "qwen_seed") {
if (!llmConfig) {
throw new http_1.ApiError("AUTOGEN_LLM_CONFIG_REQUIRED", "Для режима qwen_seed нужен активный LLM-контур (provider/model/baseUrl) из настроек подключения.", 400);
}
questions = await generateQwenSeedQuestionsLive({
count,
domain,
personalityPrompt,
llmConfig,
client: openaiClient
});
}
else {
questions = generateCodexCreativeQuestions(count, domain);
}
questions = Array.from(new Set(questions.map((item) => sanitizeGeneratedQuestion(item)).filter((item) => item.length > 0))).slice(0, count);
const generationId = generateAutogenId();
let savedCaseSetFile = null;
if (persistCaseSet) {
if (!fs_1.default.existsSync(config_1.EVAL_CASES_DIR)) {
fs_1.default.mkdirSync(config_1.EVAL_CASES_DIR, { recursive: true });
}
const fileName = buildAutogenCaseSetFileName(mode, generationId);
const filePath = path_1.default.resolve(config_1.EVAL_CASES_DIR, fileName);
const payload = buildAutogenCaseSetPayload({
generationId,
mode,
domain,
questions
});
fs_1.default.writeFileSync(filePath, JSON.stringify(payload, null, 2), "utf-8");
savedCaseSetFile = fileName;
}
const record = {
generation_id: generationId,
created_at: new Date().toISOString(),
mode,
title: null,
count: questions.length,
domain,
questions,
generated_by: generatedBy,
saved_case_set_file: savedCaseSetFile,
context: context
? {
llm_provider: toStringSafe(context.llm_provider),
model: toStringSafe(context.model),
assistant_prompt_version: toStringSafe(context.assistant_prompt_version),
decomposition_prompt_version: toStringSafe(context.decomposition_prompt_version),
prompt_fingerprint: toStringSafe(context.prompt_fingerprint)
? repairAutogenMojibake(String(context.prompt_fingerprint))
: null,
autogen_personality_id: toStringSafe(context.autogen_personality_id),
autogen_personality_prompt: toStringSafe(context.autogen_personality_prompt)
? repairAutogenMojibake(String(context.autogen_personality_prompt))
: null,
source_session_id: null,
saved_session_file: null,
saved_case_set_kind: "single_turn_list"
}
: null
};
const history = readAutoGenHistory();
history.unshift(record);
writeAutoGenHistory(history.slice(0, 500));
(0, http_1.ok)(res, {
ok: true,
generation: record
});
throw new http_1.ApiError("AUTOGEN_QUESTION_GENERATION_DISABLED", "Автогенерация вопросов отключена: используйте сохранение живых пользовательских сессий.", 410);
}
catch (error) {
next(error);

View File

@ -1869,6 +1869,10 @@ function resolveUnicodeAddressIntentBridge(text) {
/(?:хвост|задержк|проблем|систематическ)/iu.test(normalized)) {
return unicodeBridgeResolution("list_payables_counterparties", "high", "supplier_tail_risk_signal_detected");
}
if (/(?:поставщик|закуп|исходящ|плат[её]ж|supplier|vendor|procurement|outgoing|payout)/iu.test(normalized) &&
/(?:зависим|завязан|концентрац|ключев|основн|критич|dependency|concentration|key|critical)/iu.test(normalized)) {
return unicodeBridgeResolution("unknown", "high", "unicode_business_overview_supplier_quality_deferred_to_discovery");
}
if (hasDocumentCue && (hasCounterpartyCue || hasLooseCounterpartyByAnchor) && !hasContractCue) {
return unicodeBridgeResolution("list_documents_by_counterparty", "high", "unicode_documents_by_counterparty_bridge_signal_detected");
}

View File

@ -211,6 +211,22 @@ function deriveTaxQuarterWindowForDate(value) {
period_to: `${year}-${String(quarterEndMonth).padStart(2, "0")}-${String(quarterEndDay).padStart(2, "0")}`
};
}
function isFullCalendarMonthWindow(periodFrom, periodTo) {
const fromIso = normalizeIsoDateForQuery(periodFrom);
const toIso = normalizeIsoDateForQuery(periodTo);
if (!fromIso || !toIso) {
return false;
}
const fromMatch = fromIso.match(/^(\d{4})-(\d{2})-(\d{2})$/);
const toMatch = toIso.match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (!fromMatch || !toMatch || fromMatch[1] !== toMatch[1] || fromMatch[2] !== toMatch[2] || fromMatch[3] !== "01") {
return false;
}
const year = Number(fromMatch[1]);
const month = Number(fromMatch[2]);
const lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate();
return toMatch[3] === String(lastDay).padStart(2, "0");
}
function deriveMonthWindowForDate(value) {
const isoDate = normalizeIsoDateForQuery(value);
if (!isoDate) {
@ -3003,8 +3019,18 @@ class AddressQueryService {
})
});
}
const vatTaxPeriodFromCarriedMonth = intent.intent === "vat_liability_confirmed_for_tax_period" &&
(filters.warnings.includes("period_from_from_followup_context") ||
filters.warnings.includes("period_to_from_followup_context")) &&
isFullCalendarMonthWindow(filters.extracted_filters.period_from, filters.extracted_filters.period_to);
const vatTaxPeriodFromPurchaseBridgeMonth = intent.intent === "vat_liability_confirmed_for_tax_period" &&
(filters.warnings.includes("period_from_purchase_date_bridge_followup_context") ||
filters.warnings.includes("period_to_purchase_date_bridge_followup_context")) &&
isFullCalendarMonthWindow(filters.extracted_filters.period_from, filters.extracted_filters.period_to);
if (intent.intent === "vat_liability_confirmed_for_tax_period" &&
filters.warnings.includes("period_derived_from_month_phrase")) {
(filters.warnings.includes("period_derived_from_month_phrase") ||
vatTaxPeriodFromCarriedMonth ||
vatTaxPeriodFromPurchaseBridgeMonth)) {
const taxQuarterWindow = deriveTaxQuarterWindowForDate(filters.extracted_filters.period_to);
if (taxQuarterWindow) {
filters.extracted_filters = {
@ -3176,8 +3202,14 @@ class AddressQueryService {
shadowRouteAudit
});
}
const rawUserMessageForReply = typeof options.rawUserMessage === "string" && options.rawUserMessage.trim().length > 0
? options.rawUserMessage
: undefined;
const composeOptionsFromFilters = (filterSet, options = {}) => ({
userMessage,
rawUserMessage: typeof options.rawUserMessage === "string" && options.rawUserMessage.trim().length > 0
? options.rawUserMessage
: rawUserMessageForReply,
itemHint: typeof filterSet.item === "string" ? filterSet.item : undefined,
counterpartyHint: typeof options.counterpartyHint === "string"
? options.counterpartyHint
@ -3195,6 +3227,17 @@ class AddressQueryService {
asOfDate: typeof filterSet.as_of_date === "string" ? filterSet.as_of_date : undefined,
requestedResultMode,
vatDirectSourceProbe: options.vatDirectSourceProbe ?? undefined,
purchaseDateBridge: typeof filterSet.purchase_date_bridge_selected === "string" ||
typeof filterSet.purchase_date_bridge_first === "string" ||
typeof filterSet.purchase_date_bridge_last === "string"
? {
selectedPurchaseDate: filterSet.purchase_date_bridge_selected,
firstPurchaseDate: filterSet.purchase_date_bridge_first,
lastPurchaseDate: filterSet.purchase_date_bridge_last,
basis: filterSet.purchase_date_bridge_basis,
hasMultiplePurchaseDates: filterSet.purchase_date_bridge_has_multiple === true
}
: undefined,
emphasizeNumbers: options.emphasizeNumbers ?? undefined,
useRubCurrency: options.useRubCurrency ?? undefined
});

View File

@ -1413,6 +1413,85 @@ function buildInventoryMovementQuery(filters, resolvedLimit, side) {
.replace("__WHERE_CLAUSE__", buildWhereClause(filters, "Движения.Период", [inventoryCondition, itemCondition].filter((item) => Boolean(item))))
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort));
}
function buildStaticWhereClause(conditions, indent = " ") {
const cleaned = conditions.map((condition) => String(condition ?? "").trim()).filter((condition) => condition.length > 0);
return cleaned.length > 0 ? `ГДЕ\n${indent}${cleaned.join(`\n${indent}И `)}` : "";
}
function resolveInventoryAgingAsOfExpr(filters) {
const now = new Date();
return ((typeof filters.as_of_date === "string" && filters.as_of_date.trim().length > 0
? toDateTimeExpr(filters.as_of_date, true)
: null) ??
(typeof filters.period_to === "string" && filters.period_to.trim().length > 0
? toDateTimeExpr(filters.period_to, true)
: null) ??
(typeof filters.period_from === "string" && filters.period_from.trim().length > 0
? toDateTimeExpr(filters.period_from, true)
: null) ??
`ДАТАВРЕМЯ(${now.getFullYear()}, ${now.getMonth() + 1}, ${now.getDate()}, 23, 59, 59)`);
}
function buildInventoryAgingByPurchaseDateQuery(filters, resolvedLimit) {
const asOfExpr = resolveInventoryAgingAsOfExpr(filters);
const inventoryAccountPredicate = buildAccountPrefixPredicate("Остатки.Счет", ["41.01"]);
const onHandScopeConditions = [
"Остатки.КоличествоРазвернутыйОстатокДт > 0",
inventoryAccountPredicate,
buildOrganizationReferenceCondition(filters, ["Остатки.Организация"]),
buildWarehouseReferenceCondition(filters, ["Остатки.Субконто3"]),
buildInventoryItemReferenceCondition(filters, ["Остатки.Субконто1"])
];
const onHandWhereClause = buildStaticWhereClause(onHandScopeConditions);
const currentStockItemSubquery = [
"(ВЫБРАТЬ РАЗЛИЧНЫЕ",
" Остатки.Субконто1",
" ИЗ",
` РегистрБухгалтерии.Хозрасчетный.Остатки(${asOfExpr}, , , ) КАК Остатки`,
buildStaticWhereClause(onHandScopeConditions, " "),
")"
].join("\n");
const purchaseWhereClause = buildStaticWhereClause([
"Товары.Ссылка.Проведен = ИСТИНА",
`Товары.Ссылка.Дата <= ${asOfExpr}`,
buildOrganizationReferenceCondition(filters, ["Товары.Ссылка.Организация"]),
buildInventoryItemReferenceCondition(filters, ["Товары.Номенклатура"]),
`Товары.Номенклатура В ${currentStockItemSubquery}`
]);
return `
ВЫБРАТЬ ПЕРВЫЕ ${resolvedLimit}
${asOfExpr} КАК Период,
"Остатки на дату" КАК Регистратор,
ПРЕДСТАВЛЕНИЕ(Остатки.Счет) КАК СчетДт,
"" КАК СчетКт,
Остатки.СуммаРазвернутыйОстатокДт КАК Сумма,
ПРЕДСТАВЛЕНИЕ(Остатки.Субконто1) КАК Номенклатура,
"" КАК Контрагент,
"" КАК Договор,
ПРЕДСТАВЛЕНИЕ(Остатки.Организация) КАК Организация,
ПРЕДСТАВЛЕНИЕ(Остатки.Субконто3) КАК Склад,
Остатки.КоличествоРазвернутыйОстатокДт КАК Количество
ИЗ
РегистрБухгалтерии.Хозрасчетный.Остатки(${asOfExpr}, , , ) КАК Остатки
${onHandWhereClause}
ОБЪЕДИНИТЬ ВСЕ
ВЫБРАТЬ ПЕРВЫЕ ${resolvedLimit}
Товары.Ссылка.Дата КАК Период,
ПРЕДСТАВЛЕНИЕ(Товары.Ссылка) КАК Регистратор,
"41.01" КАК СчетДт,
"" КАК СчетКт,
Товары.Сумма КАК Сумма,
ПРЕДСТАВЛЕНИЕ(Товары.Номенклатура) КАК Номенклатура,
ПРЕДСТАВЛЕНИЕ(Товары.Ссылка.Контрагент) КАК Контрагент,
ПРЕДСТАВЛЕНИЕ(Товары.Ссылка.ДоговорКонтрагента) КАК Договор,
ПРЕДСТАВЛЕНИЕ(Товары.Ссылка.Организация) КАК Организация,
"" КАК Склад,
Товары.Количество КАК Количество
ИЗ
Документ.ПоступлениеТоваровУслуг.Товары КАК Товары
${purchaseWhereClause}
УПОРЯДОЧИТЬ ПО
Период ВОЗР
`.trim();
}
function buildWarehouseReferenceCondition(filters, fieldPaths) {
const warehouse = typeof filters.warehouse === "string" ? filters.warehouse.trim() : "";
if (!warehouse) {
@ -1922,7 +2001,7 @@ function buildAddressRecipePlan(recipe, filters) {
: recipe.query_template === "inventory_purchase_to_sale_chain_profile"
? buildInventoryPurchaseToSaleDocumentQuery(filters, resolvedLimit)
: recipe.query_template === "inventory_aging_by_purchase_date_profile"
? buildInventoryMovementQuery(filters, resolvedLimit, "dt")
? buildInventoryAgingByPurchaseDateQuery(filters, resolvedLimit)
: recipe.query_template === "inventory_quality_events_profile"
? buildInventoryQualityEventsQuery(filters, resolvedLimit)
: recipe.query_template === "contracts_by_counterparty_profile"

View File

@ -549,9 +549,33 @@ function needsVatPurchaseDateAnchorDisclosure(userMessage) {
}
return /(?:дата|дату|дате|момент)\s+(?:покуп|закуп)|(?:покуп|закуп)\S*\s+(?:дат|момент)|purchase\s+date|date\s+of\s+purchase/iu.test(text);
}
function buildVatPurchaseDateAnchorDisclosureLine(options, periodWindowLabel) {
if (!periodWindowLabel || !needsVatPurchaseDateAnchorDisclosure(options.userMessage)) {
return null;
function vatPurchaseDateBasisLabel(basis, hasMultiplePurchaseDates) {
if (!hasMultiplePurchaseDates) {
return "подтвержденную дату закупки";
}
if (basis === "last_confirmed_purchase") {
return "последнюю подтвержденную дату закупки";
}
return "первую подтвержденную дату закупки";
}
function buildVatPurchaseDateAnchorDisclosureLines(options, periodWindowLabel) {
const bridge = options.purchaseDateBridge ?? null;
const selectedPurchaseDate = normalizeIsoDateOnly(bridge?.selectedPurchaseDate);
const firstPurchaseDate = normalizeIsoDateOnly(bridge?.firstPurchaseDate);
const lastPurchaseDate = normalizeIsoDateOnly(bridge?.lastPurchaseDate);
const hasMultiplePurchaseDates = Boolean(bridge?.hasMultiplePurchaseDates || (firstPurchaseDate && lastPurchaseDate && firstPurchaseDate !== lastPurchaseDate));
if (!periodWindowLabel || (!selectedPurchaseDate && !needsVatPurchaseDateAnchorDisclosure(options.userMessage))) {
return [];
}
if (selectedPurchaseDate) {
const basisLabel = vatPurchaseDateBasisLabel(bridge?.basis, hasMultiplePurchaseDates);
const lines = [
`- Якорь периода: для расчета я использую ${basisLabel} ${formatDateRu(selectedPurchaseDate)}; налоговый период ${periodWindowLabel}; сам расчет ниже взят из книг продаж/покупок за это окно.`
];
if (hasMultiplePurchaseDates && firstPurchaseDate && lastPurchaseDate) {
lines.push(`- Важно: у позиции несколько подтвержденных дат закупки (${formatDateRu(firstPurchaseDate)}..${formatDateRu(lastPurchaseDate)}); это расчет по выбранному якорю, а не единственная возможная дата.`);
}
return lines;
}
const asOfDate = normalizeIsoDateOnly(options.asOfDate);
const periodFrom = normalizeIsoDateOnly(options.periodFrom);
@ -565,9 +589,13 @@ function buildVatPurchaseDateAnchorDisclosureLine(options, periodWindowLabel) {
toTs !== null &&
asOfTs >= fromTs &&
asOfTs <= toTs) {
return `- Якорь периода: дата покупки ${formatDateRu(asOfDate)} попадает в налоговый период ${periodWindowLabel}; поэтому расчет ниже взят из книг продаж/покупок за это окно.`;
return [
`- Якорь периода: дата покупки ${formatDateRu(asOfDate)} попадает в налоговый период ${periodWindowLabel}; поэтому расчет ниже взят из книг продаж/покупок за это окно.`
];
}
return `- Якорь периода: дата покупки из вопроса/контекста использована для выбора налогового периода ${periodWindowLabel}; сам расчет ниже взят из книг продаж/покупок за это окно.`;
return [
`- Якорь периода: дата покупки из вопроса/контекста использована для выбора налогового периода ${periodWindowLabel}; сам расчет ниже взят из книг продаж/покупок за это окно.`
];
}
function detectRankingLimit(userMessage, fallback = 20) {
const text = normalizeQuestionText(userMessage);
@ -996,12 +1024,72 @@ function hasInventoryAccountPrefix(value, prefix) {
.replace(",", ".");
return normalized === prefix || normalized.startsWith(`${prefix}.`) || normalized.startsWith(prefix);
}
function isInventoryOnHandSnapshotRow(row) {
const registrator = String(row.registrator ?? "").trim().toLowerCase();
return /(?:остатки\s+на\s+дату|stock\s*on\s*hand|balance\s+as\s+of)/iu.test(registrator);
}
function isInventoryPurchaseMovement(row) {
return hasInventoryAccountPrefix(row.account_dt, "41.01");
return hasInventoryAccountPrefix(row.account_dt, "41.01") && !isInventoryOnHandSnapshotRow(row);
}
function isInventorySaleMovement(row) {
return hasInventoryAccountPrefix(row.account_kt, "41.01");
}
function inventoryAgingKeyPart(value) {
return normalizeEntityToken(value) ?? "";
}
function buildInventoryOnHandAgingScope(rows) {
const scope = {
itemKeys: new Set(),
itemOrgKeys: new Set(),
fullKeys: new Set(),
orgKeys: new Set()
};
for (const row of rows) {
if (!isInventoryOnHandSnapshotRow(row)) {
continue;
}
const quantity = extractInventoryQuantity(row);
if (quantity === null || quantity <= 0) {
continue;
}
const itemKey = inventoryAgingKeyPart(extractInventoryItemName(row));
if (!itemKey) {
continue;
}
const organizationKey = inventoryAgingKeyPart(extractInventoryOrganizationName(row));
const warehouseKey = inventoryAgingKeyPart(extractInventoryWarehouseName(row));
scope.itemKeys.add(itemKey);
if (organizationKey) {
scope.orgKeys.add(organizationKey);
scope.itemOrgKeys.add(`${itemKey}|${organizationKey}`);
}
if (organizationKey || warehouseKey) {
scope.fullKeys.add(`${itemKey}|${warehouseKey}|${organizationKey}`);
}
}
return scope;
}
function inventoryPurchaseMatchesOnHandAgingScope(row, scope) {
if (scope.itemKeys.size === 0) {
return true;
}
const itemKey = inventoryAgingKeyPart(extractInventoryItemName(row));
if (!itemKey || !scope.itemKeys.has(itemKey)) {
return false;
}
const organizationKey = inventoryAgingKeyPart(extractInventoryOrganizationName(row));
const warehouseKey = inventoryAgingKeyPart(extractInventoryWarehouseName(row));
if (scope.fullKeys.has(`${itemKey}|${warehouseKey}|${organizationKey}`)) {
return true;
}
if (organizationKey && scope.itemOrgKeys.has(`${itemKey}|${organizationKey}`)) {
return true;
}
if (organizationKey && scope.orgKeys.size > 0) {
return false;
}
return true;
}
function looksLikeInventoryTraceDocumentToken(value) {
const normalized = String(value ?? "").trim();
if (!normalized) {
@ -1162,7 +1250,11 @@ function formatCounterpartyItemFlowRows(rows, limit = 12) {
function buildInventoryAgingByItemAggregate(rows, asOfDate) {
const byItem = new Map();
const asOfTimestamp = toUtcDayTimestamp(asOfDate);
const onHandScope = buildInventoryOnHandAgingScope(rows);
for (const row of rows) {
if (!isInventoryPurchaseMovement(row) || !inventoryPurchaseMatchesOnHandAgingScope(row, onHandScope)) {
continue;
}
const item = extractInventoryItemName(row);
if (!item) {
continue;
@ -1245,19 +1337,11 @@ function formatInventoryAgingRows(items, asOfDate, limit = 10) {
const parts = [
`${index + 1}. ${item.item}`,
`первая закупка: ${inventoryTraceDateLabel(item.firstPurchasePeriod)}`,
`последняя закупка: ${inventoryTraceDateLabel(item.lastPurchasePeriod)}`,
`документов: ${formatNumberWithDots(item.documentCount)}`,
`операций: ${formatNumberWithDots(item.operations)}`
`последняя закупка: ${inventoryTraceDateLabel(item.lastPurchasePeriod)}`
];
if (item.ageDays !== null) {
parts.push(`возраст следа на ${formatDateRu(asOfDate)}: ${formatNumberWithDots(item.ageDays)} дн.`);
}
if (item.warehouse) {
parts.push(`склад: ${item.warehouse}`);
}
if (item.organization) {
parts.push(`организация: ${item.organization}`);
}
if (item.counterparties.length > 0) {
parts.push(`поставщики: ${item.counterparties.slice(0, 3).join("; ")}`);
}
@ -3060,15 +3144,27 @@ function composeFactualReplyBody(intent, rows, options = {}) {
const formatConfirmedMoney = (value) => (options.useRubCurrency ? formatMoneyRub(value) : formatMoney(value));
const organizationLabel = (0, assistantOrganizationMatcher_1.normalizeOrganizationScopeValue)(options.organizationHint);
const organizationScopeLabel = organizationLabel ? ` по организации ${organizationLabel}` : "";
const purchaseDateAnchorLine = buildVatPurchaseDateAnchorDisclosureLine(options, periodWindowLabel);
const purchaseDateAnchorLines = buildVatPurchaseDateAnchorDisclosureLines(options, periodWindowLabel);
const selectedPurchaseDate = normalizeIsoDateOnly(options.purchaseDateBridge?.selectedPurchaseDate);
const hasMultiplePurchaseDates = Boolean(options.purchaseDateBridge?.hasMultiplePurchaseDates ||
(normalizeIsoDateOnly(options.purchaseDateBridge?.firstPurchaseDate) &&
normalizeIsoDateOnly(options.purchaseDateBridge?.lastPurchaseDate) &&
normalizeIsoDateOnly(options.purchaseDateBridge?.firstPurchaseDate) !==
normalizeIsoDateOnly(options.purchaseDateBridge?.lastPurchaseDate)));
const purchaseBasisLabel = vatPurchaseDateBasisLabel(options.purchaseDateBridge?.basis, hasMultiplePurchaseDates);
const directVatLine = selectedPurchaseDate
? hasMultiplePurchaseDates
? `Коротко: если брать ${purchaseBasisLabel} ${formatDateRu(selectedPurchaseDate)}, подтвержденный НДС к уплате за налоговый период${organizationScopeLabel}${formatConfirmedMoney(vatToPay)}.`
: `Коротко: по дате покупки ${formatDateRu(selectedPurchaseDate)} подтвержденный НДС к уплате за налоговый период${organizationScopeLabel}${formatConfirmedMoney(vatToPay)}.`
: `Коротко: подтвержденный НДС к уплате за налоговый период${organizationScopeLabel}${formatConfirmedMoney(vatToPay)}.`;
const lines = [
`Коротко: подтвержденный НДС к уплате за налоговый период${organizationScopeLabel}${formatConfirmedMoney(vatToPay)}.`,
directVatLine,
"Расчет сделан по книгам продаж и покупок.",
"",
"Что вошло в расчет:",
...(organizationLabel ? [`- Организация: ${organizationLabel}.`] : []),
`- Налоговый период расчета: ${periodWindowLabel ?? "не задан (нужен явный период)"}.`,
...(purchaseDateAnchorLine ? [purchaseDateAnchorLine] : []),
...purchaseDateAnchorLines,
`- НДС по книге продаж: ${formatConfirmedMoney(salesVat)}.`,
`- НДС по книге покупок (вычеты): ${formatConfirmedMoney(purchaseVat)}.`,
`- Нетто НДС (книга продаж - книга покупок): ${formatConfirmedMoney(netVat)}.`

View File

@ -406,7 +406,11 @@ function composeCounterpartyAnalyticsReply(intent, rows, options = {}, deps) {
const focus = deps.detectValueRankingFocus(options.userMessage);
const limit = deps.detectRankingLimit(options.userMessage, 20);
const minOpsForAvgCheck = deps.detectMinOpsForAvgCheck(options.userMessage);
const normalizedQuestion = deps.normalizeQuestionText(options.userMessage);
const questionSignalText = [options.rawUserMessage, options.userMessage]
.map((value) => String(value ?? "").trim())
.filter(Boolean)
.join(" ");
const normalizedQuestion = deps.normalizeQuestionText(questionSignalText || options.userMessage);
const asksSingleBestCounterparty = focus === "top_by_total" &&
/(?:какой|кто|which|who|какой|кто)/iu.test(normalizedQuestion) &&
/(?:больше\s+всего|сам(?:ый|ая|ое|ые)|наибольш|прин[её]с|highest|most|больше\s+всего|сам(?:ый|ая|РѕРµ|ые)|наибол|РїСЂРёРЅ[её]СЃ)/iu.test(normalizedQuestion) &&
@ -418,6 +422,7 @@ function composeCounterpartyAnalyticsReply(intent, rows, options = {}, deps) {
const effectiveLimit = asksSingleBestCounterparty || semanticSingleBestCounterparty ? 1 : limit;
const cashflowBoundaryLine = buildCashflowBoundaryLine(isSupplier);
const cashflowNextStepLine = buildCashflowNextStepLine(isSupplier, normalizedQuestion);
const dependencyCue = /(?:зависим|завязан|концентрац|ключев|основн|критич|dependency|concentration|key|critical)/iu.test(normalizedQuestion);
const byCounterparty = new Map();
const byYear = new Map();
const deals = [];
@ -653,13 +658,20 @@ function composeCounterpartyAnalyticsReply(intent, rows, options = {}, deps) {
? `В выбранном срезе найден один поставщик: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям). Это не полноценный сравнительный рейтинг.`
: `В выбранном срезе найден один клиент: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям). Это не полноценный сравнительный рейтинг.`
: isSupplier
? `Крупнейший поставщик по подтвержденным выплатам ${rankingPeriodLabel}: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям).`
: `Самый доходный клиент ${rankingPeriodLabel} по подтвержденным поступлениям: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям).`;
? dependencyCue
? `Главный сигнал зависимости по исходящим платежам ${rankingPeriodLabel}: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям).`
: `Крупнейший поставщик по подтвержденным выплатам ${rankingPeriodLabel}: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям).`
: dependencyCue
? `Главный сигнал зависимости по входящим деньгам ${rankingPeriodLabel}: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям).`
: `Самый доходный клиент ${rankingPeriodLabel} по подтвержденным поступлениям: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям).`;
lines.unshift(directAnswerLine);
if (dependencyCue) {
lines.splice(1, 0, "Это сигнал концентрации по найденным денежным строкам 1С, а не подтверждение договорной зависимости, надежности контрагента или чистой прибыли.");
}
if (!isSupplier) {
lines.splice(1, 0, cashflowBoundaryLine);
lines.splice(dependencyCue ? 2 : 1, 0, cashflowBoundaryLine);
if (cashflowNextStepLine) {
lines.splice(2, 0, cashflowNextStepLine);
lines.splice(dependencyCue ? 3 : 2, 0, cashflowNextStepLine);
}
}
}

View File

@ -460,6 +460,23 @@ function resolveYearFromFilters(filters) {
function hasRelativeYearHint(text) {
return /(?:эт(?:от|ого)(?:\s+же)?\s+год|этого\s+же\s+года|того\s+же\s+года|this\s+year|same\s+year|that\s+year)/iu.test(String(text ?? ""));
}
function resolveMonthPeriodFromIsoDate(isoDate) {
const match = String(isoDate ?? "").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 lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate();
return {
period_from: `${match[1]}-${match[2]}-01`,
period_to: `${match[1]}-${match[2]}-${String(lastDay).padStart(2, "0")}`,
as_of_date: `${match[1]}-${match[2]}-${String(lastDay).padStart(2, "0")}`
};
}
function resolveRelativeMonthPeriodFromInventoryRoot(userMessage, followupContext) {
if (!followupContext || !isInventoryRootFrameIntent(followupContext.root_intent)) {
return null;
@ -629,6 +646,34 @@ function hasInventoryPurchaseDateVatBridgeCue(text) {
return (/(?:ндс|vat)/iu.test(normalized) &&
/(?:на\s+дат[ауеы]\s+покупк|на\s+дат[ауеы]\s+закупк|по\s+дат[еу]\s+покупк|по\s+дат[еу]\s+закупк|дата\s+покупк|дата\s+закупк|purchase\s+date)/iu.test(normalized));
}
function hasInventoryPurchaseDateVatBridgeContinuationCue(text) {
const normalized = textWithRepairedVariant(String(text ?? ""))
.trim()
.toLowerCase()
.replace(/ё/g, "е");
if (!normalized) {
return false;
}
const mentionsPurchase = /(?:покупк|закупк|purchase)/iu.test(normalized);
const mentionsFirstOrLast = /(?:перв|последн|earliest|oldest|latest|last)/iu.test(normalized);
return mentionsPurchase && mentionsFirstOrLast;
}
function purchaseDateBridgeBasisFromMessage(userMessage) {
const normalized = textWithRepairedVariant(String(userMessage ?? ""))
.trim()
.toLowerCase()
.replace(/ё/g, "е");
if (!normalized) {
return null;
}
if (/(?:последн|latest|last)/iu.test(normalized) && /(?:покупк|закупк|purchase)/iu.test(normalized)) {
return "last_confirmed_purchase";
}
if (/(?:перв|earliest|oldest)/iu.test(normalized) && /(?:покупк|закупк|purchase)/iu.test(normalized)) {
return "first_confirmed_purchase";
}
return null;
}
function hasInventoryMarginRankingFollowupCue(text) {
const normalized = textWithRepairedVariant(String(text ?? ""))
.toLowerCase()
@ -745,6 +790,31 @@ function isShortDebtRoleMirrorFollowup(intent, text, followupContext) {
function mergeFollowupFilters(current, intent, userMessage, followupContext) {
const merged = { ...current };
const reasons = [];
const clearInventoryAgingOrganizationAliasItem = (organizationHint) => {
if (intent !== "inventory_aging_by_purchase_date") {
return;
}
const explicitItemMention = /(?:^|[\s,.;:!?()\-\u2014])(?:товар(?:у|а|ом)?|позици(?:и|я|ю)|item|row|line)(?=$|[\s,.;:!?()\-\u2014])/iu.test(String(userMessage ?? ""));
const agingItem = toNonEmptyString(merged.item);
const agingOrganization = toNonEmptyString(merged.organization) ?? organizationHint;
const normalizedAgingItem = (0, assistantOrganizationMatcher_1.normalizeOrganizationScopeSearchText)(agingItem ?? "");
const normalizedAgingOrganization = (0, assistantOrganizationMatcher_1.normalizeOrganizationScopeSearchText)(agingOrganization ?? "");
const itemLooksLikeOrganizationScope = Boolean(agingItem &&
agingOrganization &&
((0, assistantOrganizationMatcher_1.organizationsLikelySameEntity)(agingItem, agingOrganization) ||
(normalizedAgingItem &&
normalizedAgingOrganization &&
(normalizedAgingOrganization.includes(normalizedAgingItem) ||
normalizedAgingItem.includes(normalizedAgingOrganization)))));
if (agingItem && itemLooksLikeOrganizationScope) {
delete merged.item;
reasons.push("item_cleared_as_organization_scope_alias_for_stock_aging");
}
else if (agingItem && !explicitItemMention) {
delete merged.item;
reasons.push("item_cleared_for_stock_slice_aging");
}
};
if (!followupContext) {
if ((intent === "list_open_contracts" ||
intent === "open_contracts_confirmed_as_of_date" ||
@ -760,6 +830,7 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
: "as_of_date_derived_from_period_for_open_contracts");
}
}
clearInventoryAgingOrganizationAliasItem(null);
return { filters: merged, reasons };
}
const previous = followupContext.previous_filters ?? {};
@ -773,6 +844,12 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
const previousAsOfDate = toNonEmptyString(previous.as_of_date);
const previousPeriodFrom = toNonEmptyString(previous.period_from);
const previousPeriodTo = toNonEmptyString(previous.period_to);
const previousPurchaseDateBridgeSelected = toNonEmptyString(previous.purchase_date_bridge_selected);
const previousPurchaseDateBridgeFirst = toNonEmptyString(previous.purchase_date_bridge_first);
const previousPurchaseDateBridgeLast = toNonEmptyString(previous.purchase_date_bridge_last);
const previousPurchaseDateBridgeBasis = toNonEmptyString(previous.purchase_date_bridge_basis);
const previousPurchaseDateBridgeHasMultiple = previous.purchase_date_bridge_has_multiple === true ||
String(previous.purchase_date_bridge_has_multiple ?? "").trim().toLowerCase() === "true";
const relativeMonthFromInventoryRoot = resolveRelativeMonthPeriodFromInventoryRoot(userMessage, followupContext);
const relativeMonthFromFollowupYear = resolveRelativeMonthPeriodFromFollowupYear(userMessage, followupContext);
const allTimeRequested = hasAllTimeHint(userMessage);
@ -790,6 +867,53 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
merged.organization = previousOrganization;
reasons.push("organization_from_followup_context");
}
if (intent === "vat_liability_confirmed_for_tax_period" && previousPurchaseDateBridgeSelected) {
const requestedBridgeBasis = purchaseDateBridgeBasisFromMessage(userMessage);
const shouldCarryPurchaseDateBridge = !currentHasExplicitTemporalScope &&
(Boolean(requestedBridgeBasis) ||
hasInventoryPurchaseDateVatBridgeCue(userMessage) ||
hasInventoryPurchaseDateVatBridgeContinuationCue(userMessage));
if (!shouldCarryPurchaseDateBridge) {
delete merged.purchase_date_bridge_selected;
delete merged.purchase_date_bridge_first;
delete merged.purchase_date_bridge_last;
delete merged.purchase_date_bridge_basis;
delete merged.purchase_date_bridge_has_multiple;
reasons.push(currentHasExplicitTemporalScope
? "purchase_date_bridge_suppressed_by_explicit_temporal_scope"
: "purchase_date_bridge_not_reused_without_bridge_cue");
}
else {
const selectedBridgeDate = requestedBridgeBasis === "last_confirmed_purchase"
? previousPurchaseDateBridgeLast ?? previousPurchaseDateBridgeSelected
: requestedBridgeBasis === "first_confirmed_purchase"
? previousPurchaseDateBridgeFirst ?? previousPurchaseDateBridgeSelected
: previousPurchaseDateBridgeSelected;
merged.purchase_date_bridge_selected = selectedBridgeDate;
const selectedBridgeWindow = resolveMonthPeriodFromIsoDate(selectedBridgeDate);
if (selectedBridgeWindow) {
merged.period_from = selectedBridgeWindow.period_from;
merged.period_to = selectedBridgeWindow.period_to;
merged.as_of_date = selectedBridgeWindow.as_of_date;
reasons.push("period_from_purchase_date_bridge_followup_context");
reasons.push("period_to_purchase_date_bridge_followup_context");
}
if (previousPurchaseDateBridgeFirst) {
merged.purchase_date_bridge_first = previousPurchaseDateBridgeFirst;
}
if (previousPurchaseDateBridgeLast) {
merged.purchase_date_bridge_last = previousPurchaseDateBridgeLast;
}
const effectiveBridgeBasis = requestedBridgeBasis ?? previousPurchaseDateBridgeBasis;
if (effectiveBridgeBasis) {
merged.purchase_date_bridge_basis = effectiveBridgeBasis;
}
if (previousPurchaseDateBridgeHasMultiple) {
merged.purchase_date_bridge_has_multiple = true;
}
reasons.push("purchase_date_bridge_from_followup_context");
}
}
if (intent === "inventory_on_hand_as_of_date" &&
followupContext.previous_intent === "inventory_on_hand_as_of_date" &&
followupContext.target_intent === "inventory_on_hand_as_of_date" &&
@ -1099,11 +1223,7 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
reasons.push("period_derived_from_inventory_root_frame_year");
}
if (intent === "inventory_aging_by_purchase_date") {
const explicitItemMention = /(?:^|[\s,.;:!?()\-\u2014])(?:товар(?:у|а|ом)?|позици(?:и|я|ю)|item|row|line)(?=$|[\s,.;:!?()\-\u2014])/iu.test(String(userMessage ?? ""));
if (toNonEmptyString(merged.item) && !explicitItemMention) {
delete merged.item;
reasons.push("item_cleared_for_stock_slice_aging");
}
clearInventoryAgingOrganizationAliasItem(previousOrganization);
}
if (!sameDateRequested &&
hasFollowupSignalForConfirmed &&
@ -1317,7 +1437,12 @@ function deriveIntentWithFollowupContext(detectedIntent, userMessage, followupCo
return detectedIntent;
}
const normalizedMessage = String(userMessage ?? "");
const hasFollowupSignal = hasAddressFollowupContextSignal(normalizedMessage);
const genericFollowupSignal = hasAddressFollowupContextSignal(normalizedMessage);
const previousFilters = followupContext.previous_filters ?? {};
const purchaseBridgeContinuationSignal = followupContext.previous_intent === "vat_liability_confirmed_for_tax_period" &&
Boolean(toNonEmptyString(previousFilters.purchase_date_bridge_selected)) &&
hasInventoryPurchaseDateVatBridgeContinuationCue(normalizedMessage);
const hasFollowupSignal = genericFollowupSignal || purchaseBridgeContinuationSignal;
if (!hasFollowupSignal) {
return detectedIntent;
}
@ -1326,7 +1451,6 @@ function deriveIntentWithFollowupContext(detectedIntent, userMessage, followupCo
if (!sourceIntent && !fallbackIntent) {
return detectedIntent;
}
const previousFilters = followupContext.previous_filters ?? {};
const previousPeriodFrom = toNonEmptyString(previousFilters.period_from);
const previousPeriodTo = toNonEmptyString(previousFilters.period_to);
const previousContract = toNonEmptyString(previousFilters.contract);
@ -1340,6 +1464,7 @@ function deriveIntentWithFollowupContext(detectedIntent, userMessage, followupCo
const samePeriodVatFollowup = isVatFollowup &&
hasSamePeriodHint(normalizedMessage) &&
Boolean(previousPeriodFrom || previousPeriodTo);
const previousPurchaseDateBridgeSelected = toNonEmptyString(previousFilters.purchase_date_bridge_selected);
const previousIsInventoryFamily = isInventoryIntent(sourceIntent ?? undefined);
const rootIsInventoryFamily = isInventoryIntent(followupContext.root_intent ?? undefined);
const inventoryLineageActive = previousIsInventoryFamily ||
@ -1361,7 +1486,11 @@ function deriveIntentWithFollowupContext(detectedIntent, userMessage, followupCo
sourceIntent === "open_items_by_counterparty_or_contract");
const hasExplicitInventoryItemReference = /(?:товар|номенклатур|позици|склад|остат|sku|item|product|товар|номенклатур|позици|склад|остат)/iu.test(normalizedMessage) || hasSelectedObjectInlineSnapshotMetadata(normalizedMessage);
const staleInventoryLineageCanYieldToCounterparty = previousCounterpartyLaneActive && !hasExplicitInventoryItemReference;
const inventoryPurchaseDateVatBridge = inventorySelectedObjectFollowup && hasInventoryPurchaseDateVatBridgeCue(normalizedMessage);
const vatPurchaseDateBridgeContinuation = sourceIntent === "vat_liability_confirmed_for_tax_period" &&
Boolean(previousPurchaseDateBridgeSelected) &&
hasInventoryPurchaseDateVatBridgeContinuationCue(normalizedMessage);
const inventoryPurchaseDateVatBridge = (inventorySelectedObjectFollowup && hasInventoryPurchaseDateVatBridgeCue(normalizedMessage)) ||
vatPurchaseDateBridgeContinuation;
const marginRankingLineageActive = sourceIntent === "inventory_margin_ranking_for_nomenclature" ||
fallbackIntent === "inventory_margin_ranking_for_nomenclature" ||
followupContext.root_intent === "inventory_margin_ranking_for_nomenclature";
@ -1382,6 +1511,7 @@ function deriveIntentWithFollowupContext(detectedIntent, userMessage, followupCo
if (inventoryPurchaseDateVatBridge &&
(detectedIntent.intent === "unknown" ||
detectedIntent.intent === sourceIntent ||
detectedIntent.intent === "account_balance_snapshot" ||
detectedIntent.intent === "vat_payable_confirmed_as_of_date" ||
detectedIntent.intent === "vat_payable_forecast")) {
return {

View File

@ -57,6 +57,16 @@ function inventoryRequestedPartyMatches(requested, actualParties) {
function inventoryPartyListOrUnknown(parties) {
return parties.length > 0 ? parties.slice(0, 4).join("; ") : "не выделен отдельным полем";
}
function normalizeInventoryReplyEntityToken(value) {
const normalized = String(value ?? "")
.trim()
.toLowerCase()
.replace(/ё/gu, "е")
.replace(/[^a-zа-я0-9]+/giu, " ")
.replace(/\s+/gu, " ")
.trim();
return normalized || null;
}
function sumInventoryRowAmount(rows) {
return rows.reduce((sum, row) => sum + (typeof row.amount === "number" && Number.isFinite(row.amount) ? row.amount : 0), 0);
}
@ -380,29 +390,47 @@ function composeInventoryReply(intent, rows, options, deps) {
}
if (intent === "inventory_aging_by_purchase_date") {
const asOfDate = deps.resolvePayablesAsOfDate(options);
const purchaseRows = rows.filter((row) => deps.isInventoryPurchaseMovement(row));
const agingItems = deps.buildInventoryAgingByItemAggregate(rows, asOfDate);
const agingItemTokens = new Set(agingItems
.map((item) => normalizeInventoryReplyEntityToken(item.item))
.filter((item) => Boolean(item)));
const purchaseRows = rows.filter((row) => {
if (!deps.isInventoryPurchaseMovement(row)) {
return false;
}
if (agingItemTokens.size === 0) {
return true;
}
const item = deps.summarizeInventoryTraceRows([row]).item;
const itemToken = normalizeInventoryReplyEntityToken(item);
return Boolean(itemToken && agingItemTokens.has(itemToken));
});
const summary = deps.summarizeInventoryTraceRows(purchaseRows);
const agingItems = deps.buildInventoryAgingByItemAggregate(purchaseRows, asOfDate);
const oldestPurchaseDate = agingItems[0]?.firstPurchasePeriod ?? summary.firstPeriod;
const oldestPurchaseAgeDays = agingItems[0]?.ageDays ?? null;
const organizationLabel = agingItems.find((item) => item.organization)?.organization ?? null;
const oldestAnswerPreview = agingItems
.slice(0, 3)
.map((item) => `${item.item} (${deps.inventoryTraceDateLabel(item.firstPurchasePeriod)})`)
.join("; ");
const directAnswerLine = agingItems.length > 0
? `К самым старым закупкам в текущем подтвержденном срезе относятся позиции с самой ранней первой закупкой: ${oldestAnswerPreview}.`
: "По доступному закупочному следу позиции со старыми закупками не материализованы.";
? `Среди фактических положительных остатков есть давно закупавшиеся позиции: ${oldestAnswerPreview}.`
: "В фактическом положительном остатке не найдено позиций с подтвержденным старым закупочным следом.";
const lines = [directAnswerLine];
(0, inventoryReplyPresentation_1.appendInventoryBulletSection)(lines, "Сводка:", [
const summaryLines = [
`Дата среза: ${deps.formatDateRu(asOfDate)}.`,
`Самая ранняя первая закупка среди позиций: ${deps.inventoryTraceDateLabel(oldestPurchaseDate)}.`,
`Самая поздняя найденная закупка: ${deps.inventoryTraceDateLabel(summary.lastPeriod)}.`,
`Позиций в выборке: ${deps.formatNumberWithDots(agingItems.length)}.`,
`Закупочных документов: ${deps.formatNumberWithDots(summary.documents.length)}.`,
`Закупочных операций: ${deps.formatNumberWithDots(purchaseRows.length)}.`
]);
];
if (organizationLabel) {
summaryLines.splice(1, 0, `Организация: ${organizationLabel}.`);
}
(0, inventoryReplyPresentation_1.appendInventoryBulletSection)(lines, "Сводка:", summaryLines);
(0, inventoryReplyPresentation_1.appendInventoryBulletSection)(lines, "Ограничения:", [
"Без партионного учета этот ответ показывает возраст закупочного следа по товарной позиции, а не возраст конкретного лота."
"Берем только позиции с положительным остатком на дату среза; без партионного учета это возраст закупочного следа по номенклатуре, а не доказанный возраст конкретной партии."
]);
if (oldestPurchaseAgeDays !== null) {
lines.push(`- Между самой ранней первой закупкой и датой среза прошло ${deps.formatNumberWithDots(oldestPurchaseAgeDays)} дн.`);
@ -411,11 +439,11 @@ function composeInventoryReply(intent, rows, options, deps) {
lines.push(`- Поставщики, встречающиеся в наблюдаемом закупочном следе: ${summary.counterparties.slice(0, 4).join("; ")}.`);
}
if (agingItems.length > 0) {
(0, inventoryReplyPresentation_1.appendInventorySection)(lines, "Позиции от самых старых закупок:", deps.formatInventoryAgingRows(agingItems, asOfDate, 12));
(0, inventoryReplyPresentation_1.appendInventorySection)(lines, "Позиции от самых старых закупок:", deps.formatInventoryAgingRows(agingItems, asOfDate, 5));
}
else {
(0, inventoryReplyPresentation_1.appendInventorySection)(lines, "Позиции от самых старых закупок:", [
"- В доступных данных не найдено закупочных движений для выбранного среза."
"- В доступных данных не найдено закупочных движений по позициям, которые есть в положительном остатке на дату среза."
]);
}
return (0, replyContracts_1.buildFactualSummaryReply)(lines, (0, replyContracts_1.buildConfirmedBalanceSemantics)(agingItems.length > 0 ? "strong" : "medium", agingItems.length > 0));

View File

@ -98,6 +98,7 @@ async function runAssistantAddressAttemptRuntime(input) {
};
const runAddressLaneAttempt = async (messageUsed, carryMeta, analysisDateHint, llmSemanticHints = null) => runAddressLaneAttemptRuntimeSafe((0, assistantAddressLaneAttemptInputBuilder_1.buildAssistantAddressLaneAttemptRuntimeInput)({
messageUsed,
rawUserMessage: input.userMessage,
carryMeta,
analysisDateHint,
llmSemanticHints,

View File

@ -4,6 +4,7 @@ exports.buildAssistantAddressLaneAttemptRuntimeInput = buildAssistantAddressLane
function buildAssistantAddressLaneAttemptRuntimeInput(input) {
return {
messageUsed: input.messageUsed,
rawUserMessage: input.rawUserMessage,
carryMeta: input.carryMeta,
analysisDateHint: input.analysisDateHint,
llmSemanticHints: input.llmSemanticHints,

View File

@ -9,7 +9,8 @@ function resolveAssistantAddressLaneAttemptFollowupContext(carryMeta) {
}
function buildAssistantAddressLaneAttemptQueryOptions(input) {
const base = {
analysisDateHint: input.analysisDateHint
analysisDateHint: input.analysisDateHint,
rawUserMessage: input.rawUserMessage ?? undefined
};
if (input.scopedFollowupContext) {
base.followupContext = input.scopedFollowupContext;

View File

@ -10,6 +10,7 @@ async function runAssistantAddressLaneAttemptRuntime(input) {
scopedFollowupContext,
llmSemanticHints: input.llmSemanticHints ?? null,
activeOrganization: input.activeOrganization,
knownOrganizations: input.knownOrganizations
knownOrganizations: input.knownOrganizations,
rawUserMessage: input.rawUserMessage ?? input.messageUsed
}));
}

View File

@ -69,7 +69,17 @@ function cleanComparisonScopeCompanyLine(line, organization) {
}
return clean.trim();
}
function hasComparisonScopeProofCue(value) {
const text = toNullableString(value)?.toLocaleLowerCase("ru-RU").replace(/\s+/gu, " ").trim();
if (!text) {
return false;
}
return /(?:\bсравни\b|собери\s+коротк\p{L}*\s+итог|коротк\p{L}*\s+сравн|что\s+.*подтверд\p{L}*.*(?:компан|контрагент|свк)|что\s+.*отдельно\s+по\s+(?:группа\s+свк|выбранн\p{L}*\s+контрагент)|какие\s+выводы\s+можно\s+делать\s+и\s+какие\s+нельзя)/iu.test(text);
}
function buildComparisonScopeProofReply(input) {
if (!hasComparisonScopeProofCue(input.userMessage)) {
return null;
}
const entryPoint = toRecordObject(input.debug.assistant_mcp_discovery_entry_point_v1);
const turnInput = toRecordObject(entryPoint?.turn_input);
const turnMeaning = toRecordObject(turnInput?.turn_meaning_ref);

View File

@ -62,7 +62,7 @@ function hasCompactCashflowFollowupSignal(text) {
if (!value) {
return false;
}
return /(?:\u0431\u0435\u0437\s+\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\p{L}*|\u0431\u0435\u0437\s+\u0440\u0430\u0437\u0431\u0438\u0432\p{L}*|\u0431\u0435\u0437\s+\u0440\u0430\u0437\u0440\u0435\u0437\p{L}*|\u0431\u0435\u0437\s+\u0434\u0435\u0442\u0430\u043b\p{L}*|\u043e\u0434\u043d\u043e\u0439\s+\u0441\u0442\u0440\u043e\u043a\p{L}*|\u0442\u043e\u043b\u044c\u043a\u043e\s+\u0438\u0442\u043e\u0433|\u043f\u0440\u0438\u0448\p{L}*[\s\S]{0,80}\u0443\u0448\p{L}*[\s\S]{0,80}\u043d\u0435\u0442\u0442\u043e|\u043f\u043e\u043b\u0443\u0447\p{L}*[\s\S]{0,80}\u0437\u0430\u043f\u043b\u0430\u0442\p{L}*[\s\S]{0,80}\u0431\u0435\u0437\s+\u0434\u0435\u0442\u0430\u043b)/iu.test(value);
return /(?:\u0431\u0435\u0437\s+\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\p{L}*|\u0431\u0435\u0437\s+\u0440\u0430\u0437\u0431\u0438\u0432\p{L}*|\u0431\u0435\u0437\s+\u0440\u0430\u0437\u0440\u0435\u0437\p{L}*|\u0431\u0435\u0437\s+\u0434\u0435\u0442\u0430\u043b\p{L}*|\u043e\u0434\u043d\u043e\u0439\s+\u0441\u0442\u0440\u043e\u043a\p{L}*|\u0442\u043e\u043b\u044c\u043a\u043e\s+\u0438\u0442\u043e\u0433|\u043f\u0440\u0438\u0448\p{L}*[\s\S]{0,80}\u0443\u0448\p{L}*[\s\S]{0,80}\u043d\u0435\u0442\u0442\u043e|\u043f\u043e\u043b\u0443\u0447\p{L}*[\s\S]{0,80}\u0437\u0430\u043f\u043b\u0430\u0442\p{L}*[\s\S]{0,80}\u0431\u0435\u0437\s+\u0434\u0435\u0442\u0430\u043b|кто\s+больше\s+всего\s+зан[её]с|кому\s+больше\s+всего\s+ушл\p{L}*|банк\s+не\s+называй\s+обычн\p{L}*\s+клиент\p{L}*|главн\p{L}*\s+клиент|главн\p{L}*\s+поставщик)/iu.test(value);
}
function dateScopeToFilterWindow(dateScope) {
if (!dateScope) {
@ -79,6 +79,35 @@ function dateScopeToFilterWindow(dateScope) {
}
return null;
}
function filterWindowFromExtractedFilters(filters) {
if (!filters) {
return null;
}
const asOfDate = typeof filters.as_of_date === "string" ? filters.as_of_date.trim() : "";
if (/^(?:19|20)\d{2}-\d{2}-\d{2}$/.test(asOfDate)) {
return { as_of_date: asOfDate };
}
const periodFrom = typeof filters.period_from === "string" ? filters.period_from.trim() : "";
const periodTo = typeof filters.period_to === "string" ? filters.period_to.trim() : "";
if (/^(?:19|20)\d{2}-\d{2}-\d{2}$/.test(periodFrom) && /^(?:19|20)\d{2}-\d{2}-\d{2}$/.test(periodTo)) {
return {
period_from: periodFrom,
period_to: periodTo
};
}
return null;
}
function hasBoundedFilterWindow(filters) {
if (!filters) {
return false;
}
if (typeof filters.as_of_date === "string" && /^(?:19|20)\d{2}-\d{2}-\d{2}$/.test(filters.as_of_date.trim())) {
return true;
}
const periodFrom = typeof filters.period_from === "string" ? filters.period_from.trim() : "";
const periodTo = typeof filters.period_to === "string" ? filters.period_to.trim() : "";
return /^(?:19|20)\d{2}-\d{2}-\d{2}$/.test(periodFrom) && /^(?:19|20)\d{2}-\d{2}-\d{2}$/.test(periodTo);
}
function looksLikeReliableOrganizationScope(value) {
const text = compactLower(value);
if (!text) {
@ -103,12 +132,15 @@ function inferBusinessOverviewDiscoveryContextFromSessionItems(sessionItems, toN
continue;
}
const turnMeaningRef = toRecordObject(turnInput?.turn_meaning_ref);
const filterWindow = dateScopeToFilterWindow(toNonEmptyString(turnMeaningRef?.explicit_date_scope));
const debugFilters = toRecordObject(debug?.extracted_filters);
const filterWindow = dateScopeToFilterWindow(toNonEmptyString(turnMeaningRef?.explicit_date_scope)) ??
filterWindowFromExtractedFilters(debugFilters);
if (!filterWindow) {
continue;
}
const previousFilters = { ...filterWindow };
const organization = toNonEmptyString(turnMeaningRef?.explicit_organization_scope);
const organization = toNonEmptyString(turnMeaningRef?.explicit_organization_scope) ??
toNonEmptyString(debugFilters?.organization);
if (looksLikeReliableOrganizationScope(organization)) {
previousFilters.organization = organization;
}
@ -129,9 +161,7 @@ function mergeBusinessOverviewDateContextForCompactCashflow(input) {
return input.followupContext;
}
const existingFilters = toRecordObject(input.followupContext?.previous_filters);
if (input.toNonEmptyString(existingFilters?.period_from) ||
input.toNonEmptyString(existingFilters?.period_to) ||
input.toNonEmptyString(existingFilters?.as_of_date)) {
if (hasBoundedFilterWindow(existingFilters)) {
return input.followupContext;
}
const inferred = inferBusinessOverviewDiscoveryContextFromSessionItems(input.sessionItems, input.toNonEmptyString);
@ -142,12 +172,12 @@ function mergeBusinessOverviewDateContextForCompactCashflow(input) {
...inferred,
...(input.followupContext ?? {}),
previous_filters: {
...(toRecordObject(inferred.previous_filters) ?? {}),
...(toRecordObject(input.followupContext?.previous_filters) ?? {})
...(toRecordObject(input.followupContext?.previous_filters) ?? {}),
...(toRecordObject(inferred.previous_filters) ?? {})
},
root_filters: {
...(toRecordObject(inferred.root_filters) ?? {}),
...(toRecordObject(input.followupContext?.root_filters) ?? {})
...(toRecordObject(input.followupContext?.root_filters) ?? {}),
...(toRecordObject(inferred.root_filters) ?? {})
}
};
}
@ -172,6 +202,13 @@ function sameEntityHint(expected, actual) {
}
return left === right || left.includes(right) || right.includes(left);
}
function hasComparisonScopeProofCue(text) {
const value = compactLower(text);
if (!value) {
return false;
}
return /(?:\bсравни\b|собери\s+коротк\p{L}*\s+итог|коротк\p{L}*\s+сравн|что\s+.*подтверд\p{L}*.*(?:компан|контрагент|свк)|что\s+.*отдельно\s+по\s+(?:группа\s+свк|выбранн\p{L}*\s+контрагент)|какие\s+выводы\s+можно\s+делать\s+и\s+какие\s+нельзя)/iu.test(value);
}
function isBusinessOverviewDiscoveryFollowup(followupContext, toNonEmptyString) {
if (!followupContext) {
return false;
@ -344,6 +381,9 @@ function mergeBusinessOverviewProofBundlesFromNavigationState(input) {
const entities = toRecordObject(input.predecomposeContract?.entities);
const hasCurrentOrganizationSelection = Boolean(input.toNonEmptyString(entities?.organization));
const businessOverviewFollowup = isBusinessOverviewDiscoveryFollowup(input.followupContext, input.toNonEmptyString);
if (!hasComparisonScopeProofCue(input.userMessage)) {
return input.followupContext;
}
if (!businessOverviewFollowup && !hasCurrentOrganizationSelection) {
return input.followupContext;
}
@ -376,6 +416,9 @@ function mergeBusinessOverviewProofBundlesFromNavigationState(input) {
};
}
function mergeBusinessOverviewProofBundlesFromSessionItems(input) {
if (!hasComparisonScopeProofCue(input.userMessage)) {
return input.followupContext;
}
if (!isBusinessOverviewDiscoveryFollowup(input.followupContext, input.toNonEmptyString)) {
return input.followupContext;
}
@ -576,6 +619,7 @@ async function buildAssistantAddressOrchestrationRuntime(input) {
});
const discoveryFollowupContextWithProofBundles = mergeBusinessOverviewProofBundlesFromSessionItems({
followupContext: discoveryFollowupContext,
userMessage: input.userMessage,
sessionItems: input.sessionItems,
toNonEmptyString: input.toNonEmptyString
});
@ -583,6 +627,7 @@ async function buildAssistantAddressOrchestrationRuntime(input) {
followupContext: discoveryFollowupContextWithProofBundles,
sessionAddressNavigationState: input.sessionAddressNavigationState,
predecomposeContract,
userMessage: input.userMessage,
toNonEmptyString: input.toNonEmptyString
});
const dialogContinuationContract = input.buildAddressDialogContinuationContractV2(input.userMessage, addressInputMessage, carryover, addressPreDecompose);

View File

@ -74,6 +74,15 @@ function normalizeAddressRuntimeMetaForDeep(value) {
mcpDiscoveryRuntimeEntryPoint: source.mcpDiscoveryRuntimeEntryPoint ?? null
};
}
function stripRoutineShortAnswerPrefix(value) {
return String(value ?? "")
.split(/\r?\n/g)
.map((line) => line
.replace(/^((?:\s|\uFEFF)*(?:[-*]\s*)?)(?:\u041a\u043e\u0440\u043e\u0442\u043a\u043e:\s*)+/u, "$1")
.replace(/(:\s*)\u041a\u043e\u0440\u043e\u0442\u043a\u043e:\s*/u, "$1"))
.join("\n")
.trim();
}
function runAssistantDeepTurnResponseRuntime(input) {
const runPackagingRuntimeSafe = input.runPackagingRuntime ?? assistantDeepTurnPackagingRuntimeAdapter_1.runAssistantDeepTurnPackagingRuntime;
const runFinalizeDeepTurnSafe = input.runFinalizeDeepTurn ?? assistantDeepTurnFinalizeRuntimeAdapter_1.finalizeAssistantDeepTurn;
@ -125,9 +134,9 @@ function runAssistantDeepTurnResponseRuntime(input) {
currentReplySource: "deep_analysis",
addressRuntimeMeta: addressRuntimeMetaForDeep
});
const assistantReply = mcpDiscoveryResponsePolicy.applied
? mcpDiscoveryResponsePolicy.reply_text
: packagingRuntime.safeAssistantReply;
const assistantReply = stripRoutineShortAnswerPrefix(mcpDiscoveryResponsePolicy.applied
? input.sanitizeReply(mcpDiscoveryResponsePolicy.reply_text, packagingRuntime.safeAssistantReply)
: packagingRuntime.safeAssistantReply);
const replyType = mcpDiscoveryResponsePolicy.applied ? "partial_coverage" : input.composition.reply_type;
const debug = {
...packagingRuntime.debug,

View File

@ -69,6 +69,10 @@ function requestsCounterpartyLeaderSurface(turnMeaning, graph) {
}
return /(?:(?:кто|как\p{L}*|покаж\p{L}*|назов\p{L}*|раскро\p{L}*)[\s\S]{0,100}(?:главн\p{L}*|крупнейш\p{L}*|основн\p{L}*|ведущ\p{L}*|топ)[\s\S]{0,80}(?:клиент|заказчик|поставщик|получател)|(?:главн\p{L}*|крупнейш\p{L}*|основн\p{L}*|ведущ\p{L}*)[\s\S]{0,80}(?:клиент|заказчик|поставщик|получател)|(?:топ[-\s]*(?:клиент|заказчик|поставщик|получател))|(?:клиент|поставщик)[\s\S]{0,80}(?:главн|крупнейш|основн|ведущ|топ))/iu.test(text);
}
function requestsBusinessOverviewNextChecks(turnMeaning, graph) {
const text = normalizedTurnAndGraphText(turnMeaning, graph);
return /(?:что\s+(?:дальше\s+)?провер\w*|что\s+провер\w*\s+дальше|следующ\w*\s+шаг|в\s+первую\s+очередь\s+провер\w*|next\s+checks?|next\s+steps?)/iu.test(text);
}
function requestsCompactCashflowAnswer(turnMeaning, graph) {
const text = normalizeQuestionText([
turnMeaning?.raw_message,
@ -429,6 +433,13 @@ function businessOverviewPeriodText(overview) {
const period = toNonEmptyString(overview.period_scope);
return period ? `за ${period}` : "за все доступное проверенное окно";
}
function businessOverviewPeriodPhrase(value) {
const normalized = toNonEmptyString(value);
if (!normalized) {
return "за все доступное проверенное окно";
}
return /^за\s+/iu.test(normalized) ? normalized : `за ${normalized}`;
}
function strongestIncomingYear(overview) {
const years = Array.isArray(overview.yearly_breakdown) ? overview.yearly_breakdown : [];
const sorted = years
@ -857,7 +868,10 @@ function buildCompactBusinessOverviewReply(entryPoint, draft) {
const compactCashflowRequested = directMoneyOnlyAnswer && requestsCompactCashflowAnswer(turnMeaning, graph);
const cashflowPolarityRequested = compactCashflowRequested && requestsCashflowPolarityAnswer(turnMeaning, graph);
const directAccountingProfitRequested = requestsDirectAccountingProfitAnswer(turnMeaning, graph);
const nextChecksRequested = requestsBusinessOverviewNextChecks(turnMeaning, graph);
const rawMessage = toNonEmptyString(turnMeaning?.raw_message) ?? toNonEmptyString(turnMeaning?.effective_message);
const earningsAmountRequested = /(?:\u0437\u0430\u0440\u0430\u0431\u043e\u0442|\u0434\u043e\u0445\u043e\u0434\p{L}*)/iu.test(normalizeQuestionText(rawMessage ?? ""));
const accountingFinancialResultForEarnings = toRecordObject(overview.accounting_financial_result);
const rawMessageComparable = compactComparable(rawMessage);
const organizationScopeComparable = compactComparable(organizationScope);
const plainOrganizationClarificationSelection = Boolean(separateSubject &&
@ -868,15 +882,51 @@ function buildCompactBusinessOverviewReply(entryPoint, draft) {
rawMessageComparable.includes(organizationScopeComparable) &&
rawMessage.length <= 90 &&
!/(?:сравн|подтвержд|деньг|сколько|что\s+|покаж|дай|вывод|нельзя|клиент|поставщик|\?)/iu.test(rawMessage));
if (compactCashflowRequested &&
!rankingNeed &&
!profitMarginBoundary &&
!cashflowPolarityRequested &&
earningsAmountRequested &&
accountingFinancialResultForEarnings) {
const direction = toNonEmptyString(accountingFinancialResultForEarnings.final_result_direction);
const amount = moneyText(accountingFinancialResultForEarnings.final_result_amount_human_ru);
const periodScope = toNonEmptyString(accountingFinancialResultForEarnings.period_scope) ?? period;
const marginPct = typeof accountingFinancialResultForEarnings.net_margin_to_revenue_pct === "number" &&
Number.isFinite(accountingFinancialResultForEarnings.net_margin_to_revenue_pct)
? `${accountingFinancialResultForEarnings.net_margin_to_revenue_pct}%`
: null;
const directionText = direction === "profit"
? "\u0443\u0447\u0435\u0442\u043d\u0430\u044f \u043f\u0440\u0438\u0431\u044b\u043b\u044c"
: direction === "loss"
? "\u0443\u0447\u0435\u0442\u043d\u044b\u0439 \u0443\u0431\u044b\u0442\u043e\u043a"
: "\u043d\u0443\u043b\u0435\u0432\u043e\u0439 \u0443\u0447\u0435\u0442\u043d\u044b\u0439 \u0444\u0438\u043d\u0440\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442";
const amountText = amount
? direction === "loss"
? `\u043c\u0438\u043d\u0443\u0441 ${amount}`
: amount
: "\u0441\u0443\u043c\u043c\u0430 \u043d\u0435 \u0440\u0430\u0441\u043f\u043e\u0437\u043d\u0430\u043d\u0430";
lines.push(`${organizationPrefix}\u0437\u0430 ${periodScope} \u043f\u043e \u0437\u0430\u043a\u0440\u044b\u0442\u0438\u044e \u0441\u0447\u0435\u0442\u043e\u0432 90/91/99 \u0432 1\u0421 \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043d\u0430 ${directionText}: ${amountText}${marginPct ? `; \u043c\u0430\u0440\u0436\u0430 \u043a \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043d\u043d\u043e\u0439 \u0432\u044b\u0440\u0443\u0447\u043a\u0435 ${marginPct}` : ""}.`);
if (incomingAmount || outgoingAmount || netAmount) {
lines.push(`\u0414\u043b\u044f \u0441\u0432\u0435\u0440\u043a\u0438 \u043f\u043e \u0434\u0435\u043d\u044c\u0433\u0430\u043c: \u043f\u043e\u043b\u0443\u0447\u0438\u043b\u0438 ${incomingAmount ?? "0 \u0440\u0443\u0431."}; \u0437\u0430\u043f\u043b\u0430\u0442\u0438\u043b\u0438/\u0441\u043f\u0438\u0441\u0430\u043b\u0438 ${outgoingAmount ?? "0 \u0440\u0443\u0431."}; \u0434\u0435\u043d\u0435\u0436\u043d\u043e\u0435 \u043d\u0435\u0442\u0442\u043e ${sentenceAmount(netAmount) ?? netAmount ?? "0 \u0440\u0443\u0431."}.`);
}
lines.push("\u0412\u0430\u0436\u043d\u043e: \u0443\u0447\u0435\u0442\u043d\u044b\u0439 \u0444\u0438\u043d\u0440\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442 \u0438 \u0434\u0432\u0438\u0436\u0435\u043d\u0438\u0435 \u0434\u0435\u043d\u0435\u0433 - \u0440\u0430\u0437\u043d\u044b\u0435 \u043c\u0435\u0442\u0440\u0438\u043a\u0438; \u043d\u0435 \u0441\u043c\u0435\u0448\u0438\u0432\u0430\u044e \u0438\u0445 \u0432 \u043e\u0434\u043d\u0443 \"\u043f\u0440\u0438\u0431\u044b\u043b\u044c\".");
return joinBusinessReplyLines(lines);
}
if (compactCashflowRequested && !rankingNeed && (incomingAmount || outgoingAmount || netAmount)) {
const netDisplay = sentenceAmount(netAmount) ?? netAmount ?? "0 \u0440\u0443\u0431.";
const signedNetDisplay = cashflowPolarityRequested && netDisplay && !String(netDisplay).trim().startsWith("-")
? `+${netDisplay}`
: netDisplay;
const polarityLead = cashflowPolarityRequested
? `\u041a\u043e\u0440\u043e\u0442\u043a\u043e: \u043f\u043e \u0434\u0435\u043d\u044c\u0433\u0430\u043c \u043f\u043b\u044e\u0441. `
: "\u041a\u043e\u0440\u043e\u0442\u043a\u043e: ";
const limitedEarningsAnswer = Boolean(earningsAmountRequested && !accountingFinancialResultForEarnings && limitLine);
const polarityLead = limitedEarningsAnswer
? "\u041a\u043e\u0440\u043e\u0442\u043a\u043e: \u0442\u043e\u0447\u043d\u0443\u044e \u0443\u0447\u0435\u0442\u043d\u0443\u044e \u043f\u0440\u0438\u0431\u044b\u043b\u044c \u0437\u0430 \u044d\u0442\u043e \u043e\u043a\u043d\u043e \u043d\u0435 \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0430\u044e; \u0432 \u043e\u0433\u0440\u0430\u043d\u0438\u0447\u0435\u043d\u043d\u043e\u043c \u0434\u0435\u043d\u0435\u0436\u043d\u043e\u043c \u0441\u0440\u0435\u0437\u0435 "
: cashflowPolarityRequested
? `\u041a\u043e\u0440\u043e\u0442\u043a\u043e: \u043f\u043e \u0434\u0435\u043d\u044c\u0433\u0430\u043c \u043f\u043b\u044e\u0441. `
: "\u041a\u043e\u0440\u043e\u0442\u043a\u043e: ";
lines.push(`${polarityLead}${organizationPrefix}${period} \u043f\u043e\u043b\u0443\u0447\u0438\u043b\u0438 ${incomingAmount ?? "0 \u0440\u0443\u0431."}; \u0437\u0430\u043f\u043b\u0430\u0442\u0438\u043b\u0438/\u0441\u043f\u0438\u0441\u0430\u043b\u0438 ${outgoingAmount ?? "0 \u0440\u0443\u0431."}; \u0434\u0435\u043d\u0435\u0436\u043d\u043e\u0435 \u043d\u0435\u0442\u0442\u043e ${signedNetDisplay}.`);
if (limitLine) {
lines.push(limitLine);
}
lines.push(cashflowPolarityRequested
? "\u042d\u0442\u043e \u0434\u0435\u043d\u0435\u0436\u043d\u044b\u0439 \u043f\u043e\u0442\u043e\u043a \u043f\u043e \u043d\u0430\u0439\u0434\u0435\u043d\u043d\u044b\u043c \u0441\u0442\u0440\u043e\u043a\u0430\u043c 1\u0421, \u043d\u0435 \u0431\u0443\u0445\u0433\u0430\u043b\u0442\u0435\u0440\u0441\u043a\u0438\u0439 \u0444\u0438\u043d\u0440\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442."
: "\u042d\u0442\u043e \u0434\u0435\u043d\u0435\u0436\u043d\u044b\u0439 \u043f\u043e\u0442\u043e\u043a \u043f\u043e \u043d\u0430\u0439\u0434\u0435\u043d\u043d\u044b\u043c \u0441\u0442\u0440\u043e\u043a\u0430\u043c 1\u0421, \u043d\u0435 \u0447\u0438\u0441\u0442\u0430\u044f \u043f\u0440\u0438\u0431\u044b\u043b\u044c \u0438 \u043d\u0435 \u0431\u0443\u0445\u0433\u0430\u043b\u0442\u0435\u0440\u0441\u043a\u0438\u0439 \u0444\u0438\u043d\u0440\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442.");
@ -921,11 +971,7 @@ function buildCompactBusinessOverviewReply(entryPoint, draft) {
: "Это учетный финрезультат по найденным строкам закрытия периода в 1С, а не внешний аудит и не юридически подтвержденная отчетность.");
return joinBusinessReplyLines(lines);
}
const headline = toNonEmptyString(draft.headline);
const cleanHeadline = headline?.replace(/^Коротко:\s*/iu, "").trim();
lines.push(cleanHeadline
? `Коротко: ${localizeLine(cleanHeadline)}`
: "Коротко: нельзя точно подтвердить чистую прибыль и маржу по текущему срезу 1С; есть только ограниченный операционный денежный/товарный сигнал, а не полный отчет о прибыли и не бухгалтерский финансовый результат.");
lines.push("Коротко: чистую прибыль и маржу не подтверждаю по текущему срезу 1С; видны только ограниченные денежные обороты и расчетное нетто/операционный сигнал, а не полный отчет о прибыли и не бухгалтерский финансовый результат.");
const boundaryLines = userFacingLines([
...toStringList(draft.confirmed_lines),
...toStringList(draft.inference_lines),
@ -1005,9 +1051,12 @@ function buildCompactBusinessOverviewReply(entryPoint, draft) {
? `${vendorProcurementQuality.top_non_financial_supplier_share_pct}%`
: null;
const periodScope = toNonEmptyString(vendorProcurementQuality.period_scope) ?? period;
const totalText = totalOutgoing ? `; всего исходящих платежей в проверенном срезе ${totalOutgoing}` : "";
const periodPhrase = businessOverviewPeriodPhrase(periodScope);
const totalText = totalOutgoing
? `; всего исходящих платежей в проверенном срезе ${sentenceAmount(totalOutgoing)}`
: "";
if (status === "financial_institution_leads_outgoing_cash") {
lines.push(`Коротко: проверка концентрации закупок/исходящих платежей за ${periodScope} не подтверждает зависимость от обычного поставщика: крупнейший получатель исходящих денег ${topOutgoingName ?? "не распознан"}${topOutgoingShare ? ` держит около ${topOutgoingShare}` : ""}${topOutgoingAmount ? ` (${topOutgoingAmount})` : ""}, но по названию это банк/финансовая организация${totalText}.`);
lines.push(`Коротко: проверка концентрации закупок/исходящих платежей ${periodPhrase} не подтверждает зависимость от обычного поставщика: крупнейший получатель исходящих денег ${topOutgoingName ?? "не распознан"}${topOutgoingShare ? ` держит около ${topOutgoingShare}` : ""}${topOutgoingAmount ? ` (${topOutgoingAmount})` : ""}, но по названию это банк/финансовая организация${totalText}.`);
const financialHintText = financialFlowHintTextRuFromRecord(topOutgoingRecord);
if (financialHintText) {
lines.push(financialHintText);
@ -1017,17 +1066,17 @@ function buildCompactBusinessOverviewReply(entryPoint, draft) {
}
}
else if (status === "reviewed_procurement_concentration") {
lines.push(`Коротко: точный риск зависимости от одного поставщика не подтвержден полностью; проверка концентрации закупок/исходящих платежей за ${periodScope} нашла крупнейшего получателя исходящего потока: ${topOutgoingName ?? nonFinancialName ?? "получатель не распознан"}${topOutgoingShare ? ` держит около ${topOutgoingShare}` : nonFinancialShare ? ` держит около ${nonFinancialShare}` : ""}${topOutgoingAmount ? ` (${topOutgoingAmount})` : nonFinancialAmount ? ` (${nonFinancialAmount})` : ""}${totalText}.`);
lines.push(`Коротко: точный риск зависимости от одного поставщика не подтвержден полностью; проверка концентрации закупок/исходящих платежей ${periodPhrase} нашла крупнейшего получателя исходящего потока: ${topOutgoingName ?? nonFinancialName ?? "получатель не распознан"}${topOutgoingShare ? ` держит около ${topOutgoingShare}` : nonFinancialShare ? ` держит около ${nonFinancialShare}` : ""}${topOutgoingAmount ? ` (${topOutgoingAmount})` : nonFinancialAmount ? ` (${nonFinancialAmount})` : ""}${totalText}.`);
}
else {
lines.push(`Коротко: проверка концентрации закупок/исходящих платежей за ${periodScope} выполнена, но надежной небанковской концентрации поставщика по найденным исходящим платежам не хватает${totalText}.`);
lines.push(`Коротко: проверка концентрации закупок/исходящих платежей ${periodPhrase} выполнена, но надежной небанковской концентрации поставщика по найденным исходящим платежам не хватает${totalText}.`);
}
const contractText = typeof vendorProcurementQuality.used_contracts === "number" && Number.isFinite(vendorProcurementQuality.used_contracts)
? typeof vendorProcurementQuality.total_contracts === "number" && Number.isFinite(vendorProcurementQuality.total_contracts)
? ` Договорный профиль: используется ${vendorProcurementQuality.used_contracts}/${vendorProcurementQuality.total_contracts} договоров${typeof vendorProcurementQuality.used_contract_share_pct === "number" && Number.isFinite(vendorProcurementQuality.used_contract_share_pct) ? ` (${vendorProcurementQuality.used_contract_share_pct}%)` : ""}.`
: ` Договорный профиль: используется ${vendorProcurementQuality.used_contracts} договоров.`
: "";
lines.push(`Что не доказано этим срезом: надежность поставщика, качество поставок, договорные условия, назначение каждого платежа и полная структура всех расходов.${contractText}`);
lines.push(`Это не аудит поставщиков: срез не доказывает надежность поставщика, качество поставок, договорные условия, назначение каждого платежа и полную структуру всех расходов.${contractText}`);
return joinBusinessReplyLines(lines);
}
const supplierBasis = topSupplier
@ -1068,6 +1117,19 @@ function buildCompactBusinessOverviewReply(entryPoint, draft) {
lines.push("Проверить нужно отдельно: складской срез на дату, учетную политику резервов, списания и ликвидационную стоимость; косвенные признаки нельзя выдавать за доказанный факт резерва.");
return joinBusinessReplyLines(lines);
}
if (!separateSubject &&
!crossScopeExecutiveSummary &&
!counterpartyLeaderSurfaceRequested &&
!rankingNeed &&
nextChecksRequested) {
const subject = organizationScope ? `по компании ${organizationScope}` : "по компании";
lines.push(`В первую очередь ${subject} надо проверить чистую прибыль через закрытие 90/91/99, зависимость от топ-3 клиентов и поставщиков, долги/зависшие расчеты, складские остатки и НДС-позицию.`);
lines.push("Смысл порядка: сначала отделить бухгалтерский финрезультат от денежного нетто, затем проверить концентрацию потоков и только после этого углубляться в склад, долги и налоговую позицию.");
if (limitLine) {
lines.push(limitLine);
}
return joinBusinessReplyLines(lines);
}
if (!separateSubject &&
!crossScopeExecutiveSummary &&
!counterpartyLeaderSurfaceRequested &&
@ -1075,8 +1137,8 @@ function buildCompactBusinessOverviewReply(entryPoint, draft) {
(actionFamily === "broad_evaluation" || unsupportedFamily === "broad_business_evaluation")) {
const subject = organizationScope ?? "компания";
const periodWithoutPrefix = period.replace(/^за\s+/iu, "");
lines.push(`Коротко: по доступным данным ${subject} выглядит как бизнес с крупными контрактными денежными потоками и заметной зависимостью от нескольких крупных контрагентов, а не как равномерный поток мелких продаж.`);
lines.push("Что видно:");
lines.push(`Коротко: по ограниченному проверенному 1С-срезу ${subject} выглядит как бизнес с крупными контрактными денежными потоками и заметной зависимостью от нескольких крупных контрагентов, а не как равномерный поток мелких продаж; это не аудиторское заключение и не подтвержденная чистая прибыль.`);
lines.push("Что видно в ограниченном денежном срезе:");
if (incomingAmount) {
lines.push(`- входящие деньги за ${periodWithoutPrefix}: ${incomingAmount};`);
}
@ -1108,7 +1170,9 @@ function buildCompactBusinessOverviewReply(entryPoint, draft) {
if (debtLine) {
lines.push(`- ${localizeLine(debtLine)}`);
}
lines.push("Ограничение: это оценка по денежным потокам и найденным срезам 1С, не аудиторское заключение и не подтвержденная чистая прибыль.");
if (!limitLine) {
lines.push("Ограничение: это оценка по денежным потокам и найденным срезам 1С, не аудиторское заключение и не подтвержденная чистая прибыль.");
}
const missingOverviewFamilies = [];
if (!taxLine) {
missingOverviewFamilies.push("НДС/налоговая позиция без отдельного точного расчета");

View File

@ -52,6 +52,15 @@ function hasInternalMechanics(value) {
text.includes("catalog_") ||
text.includes("select "));
}
function stripRoutineShortAnswerPrefix(value) {
return String(value ?? "")
.split(/\r?\n/g)
.map((line) => line
.replace(/^((?:\s|\uFEFF)*(?:[-*]\s*)?)(?:\u041a\u043e\u0440\u043e\u0442\u043a\u043e:\s*)+/u, "$1")
.replace(/(:\s*)\u041a\u043e\u0440\u043e\u0442\u043a\u043e:\s*/u, "$1"))
.join("\n")
.trim();
}
function isMcpDiscoveryEntryPointContract(value) {
const record = toRecordObject(value);
return (record?.schema_version === "assistant_mcp_discovery_runtime_entry_point_v1" &&
@ -797,12 +806,13 @@ function applyAssistantMcpDiscoveryResponsePolicy(input) {
};
}
pushReason(reasonCodes, "mcp_discovery_response_policy_candidate_applied");
const replyText = stripRoutineShortAnswerPrefix(String(candidate.reply_text));
return {
schema_version: exports.ASSISTANT_MCP_DISCOVERY_RESPONSE_POLICY_SCHEMA_VERSION,
policy_owner: "assistantMcpDiscoveryResponsePolicy",
decision: "apply_candidate",
applied: true,
reply_text: String(candidate.reply_text),
reply_text: replyText || String(candidate.reply_text),
reply_source: "mcp_discovery_response_candidate_guarded",
candidate,
reason_codes: reasonCodes

View File

@ -170,6 +170,7 @@ function isGarbageSemanticAnchorCandidate(value) {
}
if (/^(?:по\s+)?(?:этим|этими)\s+данн\p{L}*$/iu.test(text) ||
/^(?:и\s+)?кто\s+(?:главн\p{L}*|основн\p{L}*|крупн\p{L}*)\s+(?:клиент|покупател|поставщик|контрагент)(?:\s+в)?$/iu.test(text) ||
/^(?:и\s+)?(?:главн\p{L}*|основн\p{L}*|крупн\p{L}*)\s+(?:клиент|покупател|поставщик|контрагент)(?:\s+в)?$/iu.test(text) ||
/^(?:или\s+)?(?:обычн\p{L}*\s+)?(?:клиент|поставщик|покупател\p{L}*|заказчик|контрагент)(?:\s+или\s+(?:клиент|поставщик|покупател\p{L}*|заказчик|контрагент))?$/iu.test(text) ||
/^(?:что|чего)\s+(?:подтвержден\p{L}*|не\s+хватает)/iu.test(text) ||
/^(?:можно\s+ли|если\s+нет|дай\s+proxy|дай\s+прокси)/iu.test(text)) {
@ -675,6 +676,15 @@ function hasDebtDueDateBoundaryFollowupSignal(text) {
const hasFollowupShape = /(?:\u0442\u043e\s+\u0435\u0441\u0442\u044c|\u043f\u043e\u0447\u0435\u043c\u0443|\u043a\u043e\u0440\u043e\u0442\w*|\u043d\u0435\u043b\u044c\u0437\u044f|\u043c\u043e\u0436\u043d\u043e\s+\u043b\u0438|\u0437\u043d\u0430\u0447\u0438\u0442|\u0432\u044b\u0445\u043e\u0434\u0438\u0442|why|short|brief|so)/iu.test(text);
return hasDueDateCue && hasFollowupShape;
}
function hasSupplierDependencyFollowupSignal(text) {
if (!text) {
return false;
}
const normalized = compactLower(text);
const hasSupplierOrOutgoingCue = /(?:\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u0437\u0430\u043a\u0443\u043f|\u0438\u0441\u0445\u043e\u0434\u044f\u0449|\u043f\u043b\u0430\u0442[\u0435\u0451]\u0436|\u0437\u0430\u043f\u043b\u0430\u0442|supplier|vendor|procurement|outgoing|payout)/iu.test(normalized);
const hasDependencyCue = /(?:\u0437\u0430\u0432\u0438\u0441\u0438\u043c|\u0437\u0430\u0432\u044f\u0437\u0430\u043d|\u043a\u043e\u043d\u0446\u0435\u043d\u0442\u0440\u0430\u0446|\u043a\u043b\u044e\u0447\u0435\u0432|\u043e\u0441\u043d\u043e\u0432\u043d|\u043a\u0440\u0438\u0442\u0438\u0447|dependency|concentration|key|critical)/iu.test(normalized);
return hasSupplierOrOutgoingCue && hasDependencyCue;
}
function hasOrganizationLevelDebtDueDateOverviewSignal(text) {
if (!text) {
return false;
@ -770,7 +780,7 @@ function hasBusinessOverviewContinuationSignal(text) {
}
const normalized = compactLower(text);
const hasEvidenceContinuationCue = /(?:по\s+этим\s+данн|на\s+базе\s+этих\s+данн|из\s+этого|по\s+итогам|что\s+подтвержден|чего\s+не\s+хватает|что\s+пока\s+нельзя|что\s+можно\s+сказать|какой\s+вывод|итогов|резюм|вывод)/iu.test(normalized);
const hasAnalystContinuationCue = /(?:можно\s+ли|если\s+нет|proxy|прокси|аудит|оцен|что\s+думаешь|нормальн\p{L}*\s+прибыл|прибыл|марж|рентаб|ликвидн|просроч|качество\s+долг|риск|налогов\p{L}*\s+вывод)/iu.test(normalized);
const hasAnalystContinuationCue = /(?:можно\s+ли|если\s+нет|proxy|прокси|аудит|оцен|что\s+думаешь|нормальн\p{L}*\s+прибыл|прибыл|марж|рентаб|ликвидн|просроч|качество\s+долг|риск|зависим|завязан|концентрац|налогов\p{L}*\s+вывод)/iu.test(normalized);
const hasTaxContinuationCue = /(?:ндс|vat)[\s\S]{0,120}(?:позици|период|основан|не\s+хватает|налогов\p{L}*\s+вывод)|(?:позици|налогов\p{L}*\s+вывод)[\s\S]{0,80}(?:ндс|vat)/iu.test(normalized);
const hasFinalSummaryCue = /(?:\u0447\u0442\u043e\s+\u043c\u044b\s+\u0437\u043d\u0430\u0435\u043c|\u0447\u0442\u043e\s+\u043f\u043e\u043d\u044f\u0442\u043d\u043e|\u0447\u0442\u043e\s+\u043f\u0440\u043e\u0432\u0435\u0440\w*\s+\u0434\u0430\u043b\u044c\u0448\u0435|\u0441\u043b\u0435\u0434\u0443\u044e\u0449\w*\s+\u0448\u0430\u0433|\u0438\u0442\u043e\u0433\w*\s+\u0432\u044b\u0432\u043e\u0434|\u043a\u0430\u043a\u043e\u0439\s+\u0432\u044b\u0432\u043e\u0434|\u0447\u0442\u043e\s+\u0441\s+\u044d\u0442\u0438\u043c\s+\u0434\u0435\u043b\u0430\u0442\u044c|what\s+do\s+we\s+know|what\s+is\s+missing|next\s+step|final\s+summary)/iu.test(normalized);
const hasMoneyBreakdownCue = /(?:\u0440\u0430\u0441\u043a\u0440\u043e\p{L}*\s+\u0434\u0435\u043d\p{L}*|\u0441\u043a\u043e\u043b\u044c\u043a\u043e\s+\u0432\u0441\u0435\u0433\u043e\s+\u043f\u043e\u043b\u0443\u0447|\u0441\u043a\u043e\u043b\u044c\u043a\u043e\s+(?:\u0432\u0441\u0435\u0433\u043e\s+)?\u0437\u0430\u043f\u043b\u0430\u0442|\u0447\u0438\u0441\u0442\p{L}*\s+\u0434\u0435\u043d\u0435\u0436\u043d\p{L}*\s+\u043f\u043e\u0442\u043e\u043a|\u0433\u043b\u0430\u0432\u043d\p{L}*\s+(?:\u043a\u043b\u0438\u0435\u043d\u0442|\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a)|top\s+(?:customer|supplier)|cash\s+breakdown)/iu.test(normalized) &&
@ -1338,7 +1348,9 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
const debtDueDateBusinessOverviewSignal = ((rawBusinessOverviewSignal || seededBusinessOverviewSignal) &&
hasOrganizationLevelDebtDueDateOverviewSignal(rawText)) ||
debtDueDateFollowupBusinessOverviewSignal;
const supplierQualityBusinessOverviewSignal = (rawBusinessOverviewSignal || seededBusinessOverviewSignal) && hasOrganizationLevelSupplierQualityOverviewSignal(rawText);
const supplierQualityBusinessOverviewSignal = ((rawBusinessOverviewSignal || seededBusinessOverviewSignal) &&
(hasOrganizationLevelSupplierQualityOverviewSignal(rawText) || hasSupplierDependencyFollowupSignal(rawText))) ||
(businessOverviewContinuationSignal && hasSupplierDependencyFollowupSignal(rawText));
const profitMarginFollowupBusinessOverviewSignal = businessOverviewContinuationSignal && hasProfitMarginBoundaryFollowupSignal(rawText);
const profitMarginBusinessOverviewSignal = ((rawBusinessOverviewSignal || seededBusinessOverviewSignal) &&
hasOrganizationLevelProfitMarginBoundaryOverviewSignal(rawText)) ||
@ -1823,11 +1835,13 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
? null
: explicitCurrentCounterpartyOverridesFollowupEntity
? null
: rawMetadataScopeHint ??
followupSeed.metadataScopeHint ??
followupSeed.discoveryEntity ??
followupSeed.metadataSelectedEntitySet ??
null;
: businessOverviewSuppressesFollowupCounterparty
? rawMetadataScopeHint
: rawMetadataScopeHint ??
followupSeed.metadataScopeHint ??
followupSeed.discoveryEntity ??
followupSeed.metadataSelectedEntitySet ??
null;
const metadataScopedLaneWithoutSubject = Boolean((metadataGroundedMovementLaneApplicable ||
metadataGroundedDocumentLaneApplicable ||
explicitVatMovementEvidenceSignal) &&
@ -2013,7 +2027,7 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
/^\d{4}$/.test(rawDateScope) &&
normalizedPredecomposeDateScope &&
normalizedPredecomposeDateScope.startsWith(`${rawDateScope}-`));
const followupAllTimeScopeApplied = normalizedFollowupDateScope === "all_time_scope";
const followupAllTimeScopeApplied = normalizedFollowupDateScope === "all_time_scope" && !currentTurnCarriesExplicitPeriod;
const explicitDateScope = rawAllTimeScopeSignal || followupAllTimeScopeApplied
? null
: normalizedAssistantTurnMeaningDateScope ??
@ -2386,6 +2400,9 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
if (debtDueDateFollowupBusinessOverviewSignal) {
pushReason(reasonCodes, "mcp_discovery_business_overview_debt_due_date_followup_boundary");
}
if (supplierQualityBusinessOverviewSignal && hasSupplierDependencyFollowupSignal(rawText)) {
pushReason(reasonCodes, "mcp_discovery_business_overview_supplier_dependency_followup_boundary");
}
if (explicitVatSuppressesBusinessOverviewContinuation) {
pushReason(reasonCodes, "mcp_discovery_business_overview_continuation_suppressed_by_explicit_vat_question");
}

View File

@ -2025,7 +2025,11 @@ function repairAddressMojibake(value) {
}
function sanitizeOutgoingAssistantText(value, fallback = "Не смог сформировать читаемый ответ. Уточните запрос.") {
const repaired = repairAddressMojibake(String(value ?? ""));
const sanitized = String((0, answerComposer_1.sanitizeAssistantReplyForUserFacing)(repaired) ?? "").trim();
const sanitized = String((0, answerComposer_1.sanitizeAssistantReplyForUserFacing)(repaired) ?? "")
.split(/\r?\n/g)
.map((line) => line.replace(/^Коротко:\s*/u, "").replace(/(:\s*)Коротко:\s*/u, "$1"))
.join("\n")
.trim();
if (sanitized) {
return sanitized;
}

View File

@ -182,7 +182,7 @@ function createAssistantTransitionPolicy(deps) {
}
return `${match[3]}-${match[2]}-${match[1]}`;
}
function computeMonthWindowFromIso(isoDate) {
function computeMonthWindowFromIso(isoDate, bridgeFacts = {}) {
const match = String(isoDate ?? "").match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (!match) {
return null;
@ -199,10 +199,15 @@ function createAssistantTransitionPolicy(deps) {
purchase_date: isoDate,
period_from: `${year}-${mm}-01`,
period_to: `${year}-${mm}-${dd}`,
as_of_date: `${year}-${mm}-${dd}`
as_of_date: `${year}-${mm}-${dd}`,
purchase_date_bridge_selected: isoDate,
...bridgeFacts
};
}
function extractEarliestDmyDateFromEntityRefs(entityRefs) {
function uniqueSortedIsoDates(dates) {
return Array.from(new Set(dates.filter(Boolean))).sort();
}
function extractDmyDatesFromEntityRefs(entityRefs) {
const dates = [];
for (const entityRef of Array.isArray(entityRefs) ? entityRefs : []) {
const value = deps.toNonEmptyString(entityRef?.value);
@ -220,32 +225,80 @@ function createAssistantTransitionPolicy(deps) {
}
}
}
if (dates.length === 0) {
return uniqueSortedIsoDates(dates);
}
function detectPurchaseDateBridgeBasis(userMessage, alternateMessage) {
const text = [userMessage, alternateMessage]
.map((item) => deps.compactWhitespace(deps.repairAddressMojibake(String(item ?? "")).toLowerCase()).replace(/ё/g, "е"))
.filter(Boolean)
.join(" ");
if (/(?:последн|latest|last|сам[а-я\s-]*поздн)/iu.test(text)) {
return "last_confirmed_purchase";
}
if (/(?:перв|earliest|oldest|ранн|сам[а-я\s-]*стар)/iu.test(text)) {
return "first_confirmed_purchase";
}
return "first_confirmed_purchase_default";
}
function extractPurchaseDateBridgeFacts(previousAddressItem, addressNavigationState, userMessage, alternateMessage) {
const previousFilters = (0, assistantContinuityPolicy_1.readAddressDebugFilters)(previousAddressItem?.debug ?? null);
let firstIsoDate = deps.toNonEmptyString(previousFilters?.purchase_date_bridge_first);
let lastIsoDate = deps.toNonEmptyString(previousFilters?.purchase_date_bridge_last);
const selectedIsoDateFromFilters = deps.toNonEmptyString(previousFilters?.purchase_date_bridge_selected);
if (!firstIsoDate && selectedIsoDateFromFilters) {
firstIsoDate = selectedIsoDateFromFilters;
}
if (!lastIsoDate && selectedIsoDateFromFilters) {
lastIsoDate = selectedIsoDateFromFilters;
}
const replyText = deps.toNonEmptyString(previousAddressItem?.text);
if (replyText && (!firstIsoDate || !lastIsoDate)) {
const repairedText = deps.repairAddressMojibake(replyText);
const explicitFirstDateMatch = repairedText.match(/первая\s+(?:найденная|подтвержденная)\s+дата\s+закупки:\s*(\d{2}\.\d{2}\.\d{4})/iu);
const explicitLastDateMatch = repairedText.match(/последняя\s+(?:найденная|подтвержденная)\s+дата\s+закупки:\s*(\d{2}\.\d{2}\.\d{4})/iu);
firstIsoDate = explicitFirstDateMatch ? parseDmyDateToIso(explicitFirstDateMatch[1]) : null;
lastIsoDate = explicitLastDateMatch ? parseDmyDateToIso(explicitLastDateMatch[1]) : null;
}
if (!firstIsoDate || !lastIsoDate) {
const navigationSessionState = (0, assistantContinuityPolicy_1.resolveNavigationSessionContextState)(addressNavigationState, deps.toNonEmptyString, deps.normalizeOrganizationScopeValue);
const focusObject = navigationSessionState.focusObject;
const preferredResultSetId = deps.toNonEmptyString(focusObject?.provenanceResultSetId) ?? navigationSessionState.activeResultSetId;
const resultSets = Array.isArray(addressNavigationState?.result_sets) ? addressNavigationState.result_sets : [];
const preferredResultSet = (preferredResultSetId
? resultSets.find((item) => deps.toNonEmptyString(item?.result_set_id) === preferredResultSetId) ?? null
: null) ??
resultSets.find((item) => deps.toNonEmptyString(item?.intent) === "inventory_purchase_provenance_for_item") ??
null;
const datesFromResultSet = extractDmyDatesFromEntityRefs(preferredResultSet?.entity_refs);
firstIsoDate = firstIsoDate ?? datesFromResultSet[0] ?? null;
lastIsoDate = lastIsoDate ?? datesFromResultSet[datesFromResultSet.length - 1] ?? null;
}
if (!firstIsoDate && !lastIsoDate) {
return null;
}
return dates.sort()[0] ?? null;
const normalizedFirst = firstIsoDate ?? lastIsoDate;
const normalizedLast = lastIsoDate ?? firstIsoDate;
const basis = detectPurchaseDateBridgeBasis(userMessage, alternateMessage);
const selectedIsoDate = basis === "last_confirmed_purchase" ? normalizedLast : normalizedFirst;
return {
selectedIsoDate,
firstIsoDate: normalizedFirst,
lastIsoDate: normalizedLast,
basis,
hasMultiplePurchaseDates: Boolean(normalizedFirst && normalizedLast && normalizedFirst !== normalizedLast)
};
}
function extractPurchaseDateBridgeWindow(previousAddressItem, addressNavigationState) {
const replyText = deps.toNonEmptyString(previousAddressItem?.text);
if (replyText) {
const repairedText = deps.repairAddressMojibake(replyText);
const explicitFirstDateMatch = repairedText.match(/первая\s+найденная\s+дата\s+закупки:\s*(\d{2}\.\d{2}\.\d{4})/iu);
const explicitFirstDateIso = explicitFirstDateMatch ? parseDmyDateToIso(explicitFirstDateMatch[1]) : null;
if (explicitFirstDateIso) {
return computeMonthWindowFromIso(explicitFirstDateIso);
}
function extractPurchaseDateBridgeWindow(previousAddressItem, addressNavigationState, userMessage, alternateMessage) {
const facts = extractPurchaseDateBridgeFacts(previousAddressItem, addressNavigationState, userMessage, alternateMessage);
if (!facts?.selectedIsoDate) {
return null;
}
const navigationSessionState = (0, assistantContinuityPolicy_1.resolveNavigationSessionContextState)(addressNavigationState, deps.toNonEmptyString, deps.normalizeOrganizationScopeValue);
const focusObject = navigationSessionState.focusObject;
const preferredResultSetId = deps.toNonEmptyString(focusObject?.provenanceResultSetId) ?? navigationSessionState.activeResultSetId;
const resultSets = Array.isArray(addressNavigationState?.result_sets) ? addressNavigationState.result_sets : [];
const preferredResultSet = (preferredResultSetId
? resultSets.find((item) => deps.toNonEmptyString(item?.result_set_id) === preferredResultSetId) ?? null
: null) ??
resultSets.find((item) => deps.toNonEmptyString(item?.intent) === "inventory_purchase_provenance_for_item") ??
null;
const earliestIsoDate = extractEarliestDmyDateFromEntityRefs(preferredResultSet?.entity_refs);
return earliestIsoDate ? computeMonthWindowFromIso(earliestIsoDate) : null;
return computeMonthWindowFromIso(facts.selectedIsoDate, {
purchase_date_bridge_first: facts.firstIsoDate,
purchase_date_bridge_last: facts.lastIsoDate,
purchase_date_bridge_basis: facts.basis,
purchase_date_bridge_has_multiple: facts.hasMultiplePurchaseDates
});
}
function isUsableFollowupSourceDebug(debug) {
if (!debug || typeof debug !== "object") {
@ -460,10 +513,12 @@ function createAssistantTransitionPolicy(deps) {
}
return null;
}
function hasInventoryPurchaseDateVatBridgeSignal(userMessage, alternateMessage, sourceIntentHint, hasInventoryItemFocusHint) {
function hasInventoryPurchaseDateVatBridgeSignal(userMessage, alternateMessage, sourceIntentHint, hasInventoryItemFocusHint, sourceHasPurchaseDateBridgeHint = false) {
const vatPurchaseBridgeContinuation = sourceIntentHint === "vat_liability_confirmed_for_tax_period" && sourceHasPurchaseDateBridgeHint;
if (sourceIntentHint !== "inventory_purchase_provenance_for_item" &&
!hasInventoryItemFocusHint &&
!deps.isInventorySelectedObjectIntent(sourceIntentHint)) {
!deps.isInventorySelectedObjectIntent(sourceIntentHint) &&
!vatPurchaseBridgeContinuation) {
return false;
}
const samples = [userMessage, alternateMessage]
@ -472,8 +527,11 @@ function createAssistantTransitionPolicy(deps) {
if (samples.length === 0) {
return false;
}
return samples.some((sample) => /(?:ндс|vat)/iu.test(sample) &&
/(?:на\s+дат[ауеы]\s+покупк|на\s+дат[ауеы]\s+закупк|по\s+дат[еу]\s+покупк|по\s+дат[еу]\s+закупк|дата\s+покупк|дата\s+закупк|purchase\s+date)/iu.test(sample));
return samples.some((sample) => ((/(?:ндс|vat)/iu.test(sample) &&
/(?:на\s+дат[ауеы]\s+покупк|на\s+дат[ауеы]\s+закупк|по\s+дат[еу]\s+покупк|по\s+дат[еу]\s+закупк|дата\s+покупк|дата\s+закупк|purchase\s+date)/iu.test(sample)) ||
((vatPurchaseBridgeContinuation || sourceIntentHint === "vat_liability_confirmed_for_tax_period") &&
/(?:перв|последн|earliest|oldest|latest|last)/iu.test(sample) &&
/(?:покупк|закупк|purchase)/iu.test(sample))));
}
function hasInventoryRootRestatementLikeSignal(userMessage, sourceIntentHint, hasInventoryRootFrame) {
if (!hasInventoryRootFrame) {
@ -701,12 +759,17 @@ function createAssistantTransitionPolicy(deps) {
const hasBusinessOverviewCarryoverSourceHint = sourceDiscoveryPilotScopeHint === "business_overview_route_template_v1";
const navigationSessionState = earlyNavigationSessionState;
const navigationFocusObjectHint = navigationSessionState.focusObject;
const sourceFiltersHint = (0, assistantContinuityPolicy_1.readAddressDebugFilters)(carryoverSourceDebug);
const sourceHasPurchaseDateBridgeHint = Boolean(deps.toNonEmptyString(sourceFiltersHint?.purchase_date_bridge_selected));
const hasNavigationInventoryItemFocusHint = Boolean(deps.toNonEmptyString(navigationFocusObjectHint?.label) &&
deps.toNonEmptyString(navigationFocusObjectHint?.objectType) === "item" &&
(sourceIntentHint === "inventory_on_hand_as_of_date" ||
sourceIntentHint === "vat_liability_confirmed_for_tax_period" ||
sourceIntentHint === "vat_payable_forecast" ||
sourceIntentHint === "vat_payable_confirmed_as_of_date" ||
sourceIntentHint === "inventory_supplier_stock_overlap_as_of_date" ||
deps.isInventorySelectedObjectIntent(sourceIntentHint)));
const inventoryPurchaseDateVatBridge = hasInventoryPurchaseDateVatBridgeSignal(userMessage, alternateMessage, sourceIntentHint, hasNavigationInventoryItemFocusHint);
const inventoryPurchaseDateVatBridge = hasInventoryPurchaseDateVatBridgeSignal(userMessage, alternateMessage, sourceIntentHint, hasNavigationInventoryItemFocusHint, sourceHasPurchaseDateBridgeHint);
const inventoryMarginRankingFollowup = hasInventoryMarginRankingFollowupSignal(userMessage, alternateMessage, sourceIntentHint);
let inventoryShortFollowupPrimary = (deps.isInventorySelectedObjectIntent(sourceIntentHint) || hasNavigationInventoryItemFocusHint) &&
deps.hasShortInventoryObjectFollowupSignal(userMessage);
@ -1092,10 +1155,16 @@ function createAssistantTransitionPolicy(deps) {
: findRecentInventoryPurchaseProvenanceItem(items, deps.toNonEmptyString(navigationFocusObjectLabel) ??
(0, assistantContinuityPolicy_1.readAddressDebugItem)(carryoverSourceDebug, deps.toNonEmptyString) ??
deps.toNonEmptyString(previousFilters.item)) ?? previousAddressItem;
const purchaseBridgeWindow = extractPurchaseDateBridgeWindow(purchaseBridgeItem, addressNavigationState);
const purchaseBridgeWindow = extractPurchaseDateBridgeWindow(purchaseBridgeItem, addressNavigationState, userMessage, alternateMessage);
if (purchaseBridgeWindow) {
previousFilters.period_from = purchaseBridgeWindow.period_from;
previousFilters.period_to = purchaseBridgeWindow.period_to;
previousFilters.as_of_date = purchaseBridgeWindow.as_of_date;
previousFilters.purchase_date_bridge_selected = purchaseBridgeWindow.purchase_date_bridge_selected;
previousFilters.purchase_date_bridge_first = purchaseBridgeWindow.purchase_date_bridge_first;
previousFilters.purchase_date_bridge_last = purchaseBridgeWindow.purchase_date_bridge_last;
previousFilters.purchase_date_bridge_basis = purchaseBridgeWindow.purchase_date_bridge_basis;
previousFilters.purchase_date_bridge_has_multiple = purchaseBridgeWindow.purchase_date_bridge_has_multiple;
}
}
previousFilters = (0, assistantContinuityPolicy_1.applyTemporalCarryoverFilters)(previousFilters, navigationDateScope, continuityTemporalScope, sourceIntentHint, deps.toNonEmptyString);

View File

@ -309,6 +309,17 @@ function detectBroadBusinessEvaluation(text) {
family: "broad_business_evaluation"
};
}
if (/(?:что\s+(?:дальше\s+)?провер\w*|что\s+провер\w*\s+дальше|следующ\w*\s+шаг|в\s+первую\s+очередь\s+провер\w*|next\s+checks?|next\s+steps?)/iu.test(normalized)) {
return {
family: "broad_business_evaluation"
};
}
if (/(?:поставщик|закуп|исходящ|плат[её]ж|supplier|vendor|procurement|outgoing|payout)/iu.test(normalized) &&
/(?:зависим|завязан|концентрац|ключев|основн|критич|dependency|concentration|key|critical)/iu.test(normalized)) {
return {
family: "broad_business_evaluation"
};
}
if (hasOrganizationLevelEarningsOverviewSignal(normalized)) {
return {
family: "broad_business_evaluation"

View File

@ -2777,106 +2777,11 @@ export function buildAutoRunsRouter(services: AppServices, openaiClient = new Op
router.post("/api/autoruns/autogen/generate", async (req, res, next) => {
try {
const body = toRecord(req.body);
if (!body) {
throw new ApiError("INVALID_AUTOGEN_PAYLOAD", "JSON body is required", 400);
}
const mode = parseAutoGenMode(body.mode);
const count = parseAutogenCount(body.count);
const domain = parseAutogenDomain(body.domain);
const persistCaseSet = toBooleanSafe(body.persist_to_eval_cases) ?? true;
const generatedBy = parseAnnotationAuthor(body.generated_by);
const context = toRecord(body.context);
const llmConfig = parseAutogenLlmRuntimeConfig(body, context);
const personalityPrompt = toStringSafe(context?.autogen_personality_prompt);
if (mode === "saved_user_sessions") {
throw new ApiError(
"AUTOGEN_MODE_NOT_SUPPORTED",
"Use `/api/autoruns/autogen/save-assistant-session` to save user sessions.",
400
);
}
let questions: string[] = [];
if (mode === "qwen_seed") {
if (!llmConfig) {
throw new ApiError(
"AUTOGEN_LLM_CONFIG_REQUIRED",
"Для режима qwen_seed нужен активный LLM-контур (provider/model/baseUrl) из настроек подключения.",
400
);
}
questions = await generateQwenSeedQuestionsLive({
count,
domain,
personalityPrompt,
llmConfig,
client: openaiClient
});
} else {
questions = generateCodexCreativeQuestions(count, domain);
}
questions = Array.from(new Set(questions.map((item) => sanitizeGeneratedQuestion(item)).filter((item) => item.length > 0))).slice(
0,
count
throw new ApiError(
"AUTOGEN_QUESTION_GENERATION_DISABLED",
"Автогенерация вопросов отключена: используйте сохранение живых пользовательских сессий.",
410
);
const generationId = generateAutogenId();
let savedCaseSetFile: string | null = null;
if (persistCaseSet) {
if (!fs.existsSync(EVAL_CASES_DIR)) {
fs.mkdirSync(EVAL_CASES_DIR, { recursive: true });
}
const fileName = buildAutogenCaseSetFileName(mode, generationId);
const filePath = path.resolve(EVAL_CASES_DIR, fileName);
const payload = buildAutogenCaseSetPayload({
generationId,
mode,
domain,
questions
});
fs.writeFileSync(filePath, JSON.stringify(payload, null, 2), "utf-8");
savedCaseSetFile = fileName;
}
const record: AutoGenHistoryRecord = {
generation_id: generationId,
created_at: new Date().toISOString(),
mode,
title: null,
count: questions.length,
domain,
questions,
generated_by: generatedBy,
saved_case_set_file: savedCaseSetFile,
context: context
? {
llm_provider: toStringSafe(context.llm_provider),
model: toStringSafe(context.model),
assistant_prompt_version: toStringSafe(context.assistant_prompt_version),
decomposition_prompt_version: toStringSafe(context.decomposition_prompt_version),
prompt_fingerprint: toStringSafe(context.prompt_fingerprint)
? repairAutogenMojibake(String(context.prompt_fingerprint))
: null,
autogen_personality_id: toStringSafe(context.autogen_personality_id),
autogen_personality_prompt: toStringSafe(context.autogen_personality_prompt)
? repairAutogenMojibake(String(context.autogen_personality_prompt))
: null,
source_session_id: null,
saved_session_file: null,
saved_case_set_kind: "single_turn_list"
}
: null
};
const history = readAutoGenHistory();
history.unshift(record);
writeAutoGenHistory(history.slice(0, 500));
ok(res, {
ok: true,
generation: record
});
} catch (error) {
next(error);
}

View File

@ -2558,6 +2558,17 @@ function resolveUnicodeAddressIntentBridge(text: string): AddressIntentResolutio
);
}
if (
/(?:поставщик|закуп|исходящ|плат[её]ж|supplier|vendor|procurement|outgoing|payout)/iu.test(normalized) &&
/(?:зависим|завязан|концентрац|ключев|основн|критич|dependency|concentration|key|critical)/iu.test(normalized)
) {
return unicodeBridgeResolution(
"unknown",
"high",
"unicode_business_overview_supplier_quality_deferred_to_discovery"
);
}
if (hasDocumentCue && (hasCounterpartyCue || hasLooseCounterpartyByAnchor) && !hasContractCue) {
return unicodeBridgeResolution(
"list_documents_by_counterparty",

View File

@ -99,6 +99,7 @@ interface AddressTryHandleOptions {
llmSemanticHints?: AddressLlmSemanticHints | null;
activeOrganization?: string | null;
knownOrganizations?: string[];
rawUserMessage?: string | null;
}
interface AddressCapabilityAudit {
@ -355,6 +356,23 @@ function deriveTaxQuarterWindowForDate(value: unknown): { period_from: string; p
};
}
function isFullCalendarMonthWindow(periodFrom: unknown, periodTo: unknown): boolean {
const fromIso = normalizeIsoDateForQuery(periodFrom);
const toIso = normalizeIsoDateForQuery(periodTo);
if (!fromIso || !toIso) {
return false;
}
const fromMatch = fromIso.match(/^(\d{4})-(\d{2})-(\d{2})$/);
const toMatch = toIso.match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (!fromMatch || !toMatch || fromMatch[1] !== toMatch[1] || fromMatch[2] !== toMatch[2] || fromMatch[3] !== "01") {
return false;
}
const year = Number(fromMatch[1]);
const month = Number(fromMatch[2]);
const lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate();
return toMatch[3] === String(lastDay).padStart(2, "0");
}
function deriveMonthWindowForDate(value: unknown): { period_from: string; period_to: string } | null {
const isoDate = normalizeIsoDateForQuery(value);
if (!isoDate) {
@ -3716,9 +3734,21 @@ export class AddressQueryService {
})
});
}
const vatTaxPeriodFromCarriedMonth =
intent.intent === "vat_liability_confirmed_for_tax_period" &&
(filters.warnings.includes("period_from_from_followup_context") ||
filters.warnings.includes("period_to_from_followup_context")) &&
isFullCalendarMonthWindow(filters.extracted_filters.period_from, filters.extracted_filters.period_to);
const vatTaxPeriodFromPurchaseBridgeMonth =
intent.intent === "vat_liability_confirmed_for_tax_period" &&
(filters.warnings.includes("period_from_purchase_date_bridge_followup_context") ||
filters.warnings.includes("period_to_purchase_date_bridge_followup_context")) &&
isFullCalendarMonthWindow(filters.extracted_filters.period_from, filters.extracted_filters.period_to);
if (
intent.intent === "vat_liability_confirmed_for_tax_period" &&
filters.warnings.includes("period_derived_from_month_phrase")
(filters.warnings.includes("period_derived_from_month_phrase") ||
vatTaxPeriodFromCarriedMonth ||
vatTaxPeriodFromPurchaseBridgeMonth)
) {
const taxQuarterWindow = deriveTaxQuarterWindowForDate(filters.extracted_filters.period_to);
if (taxQuarterWindow) {
@ -3914,6 +3944,10 @@ export class AddressQueryService {
shadowRouteAudit
});
}
const rawUserMessageForReply =
typeof options.rawUserMessage === "string" && options.rawUserMessage.trim().length > 0
? options.rawUserMessage
: undefined;
const composeOptionsFromFilters = (
filterSet: AddressFilterSet,
options: {
@ -3922,9 +3956,14 @@ export class AddressQueryService {
useRubCurrency?: boolean;
counterpartyHint?: string;
accountHint?: string;
rawUserMessage?: string | null;
} = {}
) => ({
userMessage,
rawUserMessage:
typeof options.rawUserMessage === "string" && options.rawUserMessage.trim().length > 0
? options.rawUserMessage
: rawUserMessageForReply,
itemHint: typeof filterSet.item === "string" ? filterSet.item : undefined,
counterpartyHint:
typeof options.counterpartyHint === "string"
@ -3944,6 +3983,18 @@ export class AddressQueryService {
asOfDate: typeof filterSet.as_of_date === "string" ? filterSet.as_of_date : undefined,
requestedResultMode,
vatDirectSourceProbe: options.vatDirectSourceProbe ?? undefined,
purchaseDateBridge:
typeof filterSet.purchase_date_bridge_selected === "string" ||
typeof filterSet.purchase_date_bridge_first === "string" ||
typeof filterSet.purchase_date_bridge_last === "string"
? {
selectedPurchaseDate: filterSet.purchase_date_bridge_selected,
firstPurchaseDate: filterSet.purchase_date_bridge_first,
lastPurchaseDate: filterSet.purchase_date_bridge_last,
basis: filterSet.purchase_date_bridge_basis,
hasMultiplePurchaseDates: filterSet.purchase_date_bridge_has_multiple === true
}
: undefined,
emphasizeNumbers: options.emphasizeNumbers ?? undefined,
useRubCurrency: options.useRubCurrency ?? undefined
});

View File

@ -1520,6 +1520,91 @@ function buildInventoryMovementQuery(
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort));
}
function buildStaticWhereClause(conditions: Array<string | null | undefined>, indent = " "): string {
const cleaned = conditions.map((condition) => String(condition ?? "").trim()).filter((condition) => condition.length > 0);
return cleaned.length > 0 ? `ГДЕ\n${indent}${cleaned.join(`\n${indent}И `)}` : "";
}
function resolveInventoryAgingAsOfExpr(filters: AddressFilterSet): string {
const now = new Date();
return (
(typeof filters.as_of_date === "string" && filters.as_of_date.trim().length > 0
? toDateTimeExpr(filters.as_of_date, true)
: null) ??
(typeof filters.period_to === "string" && filters.period_to.trim().length > 0
? toDateTimeExpr(filters.period_to, true)
: null) ??
(typeof filters.period_from === "string" && filters.period_from.trim().length > 0
? toDateTimeExpr(filters.period_from, true)
: null) ??
`ДАТАВРЕМЯ(${now.getFullYear()}, ${now.getMonth() + 1}, ${now.getDate()}, 23, 59, 59)`
);
}
function buildInventoryAgingByPurchaseDateQuery(filters: AddressFilterSet, resolvedLimit: number): string {
const asOfExpr = resolveInventoryAgingAsOfExpr(filters);
const inventoryAccountPredicate = buildAccountPrefixPredicate("Остатки.Счет", ["41.01"]);
const onHandScopeConditions = [
"Остатки.КоличествоРазвернутыйОстатокДт > 0",
inventoryAccountPredicate,
buildOrganizationReferenceCondition(filters, ["Остатки.Организация"]),
buildWarehouseReferenceCondition(filters, ["Остатки.Субконто3"]),
buildInventoryItemReferenceCondition(filters, ["Остатки.Субконто1"])
];
const onHandWhereClause = buildStaticWhereClause(onHandScopeConditions);
const currentStockItemSubquery = [
"(ВЫБРАТЬ РАЗЛИЧНЫЕ",
" Остатки.Субконто1",
" ИЗ",
` РегистрБухгалтерии.Хозрасчетный.Остатки(${asOfExpr}, , , ) КАК Остатки`,
buildStaticWhereClause(onHandScopeConditions, " "),
")"
].join("\n");
const purchaseWhereClause = buildStaticWhereClause([
"Товары.Ссылка.Проведен = ИСТИНА",
`Товары.Ссылка.Дата <= ${asOfExpr}`,
buildOrganizationReferenceCondition(filters, ["Товары.Ссылка.Организация"]),
buildInventoryItemReferenceCondition(filters, ["Товары.Номенклатура"]),
`Товары.Номенклатура В ${currentStockItemSubquery}`
]);
return `
ВЫБРАТЬ ПЕРВЫЕ ${resolvedLimit}
${asOfExpr} КАК Период,
"Остатки на дату" КАК Регистратор,
ПРЕДСТАВЛЕНИЕ(Остатки.Счет) КАК СчетДт,
"" КАК СчетКт,
Остатки.СуммаРазвернутыйОстатокДт КАК Сумма,
ПРЕДСТАВЛЕНИЕ(Остатки.Субконто1) КАК Номенклатура,
"" КАК Контрагент,
"" КАК Договор,
ПРЕДСТАВЛЕНИЕ(Остатки.Организация) КАК Организация,
ПРЕДСТАВЛЕНИЕ(Остатки.Субконто3) КАК Склад,
Остатки.КоличествоРазвернутыйОстатокДт КАК Количество
ИЗ
РегистрБухгалтерии.Хозрасчетный.Остатки(${asOfExpr}, , , ) КАК Остатки
${onHandWhereClause}
ОБЪЕДИНИТЬ ВСЕ
ВЫБРАТЬ ПЕРВЫЕ ${resolvedLimit}
Товары.Ссылка.Дата КАК Период,
ПРЕДСТАВЛЕНИЕ(Товары.Ссылка) КАК Регистратор,
"41.01" КАК СчетДт,
"" КАК СчетКт,
Товары.Сумма КАК Сумма,
ПРЕДСТАВЛЕНИЕ(Товары.Номенклатура) КАК Номенклатура,
ПРЕДСТАВЛЕНИЕ(Товары.Ссылка.Контрагент) КАК Контрагент,
ПРЕДСТАВЛЕНИЕ(Товары.Ссылка.ДоговорКонтрагента) КАК Договор,
ПРЕДСТАВЛЕНИЕ(Товары.Ссылка.Организация) КАК Организация,
"" КАК Склад,
Товары.Количество КАК Количество
ИЗ
Документ.ПоступлениеТоваровУслуг.Товары КАК Товары
${purchaseWhereClause}
УПОРЯДОЧИТЬ ПО
Период ВОЗР
`.trim();
}
function buildWarehouseReferenceCondition(filters: AddressFilterSet, fieldPaths: string[]): string | null {
const warehouse = typeof filters.warehouse === "string" ? filters.warehouse.trim() : "";
if (!warehouse) {
@ -2199,7 +2284,7 @@ export function buildAddressRecipePlan(
: recipe.query_template === "inventory_purchase_to_sale_chain_profile"
? buildInventoryPurchaseToSaleDocumentQuery(filters, resolvedLimit)
: recipe.query_template === "inventory_aging_by_purchase_date_profile"
? buildInventoryMovementQuery(filters, resolvedLimit, "dt")
? buildInventoryAgingByPurchaseDateQuery(filters, resolvedLimit)
: recipe.query_template === "inventory_quality_events_profile"
? buildInventoryQualityEventsQuery(filters, resolvedLimit)
: recipe.query_template === "contracts_by_counterparty_profile"

View File

@ -743,12 +743,42 @@ function needsVatPurchaseDateAnchorDisclosure(userMessage: string | null | undef
);
}
function buildVatPurchaseDateAnchorDisclosureLine(
function vatPurchaseDateBasisLabel(basis: string | null | undefined, hasMultiplePurchaseDates: boolean): string {
if (!hasMultiplePurchaseDates) {
return "подтвержденную дату закупки";
}
if (basis === "last_confirmed_purchase") {
return "последнюю подтвержденную дату закупки";
}
return "первую подтвержденную дату закупки";
}
function buildVatPurchaseDateAnchorDisclosureLines(
options: ComposeFactualReplyOptions,
periodWindowLabel: string | null
): string | null {
if (!periodWindowLabel || !needsVatPurchaseDateAnchorDisclosure(options.userMessage)) {
return null;
): string[] {
const bridge = options.purchaseDateBridge ?? null;
const selectedPurchaseDate = normalizeIsoDateOnly(bridge?.selectedPurchaseDate);
const firstPurchaseDate = normalizeIsoDateOnly(bridge?.firstPurchaseDate);
const lastPurchaseDate = normalizeIsoDateOnly(bridge?.lastPurchaseDate);
const hasMultiplePurchaseDates = Boolean(
bridge?.hasMultiplePurchaseDates || (firstPurchaseDate && lastPurchaseDate && firstPurchaseDate !== lastPurchaseDate)
);
if (!periodWindowLabel || (!selectedPurchaseDate && !needsVatPurchaseDateAnchorDisclosure(options.userMessage))) {
return [];
}
if (selectedPurchaseDate) {
const basisLabel = vatPurchaseDateBasisLabel(bridge?.basis, hasMultiplePurchaseDates);
const lines = [
`- Якорь периода: для расчета я использую ${basisLabel} ${formatDateRu(selectedPurchaseDate)}; налоговый период ${periodWindowLabel}; сам расчет ниже взят из книг продаж/покупок за это окно.`
];
if (hasMultiplePurchaseDates && firstPurchaseDate && lastPurchaseDate) {
lines.push(
`- Важно: у позиции несколько подтвержденных дат закупки (${formatDateRu(firstPurchaseDate)}..${formatDateRu(lastPurchaseDate)}); это расчет по выбранному якорю, а не единственная возможная дата.`
);
}
return lines;
}
const asOfDate = normalizeIsoDateOnly(options.asOfDate);
@ -765,10 +795,14 @@ function buildVatPurchaseDateAnchorDisclosureLine(
asOfTs >= fromTs &&
asOfTs <= toTs
) {
return `- Якорь периода: дата покупки ${formatDateRu(asOfDate)} попадает в налоговый период ${periodWindowLabel}; поэтому расчет ниже взят из книг продаж/покупок за это окно.`;
return [
`- Якорь периода: дата покупки ${formatDateRu(asOfDate)} попадает в налоговый период ${periodWindowLabel}; поэтому расчет ниже взят из книг продаж/покупок за это окно.`
];
}
return `- Якорь периода: дата покупки из вопроса/контекста использована для выбора налогового периода ${periodWindowLabel}; сам расчет ниже взят из книг продаж/покупок за это окно.`;
return [
`- Якорь периода: дата покупки из вопроса/контекста использована для выбора налогового периода ${periodWindowLabel}; сам расчет ниже взят из книг продаж/покупок за это окно.`
];
}
function detectRankingLimit(userMessage: string | null | undefined, fallback = 20): number {
@ -1292,14 +1326,85 @@ function hasInventoryAccountPrefix(value: string | null | undefined, prefix: str
return normalized === prefix || normalized.startsWith(`${prefix}.`) || normalized.startsWith(prefix);
}
function isInventoryOnHandSnapshotRow(row: ComposeStageRow): boolean {
const registrator = String(row.registrator ?? "").trim().toLowerCase();
return /(?:остатки\s+на\s+дату|stock\s*on\s*hand|balance\s+as\s+of)/iu.test(registrator);
}
function isInventoryPurchaseMovement(row: ComposeStageRow): boolean {
return hasInventoryAccountPrefix(row.account_dt, "41.01");
return hasInventoryAccountPrefix(row.account_dt, "41.01") && !isInventoryOnHandSnapshotRow(row);
}
function isInventorySaleMovement(row: ComposeStageRow): boolean {
return hasInventoryAccountPrefix(row.account_kt, "41.01");
}
interface InventoryOnHandAgingScope {
itemKeys: Set<string>;
itemOrgKeys: Set<string>;
fullKeys: Set<string>;
orgKeys: Set<string>;
}
function inventoryAgingKeyPart(value: string | null | undefined): string {
return normalizeEntityToken(value) ?? "";
}
function buildInventoryOnHandAgingScope(rows: ComposeStageRow[]): InventoryOnHandAgingScope {
const scope: InventoryOnHandAgingScope = {
itemKeys: new Set(),
itemOrgKeys: new Set(),
fullKeys: new Set(),
orgKeys: new Set()
};
for (const row of rows) {
if (!isInventoryOnHandSnapshotRow(row)) {
continue;
}
const quantity = extractInventoryQuantity(row);
if (quantity === null || quantity <= 0) {
continue;
}
const itemKey = inventoryAgingKeyPart(extractInventoryItemName(row));
if (!itemKey) {
continue;
}
const organizationKey = inventoryAgingKeyPart(extractInventoryOrganizationName(row));
const warehouseKey = inventoryAgingKeyPart(extractInventoryWarehouseName(row));
scope.itemKeys.add(itemKey);
if (organizationKey) {
scope.orgKeys.add(organizationKey);
scope.itemOrgKeys.add(`${itemKey}|${organizationKey}`);
}
if (organizationKey || warehouseKey) {
scope.fullKeys.add(`${itemKey}|${warehouseKey}|${organizationKey}`);
}
}
return scope;
}
function inventoryPurchaseMatchesOnHandAgingScope(row: ComposeStageRow, scope: InventoryOnHandAgingScope): boolean {
if (scope.itemKeys.size === 0) {
return true;
}
const itemKey = inventoryAgingKeyPart(extractInventoryItemName(row));
if (!itemKey || !scope.itemKeys.has(itemKey)) {
return false;
}
const organizationKey = inventoryAgingKeyPart(extractInventoryOrganizationName(row));
const warehouseKey = inventoryAgingKeyPart(extractInventoryWarehouseName(row));
if (scope.fullKeys.has(`${itemKey}|${warehouseKey}|${organizationKey}`)) {
return true;
}
if (organizationKey && scope.itemOrgKeys.has(`${itemKey}|${organizationKey}`)) {
return true;
}
if (organizationKey && scope.orgKeys.size > 0) {
return false;
}
return true;
}
function looksLikeInventoryTraceDocumentToken(value: string): boolean {
const normalized = String(value ?? "").trim();
if (!normalized) {
@ -1526,8 +1631,12 @@ function buildInventoryAgingByItemAggregate(
}
>();
const asOfTimestamp = toUtcDayTimestamp(asOfDate);
const onHandScope = buildInventoryOnHandAgingScope(rows);
for (const row of rows) {
if (!isInventoryPurchaseMovement(row) || !inventoryPurchaseMatchesOnHandAgingScope(row, onHandScope)) {
continue;
}
const item = extractInventoryItemName(row);
if (!item) {
continue;
@ -1613,19 +1722,11 @@ function formatInventoryAgingRows(items: InventoryAgingByItemAggregate[], asOfDa
const parts = [
`${index + 1}. ${item.item}`,
`первая закупка: ${inventoryTraceDateLabel(item.firstPurchasePeriod)}`,
`последняя закупка: ${inventoryTraceDateLabel(item.lastPurchasePeriod)}`,
`документов: ${formatNumberWithDots(item.documentCount)}`,
`операций: ${formatNumberWithDots(item.operations)}`
`последняя закупка: ${inventoryTraceDateLabel(item.lastPurchasePeriod)}`
];
if (item.ageDays !== null) {
parts.push(`возраст следа на ${formatDateRu(asOfDate)}: ${formatNumberWithDots(item.ageDays)} дн.`);
}
if (item.warehouse) {
parts.push(`склад: ${item.warehouse}`);
}
if (item.organization) {
parts.push(`организация: ${item.organization}`);
}
if (item.counterparties.length > 0) {
parts.push(`поставщики: ${item.counterparties.slice(0, 3).join("; ")}`);
}
@ -3934,16 +4035,30 @@ function composeFactualReplyBody(
const formatConfirmedMoney = (value: number): string => (options.useRubCurrency ? formatMoneyRub(value) : formatMoney(value));
const organizationLabel = normalizeOrganizationScopeValue(options.organizationHint);
const organizationScopeLabel = organizationLabel ? ` по организации ${organizationLabel}` : "";
const purchaseDateAnchorLine = buildVatPurchaseDateAnchorDisclosureLine(options, periodWindowLabel);
const purchaseDateAnchorLines = buildVatPurchaseDateAnchorDisclosureLines(options, periodWindowLabel);
const selectedPurchaseDate = normalizeIsoDateOnly(options.purchaseDateBridge?.selectedPurchaseDate);
const hasMultiplePurchaseDates = Boolean(
options.purchaseDateBridge?.hasMultiplePurchaseDates ||
(normalizeIsoDateOnly(options.purchaseDateBridge?.firstPurchaseDate) &&
normalizeIsoDateOnly(options.purchaseDateBridge?.lastPurchaseDate) &&
normalizeIsoDateOnly(options.purchaseDateBridge?.firstPurchaseDate) !==
normalizeIsoDateOnly(options.purchaseDateBridge?.lastPurchaseDate))
);
const purchaseBasisLabel = vatPurchaseDateBasisLabel(options.purchaseDateBridge?.basis, hasMultiplePurchaseDates);
const directVatLine = selectedPurchaseDate
? hasMultiplePurchaseDates
? `Коротко: если брать ${purchaseBasisLabel} ${formatDateRu(selectedPurchaseDate)}, подтвержденный НДС к уплате за налоговый период${organizationScopeLabel}${formatConfirmedMoney(vatToPay)}.`
: `Коротко: по дате покупки ${formatDateRu(selectedPurchaseDate)} подтвержденный НДС к уплате за налоговый период${organizationScopeLabel}${formatConfirmedMoney(vatToPay)}.`
: `Коротко: подтвержденный НДС к уплате за налоговый период${organizationScopeLabel}${formatConfirmedMoney(vatToPay)}.`;
const lines = [
`Коротко: подтвержденный НДС к уплате за налоговый период${organizationScopeLabel}${formatConfirmedMoney(vatToPay)}.`,
directVatLine,
"Расчет сделан по книгам продаж и покупок.",
"",
"Что вошло в расчет:",
...(organizationLabel ? [`- Организация: ${organizationLabel}.`] : []),
`- Налоговый период расчета: ${periodWindowLabel ?? "не задан (нужен явный период)"}.`,
...(purchaseDateAnchorLine ? [purchaseDateAnchorLine] : []),
...purchaseDateAnchorLines,
`- НДС по книге продаж: ${formatConfirmedMoney(salesVat)}.`,
`- НДС по книге покупок (вычеты): ${formatConfirmedMoney(purchaseVat)}.`,
`- Нетто НДС (книга продаж - книга покупок): ${formatConfirmedMoney(netVat)}.`

View File

@ -548,7 +548,11 @@ export function composeCounterpartyAnalyticsReply(
const focus = deps.detectValueRankingFocus(options.userMessage);
const limit = deps.detectRankingLimit(options.userMessage, 20);
const minOpsForAvgCheck = deps.detectMinOpsForAvgCheck(options.userMessage);
const normalizedQuestion = deps.normalizeQuestionText(options.userMessage);
const questionSignalText = [options.rawUserMessage, options.userMessage]
.map((value) => String(value ?? "").trim())
.filter(Boolean)
.join(" ");
const normalizedQuestion = deps.normalizeQuestionText(questionSignalText || options.userMessage);
const asksSingleBestCounterparty =
focus === "top_by_total" &&
/(?:какой|кто|which|who|какоР|РєСРѕ)/iu.test(normalizedQuestion) &&
@ -572,6 +576,10 @@ export function composeCounterpartyAnalyticsReply(
const effectiveLimit = asksSingleBestCounterparty || semanticSingleBestCounterparty ? 1 : limit;
const cashflowBoundaryLine = buildCashflowBoundaryLine(isSupplier);
const cashflowNextStepLine = buildCashflowNextStepLine(isSupplier, normalizedQuestion);
const dependencyCue =
/(?:зависим|завязан|концентрац|ключев|основн|критич|dependency|concentration|key|critical)/iu.test(
normalizedQuestion
);
const byCounterparty = new Map<string, CounterpartyValuePoint>();
const byYear = new Map<number, CounterpartyYearPoint>();
@ -864,13 +872,24 @@ export function composeCounterpartyAnalyticsReply(
? `В выбранном срезе найден один поставщик: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям). Это не полноценный сравнительный рейтинг.`
: `В выбранном срезе найден один клиент: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям). Это не полноценный сравнительный рейтинг.`
: isSupplier
? `Крупнейший поставщик по подтвержденным выплатам ${rankingPeriodLabel}: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям).`
: `Самый доходный клиент ${rankingPeriodLabel} по подтвержденным поступлениям: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям).`;
? dependencyCue
? `Главный сигнал зависимости по исходящим платежам ${rankingPeriodLabel}: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям).`
: `Крупнейший поставщик по подтвержденным выплатам ${rankingPeriodLabel}: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям).`
: dependencyCue
? `Главный сигнал зависимости по входящим деньгам ${rankingPeriodLabel}: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям).`
: `Самый доходный клиент ${rankingPeriodLabel} по подтвержденным поступлениям: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям).`;
lines.unshift(directAnswerLine);
if (dependencyCue) {
lines.splice(
1,
0,
"Это сигнал концентрации по найденным денежным строкам 1С, а не подтверждение договорной зависимости, надежности контрагента или чистой прибыли."
);
}
if (!isSupplier) {
lines.splice(1, 0, cashflowBoundaryLine);
lines.splice(dependencyCue ? 2 : 1, 0, cashflowBoundaryLine);
if (cashflowNextStepLine) {
lines.splice(2, 0, cashflowNextStepLine);
lines.splice(dependencyCue ? 3 : 2, 0, cashflowNextStepLine);
}
}
}

View File

@ -574,6 +574,24 @@ function hasRelativeYearHint(text: string): boolean {
);
}
function resolveMonthPeriodFromIsoDate(isoDate: string | null | undefined): { period_from: string; period_to: string; as_of_date: string } | null {
const match = String(isoDate ?? "").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 lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate();
return {
period_from: `${match[1]}-${match[2]}-01`,
period_to: `${match[1]}-${match[2]}-${String(lastDay).padStart(2, "0")}`,
as_of_date: `${match[1]}-${match[2]}-${String(lastDay).padStart(2, "0")}`
};
}
function resolveRelativeMonthPeriodFromInventoryRoot(
userMessage: string,
followupContext: AddressFollowupContext | null
@ -801,6 +819,36 @@ export function hasInventoryPurchaseDateVatBridgeCue(text: string): boolean {
);
}
function hasInventoryPurchaseDateVatBridgeContinuationCue(text: string): boolean {
const normalized = textWithRepairedVariant(String(text ?? ""))
.trim()
.toLowerCase()
.replace(/ё/g, "е");
if (!normalized) {
return false;
}
const mentionsPurchase = /(?:покупк|закупк|purchase)/iu.test(normalized);
const mentionsFirstOrLast = /(?:перв|последн|earliest|oldest|latest|last)/iu.test(normalized);
return mentionsPurchase && mentionsFirstOrLast;
}
function purchaseDateBridgeBasisFromMessage(userMessage: string): string | null {
const normalized = textWithRepairedVariant(String(userMessage ?? ""))
.trim()
.toLowerCase()
.replace(/ё/g, "е");
if (!normalized) {
return null;
}
if (/(?:последн|latest|last)/iu.test(normalized) && /(?:покупк|закупк|purchase)/iu.test(normalized)) {
return "last_confirmed_purchase";
}
if (/(?:перв|earliest|oldest)/iu.test(normalized) && /(?:покупк|закупк|purchase)/iu.test(normalized)) {
return "first_confirmed_purchase";
}
return null;
}
export function hasInventoryMarginRankingFollowupCue(text: string): boolean {
const normalized = textWithRepairedVariant(String(text ?? ""))
.toLowerCase()
@ -968,6 +1016,34 @@ function mergeFollowupFilters(
): { filters: AddressFilterSet; reasons: string[] } {
const merged: AddressFilterSet = { ...current };
const reasons: string[] = [];
const clearInventoryAgingOrganizationAliasItem = (organizationHint: string | null): void => {
if (intent !== "inventory_aging_by_purchase_date") {
return;
}
const explicitItemMention = /(?:^|[\s,.;:!?()\-\u2014])(?:товар(?:у|а|ом)?|позици(?:и|я|ю)|item|row|line)(?=$|[\s,.;:!?()\-\u2014])/iu.test(
String(userMessage ?? "")
);
const agingItem = toNonEmptyString(merged.item);
const agingOrganization = toNonEmptyString(merged.organization) ?? organizationHint;
const normalizedAgingItem = normalizeOrganizationScopeSearchText(agingItem ?? "");
const normalizedAgingOrganization = normalizeOrganizationScopeSearchText(agingOrganization ?? "");
const itemLooksLikeOrganizationScope = Boolean(
agingItem &&
agingOrganization &&
(organizationsLikelySameEntity(agingItem, agingOrganization) ||
(normalizedAgingItem &&
normalizedAgingOrganization &&
(normalizedAgingOrganization.includes(normalizedAgingItem) ||
normalizedAgingItem.includes(normalizedAgingOrganization))))
);
if (agingItem && itemLooksLikeOrganizationScope) {
delete merged.item;
reasons.push("item_cleared_as_organization_scope_alias_for_stock_aging");
} else if (agingItem && !explicitItemMention) {
delete merged.item;
reasons.push("item_cleared_for_stock_slice_aging");
}
};
if (!followupContext) {
if (
(intent === "list_open_contracts" ||
@ -987,6 +1063,7 @@ function mergeFollowupFilters(
);
}
}
clearInventoryAgingOrganizationAliasItem(null);
return { filters: merged, reasons };
}
@ -1001,6 +1078,13 @@ function mergeFollowupFilters(
const previousAsOfDate = toNonEmptyString(previous.as_of_date);
const previousPeriodFrom = toNonEmptyString(previous.period_from);
const previousPeriodTo = toNonEmptyString(previous.period_to);
const previousPurchaseDateBridgeSelected = toNonEmptyString(previous.purchase_date_bridge_selected);
const previousPurchaseDateBridgeFirst = toNonEmptyString(previous.purchase_date_bridge_first);
const previousPurchaseDateBridgeLast = toNonEmptyString(previous.purchase_date_bridge_last);
const previousPurchaseDateBridgeBasis = toNonEmptyString(previous.purchase_date_bridge_basis);
const previousPurchaseDateBridgeHasMultiple =
previous.purchase_date_bridge_has_multiple === true ||
String(previous.purchase_date_bridge_has_multiple ?? "").trim().toLowerCase() === "true";
const relativeMonthFromInventoryRoot = resolveRelativeMonthPeriodFromInventoryRoot(userMessage, followupContext);
const relativeMonthFromFollowupYear = resolveRelativeMonthPeriodFromFollowupYear(userMessage, followupContext);
const allTimeRequested = hasAllTimeHint(userMessage);
@ -1020,6 +1104,57 @@ function mergeFollowupFilters(
reasons.push("organization_from_followup_context");
}
if (intent === "vat_liability_confirmed_for_tax_period" && previousPurchaseDateBridgeSelected) {
const requestedBridgeBasis = purchaseDateBridgeBasisFromMessage(userMessage);
const shouldCarryPurchaseDateBridge =
!currentHasExplicitTemporalScope &&
(Boolean(requestedBridgeBasis) ||
hasInventoryPurchaseDateVatBridgeCue(userMessage) ||
hasInventoryPurchaseDateVatBridgeContinuationCue(userMessage));
if (!shouldCarryPurchaseDateBridge) {
delete merged.purchase_date_bridge_selected;
delete merged.purchase_date_bridge_first;
delete merged.purchase_date_bridge_last;
delete merged.purchase_date_bridge_basis;
delete merged.purchase_date_bridge_has_multiple;
reasons.push(
currentHasExplicitTemporalScope
? "purchase_date_bridge_suppressed_by_explicit_temporal_scope"
: "purchase_date_bridge_not_reused_without_bridge_cue"
);
} else {
const selectedBridgeDate =
requestedBridgeBasis === "last_confirmed_purchase"
? previousPurchaseDateBridgeLast ?? previousPurchaseDateBridgeSelected
: requestedBridgeBasis === "first_confirmed_purchase"
? previousPurchaseDateBridgeFirst ?? previousPurchaseDateBridgeSelected
: previousPurchaseDateBridgeSelected;
merged.purchase_date_bridge_selected = selectedBridgeDate;
const selectedBridgeWindow = resolveMonthPeriodFromIsoDate(selectedBridgeDate);
if (selectedBridgeWindow) {
merged.period_from = selectedBridgeWindow.period_from;
merged.period_to = selectedBridgeWindow.period_to;
merged.as_of_date = selectedBridgeWindow.as_of_date;
reasons.push("period_from_purchase_date_bridge_followup_context");
reasons.push("period_to_purchase_date_bridge_followup_context");
}
if (previousPurchaseDateBridgeFirst) {
merged.purchase_date_bridge_first = previousPurchaseDateBridgeFirst;
}
if (previousPurchaseDateBridgeLast) {
merged.purchase_date_bridge_last = previousPurchaseDateBridgeLast;
}
const effectiveBridgeBasis = requestedBridgeBasis ?? previousPurchaseDateBridgeBasis;
if (effectiveBridgeBasis) {
merged.purchase_date_bridge_basis = effectiveBridgeBasis;
}
if (previousPurchaseDateBridgeHasMultiple) {
merged.purchase_date_bridge_has_multiple = true;
}
reasons.push("purchase_date_bridge_from_followup_context");
}
}
if (
intent === "inventory_on_hand_as_of_date" &&
followupContext.previous_intent === "inventory_on_hand_as_of_date" &&
@ -1386,13 +1521,7 @@ function mergeFollowupFilters(
reasons.push("period_derived_from_inventory_root_frame_year");
}
if (intent === "inventory_aging_by_purchase_date") {
const explicitItemMention = /(?:^|[\s,.;:!?()\-\u2014])(?:товар(?:у|а|ом)?|позици(?:и|я|ю)|item|row|line)(?=$|[\s,.;:!?()\-\u2014])/iu.test(
String(userMessage ?? "")
);
if (toNonEmptyString(merged.item) && !explicitItemMention) {
delete merged.item;
reasons.push("item_cleared_for_stock_slice_aging");
}
clearInventoryAgingOrganizationAliasItem(previousOrganization);
}
if (
!sameDateRequested &&
@ -1646,7 +1775,13 @@ function deriveIntentWithFollowupContext(
}
const normalizedMessage = String(userMessage ?? "");
const hasFollowupSignal = hasAddressFollowupContextSignal(normalizedMessage);
const genericFollowupSignal = hasAddressFollowupContextSignal(normalizedMessage);
const previousFilters = followupContext.previous_filters ?? {};
const purchaseBridgeContinuationSignal =
followupContext.previous_intent === "vat_liability_confirmed_for_tax_period" &&
Boolean(toNonEmptyString(previousFilters.purchase_date_bridge_selected)) &&
hasInventoryPurchaseDateVatBridgeContinuationCue(normalizedMessage);
const hasFollowupSignal = genericFollowupSignal || purchaseBridgeContinuationSignal;
if (!hasFollowupSignal) {
return detectedIntent;
}
@ -1655,7 +1790,6 @@ function deriveIntentWithFollowupContext(
if (!sourceIntent && !fallbackIntent) {
return detectedIntent;
}
const previousFilters = followupContext.previous_filters ?? {};
const previousPeriodFrom = toNonEmptyString(previousFilters.period_from);
const previousPeriodTo = toNonEmptyString(previousFilters.period_to);
const previousContract = toNonEmptyString(previousFilters.contract);
@ -1672,6 +1806,7 @@ function deriveIntentWithFollowupContext(
isVatFollowup &&
hasSamePeriodHint(normalizedMessage) &&
Boolean(previousPeriodFrom || previousPeriodTo);
const previousPurchaseDateBridgeSelected = toNonEmptyString(previousFilters.purchase_date_bridge_selected);
const previousIsInventoryFamily = isInventoryIntent(sourceIntent ?? undefined);
const rootIsInventoryFamily = isInventoryIntent(followupContext.root_intent ?? undefined);
const inventoryLineageActive =
@ -1700,8 +1835,13 @@ function deriveIntentWithFollowupContext(
) || hasSelectedObjectInlineSnapshotMetadata(normalizedMessage);
const staleInventoryLineageCanYieldToCounterparty =
previousCounterpartyLaneActive && !hasExplicitInventoryItemReference;
const vatPurchaseDateBridgeContinuation =
sourceIntent === "vat_liability_confirmed_for_tax_period" &&
Boolean(previousPurchaseDateBridgeSelected) &&
hasInventoryPurchaseDateVatBridgeContinuationCue(normalizedMessage);
const inventoryPurchaseDateVatBridge =
inventorySelectedObjectFollowup && hasInventoryPurchaseDateVatBridgeCue(normalizedMessage);
(inventorySelectedObjectFollowup && hasInventoryPurchaseDateVatBridgeCue(normalizedMessage)) ||
vatPurchaseDateBridgeContinuation;
const marginRankingLineageActive =
sourceIntent === "inventory_margin_ranking_for_nomenclature" ||
fallbackIntent === "inventory_margin_ranking_for_nomenclature" ||
@ -1728,6 +1868,7 @@ function deriveIntentWithFollowupContext(
inventoryPurchaseDateVatBridge &&
(detectedIntent.intent === "unknown" ||
detectedIntent.intent === sourceIntent ||
detectedIntent.intent === "account_balance_snapshot" ||
detectedIntent.intent === "vat_payable_confirmed_as_of_date" ||
detectedIntent.intent === "vat_payable_forecast")
) {

View File

@ -134,6 +134,17 @@ function inventoryPartyListOrUnknown(parties: string[]): string {
return parties.length > 0 ? parties.slice(0, 4).join("; ") : "не выделен отдельным полем";
}
function normalizeInventoryReplyEntityToken(value: string | null | undefined): string | null {
const normalized = String(value ?? "")
.trim()
.toLowerCase()
.replace(/ё/gu, "е")
.replace(/[^a-zа-я0-9]+/giu, " ")
.replace(/\s+/gu, " ")
.trim();
return normalized || null;
}
function sumInventoryRowAmount(rows: ComposeStageRow[]): number {
return rows.reduce((sum, row) => sum + (typeof row.amount === "number" && Number.isFinite(row.amount) ? row.amount : 0), 0);
}
@ -550,30 +561,50 @@ export function composeInventoryReply(
if (intent === "inventory_aging_by_purchase_date") {
const asOfDate = deps.resolvePayablesAsOfDate(options);
const purchaseRows = rows.filter((row) => deps.isInventoryPurchaseMovement(row));
const agingItems = deps.buildInventoryAgingByItemAggregate(rows, asOfDate);
const agingItemTokens = new Set(
agingItems
.map((item) => normalizeInventoryReplyEntityToken(item.item))
.filter((item): item is string => Boolean(item))
);
const purchaseRows = rows.filter((row) => {
if (!deps.isInventoryPurchaseMovement(row)) {
return false;
}
if (agingItemTokens.size === 0) {
return true;
}
const item = deps.summarizeInventoryTraceRows([row]).item;
const itemToken = normalizeInventoryReplyEntityToken(item);
return Boolean(itemToken && agingItemTokens.has(itemToken));
});
const summary = deps.summarizeInventoryTraceRows(purchaseRows);
const agingItems = deps.buildInventoryAgingByItemAggregate(purchaseRows, asOfDate);
const oldestPurchaseDate = agingItems[0]?.firstPurchasePeriod ?? summary.firstPeriod;
const oldestPurchaseAgeDays = agingItems[0]?.ageDays ?? null;
const organizationLabel = agingItems.find((item) => item.organization)?.organization ?? null;
const oldestAnswerPreview = agingItems
.slice(0, 3)
.map((item) => `${item.item} (${deps.inventoryTraceDateLabel(item.firstPurchasePeriod)})`)
.join("; ");
const directAnswerLine =
agingItems.length > 0
? `К самым старым закупкам в текущем подтвержденном срезе относятся позиции с самой ранней первой закупкой: ${oldestAnswerPreview}.`
: "По доступному закупочному следу позиции со старыми закупками не материализованы.";
? `Среди фактических положительных остатков есть давно закупавшиеся позиции: ${oldestAnswerPreview}.`
: "В фактическом положительном остатке не найдено позиций с подтвержденным старым закупочным следом.";
const lines: string[] = [directAnswerLine];
appendInventoryBulletSection(lines, "Сводка:", [
const summaryLines = [
`Дата среза: ${deps.formatDateRu(asOfDate)}.`,
`Самая ранняя первая закупка среди позиций: ${deps.inventoryTraceDateLabel(oldestPurchaseDate)}.`,
`Самая поздняя найденная закупка: ${deps.inventoryTraceDateLabel(summary.lastPeriod)}.`,
`Позиций в выборке: ${deps.formatNumberWithDots(agingItems.length)}.`,
`Закупочных документов: ${deps.formatNumberWithDots(summary.documents.length)}.`,
`Закупочных операций: ${deps.formatNumberWithDots(purchaseRows.length)}.`
]);
];
if (organizationLabel) {
summaryLines.splice(1, 0, `Организация: ${organizationLabel}.`);
}
appendInventoryBulletSection(lines, "Сводка:", summaryLines);
appendInventoryBulletSection(lines, "Ограничения:", [
"Без партионного учета этот ответ показывает возраст закупочного следа по товарной позиции, а не возраст конкретного лота."
"Берем только позиции с положительным остатком на дату среза; без партионного учета это возраст закупочного следа по номенклатуре, а не доказанный возраст конкретной партии."
]);
if (oldestPurchaseAgeDays !== null) {
lines.push(`- Между самой ранней первой закупкой и датой среза прошло ${deps.formatNumberWithDots(oldestPurchaseAgeDays)} дн.`);
@ -582,10 +613,10 @@ export function composeInventoryReply(
lines.push(`- Поставщики, встречающиеся в наблюдаемом закупочном следе: ${summary.counterparties.slice(0, 4).join("; ")}.`);
}
if (agingItems.length > 0) {
appendInventorySection(lines, "Позиции от самых старых закупок:", deps.formatInventoryAgingRows(agingItems, asOfDate, 12));
appendInventorySection(lines, "Позиции от самых старых закупок:", deps.formatInventoryAgingRows(agingItems, asOfDate, 5));
} else {
appendInventorySection(lines, "Позиции от самых старых закупок:", [
"- В доступных данных не найдено закупочных движений для выбранного среза."
"- В доступных данных не найдено закупочных движений по позициям, которые есть в положительном остатке на дату среза."
]);
}
return buildFactualSummaryReply(

View File

@ -2,6 +2,7 @@ import type { AddressEvidenceStrength, AddressResponseType, AddressResultMode }
export interface ComposeFactualReplyOptions<TVatDirectSourceProbe = unknown> {
userMessage?: string;
rawUserMessage?: string;
itemHint?: string;
counterpartyHint?: string;
organizationHint?: string;
@ -11,6 +12,13 @@ export interface ComposeFactualReplyOptions<TVatDirectSourceProbe = unknown> {
asOfDate?: string;
requestedResultMode?: AddressResultMode;
vatDirectSourceProbe?: TVatDirectSourceProbe | null;
purchaseDateBridge?: {
selectedPurchaseDate?: string | null;
firstPurchaseDate?: string | null;
lastPurchaseDate?: string | null;
basis?: string | null;
hasMultiplePurchaseDates?: boolean;
} | null;
emphasizeNumbers?: boolean;
useRubCurrency?: boolean;
}

View File

@ -224,6 +224,7 @@ export async function runAssistantAddressAttemptRuntime<ResponseType = unknown>(
runAddressLaneAttemptRuntimeSafe(
buildAssistantAddressLaneAttemptRuntimeInput({
messageUsed,
rawUserMessage: input.userMessage,
carryMeta,
analysisDateHint,
llmSemanticHints,

View File

@ -2,6 +2,7 @@ import type { RunAssistantAddressLaneAttemptRuntimeInput } from "./assistantAddr
export interface BuildAssistantAddressLaneAttemptRuntimeInputInput {
messageUsed: RunAssistantAddressLaneAttemptRuntimeInput["messageUsed"];
rawUserMessage: RunAssistantAddressLaneAttemptRuntimeInput["rawUserMessage"];
carryMeta: RunAssistantAddressLaneAttemptRuntimeInput["carryMeta"];
analysisDateHint: RunAssistantAddressLaneAttemptRuntimeInput["analysisDateHint"];
llmSemanticHints: RunAssistantAddressLaneAttemptRuntimeInput["llmSemanticHints"];
@ -17,6 +18,7 @@ export function buildAssistantAddressLaneAttemptRuntimeInput(
): RunAssistantAddressLaneAttemptRuntimeInput {
return {
messageUsed: input.messageUsed,
rawUserMessage: input.rawUserMessage,
carryMeta: input.carryMeta,
analysisDateHint: input.analysisDateHint,
llmSemanticHints: input.llmSemanticHints,

View File

@ -10,6 +10,7 @@ export function resolveAssistantAddressLaneAttemptFollowupContext(
export interface BuildAssistantAddressLaneAttemptQueryOptionsInput {
analysisDateHint: RunAssistantAddressLaneAttemptRuntimeInput["analysisDateHint"];
rawUserMessage: RunAssistantAddressLaneAttemptRuntimeInput["rawUserMessage"];
scopedFollowupContext: Record<string, unknown> | null;
llmSemanticHints: RunAssistantAddressLaneAttemptRuntimeInput["llmSemanticHints"];
activeOrganization: RunAssistantAddressLaneAttemptRuntimeInput["activeOrganization"];
@ -20,7 +21,8 @@ export function buildAssistantAddressLaneAttemptQueryOptions(
input: BuildAssistantAddressLaneAttemptQueryOptionsInput
): Parameters<RunAssistantAddressLaneAttemptRuntimeInput["runAddressQueryTryHandle"]>[1] {
const base = {
analysisDateHint: input.analysisDateHint
analysisDateHint: input.analysisDateHint,
rawUserMessage: input.rawUserMessage ?? undefined
} as Parameters<RunAssistantAddressLaneAttemptRuntimeInput["runAddressQueryTryHandle"]>[1];
if (input.scopedFollowupContext) {
base.followupContext = input.scopedFollowupContext;

View File

@ -7,6 +7,7 @@ import {
export interface RunAssistantAddressLaneAttemptRuntimeInput {
messageUsed: string;
rawUserMessage?: string | null;
carryMeta: AssistantAddressCarryoverLike | null;
analysisDateHint: string | null;
llmSemanticHints?: Record<string, unknown> | null;
@ -24,6 +25,7 @@ export interface RunAssistantAddressLaneAttemptRuntimeInput {
llmSemanticHints?: Record<string, unknown> | null;
activeOrganization?: string | null;
knownOrganizations?: string[];
rawUserMessage?: string | null;
}
) => Promise<AssistantAddressLaneLike | null>;
}
@ -43,7 +45,8 @@ export async function runAssistantAddressLaneAttemptRuntime(
scopedFollowupContext,
llmSemanticHints: input.llmSemanticHints ?? null,
activeOrganization: input.activeOrganization,
knownOrganizations: input.knownOrganizations
knownOrganizations: input.knownOrganizations,
rawUserMessage: input.rawUserMessage ?? input.messageUsed
})
);
}

View File

@ -120,12 +120,25 @@ function cleanComparisonScopeCompanyLine(line: string, organization: string | nu
return clean.trim();
}
function hasComparisonScopeProofCue(value: unknown): boolean {
const text = toNullableString(value)?.toLocaleLowerCase("ru-RU").replace(/\s+/gu, " ").trim();
if (!text) {
return false;
}
return /(?:\bсравни\b|собери\s+коротк\p{L}*\s+итог|коротк\p{L}*\s+сравн|что\s+.*подтверд\p{L}*.*(?:компан|контрагент|свк)|что\s+.*отдельно\s+по\s+(?:группа\s+свк|выбранн\p{L}*\s+контрагент)|какие\s+выводы\s+можно\s+делать\s+и\s+какие\s+нельзя)/iu.test(
text
);
}
function buildComparisonScopeProofReply(input: {
baseReply: string;
debug: Record<string, unknown>;
session: unknown;
userMessage?: unknown;
}): { reply: string; audit: Record<string, unknown> } | null {
if (!hasComparisonScopeProofCue(input.userMessage)) {
return null;
}
const entryPoint = toRecordObject(input.debug.assistant_mcp_discovery_entry_point_v1);
const turnInput = toRecordObject(entryPoint?.turn_input);
const turnMeaning = toRecordObject(turnInput?.turn_meaning_ref);

View File

@ -150,7 +150,7 @@ function hasCompactCashflowFollowupSignal(text: string | null): boolean {
if (!value) {
return false;
}
return /(?:\u0431\u0435\u0437\s+\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\p{L}*|\u0431\u0435\u0437\s+\u0440\u0430\u0437\u0431\u0438\u0432\p{L}*|\u0431\u0435\u0437\s+\u0440\u0430\u0437\u0440\u0435\u0437\p{L}*|\u0431\u0435\u0437\s+\u0434\u0435\u0442\u0430\u043b\p{L}*|\u043e\u0434\u043d\u043e\u0439\s+\u0441\u0442\u0440\u043e\u043a\p{L}*|\u0442\u043e\u043b\u044c\u043a\u043e\s+\u0438\u0442\u043e\u0433|\u043f\u0440\u0438\u0448\p{L}*[\s\S]{0,80}\u0443\u0448\p{L}*[\s\S]{0,80}\u043d\u0435\u0442\u0442\u043e|\u043f\u043e\u043b\u0443\u0447\p{L}*[\s\S]{0,80}\u0437\u0430\u043f\u043b\u0430\u0442\p{L}*[\s\S]{0,80}\u0431\u0435\u0437\s+\u0434\u0435\u0442\u0430\u043b)/iu.test(value);
return /(?:\u0431\u0435\u0437\s+\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\p{L}*|\u0431\u0435\u0437\s+\u0440\u0430\u0437\u0431\u0438\u0432\p{L}*|\u0431\u0435\u0437\s+\u0440\u0430\u0437\u0440\u0435\u0437\p{L}*|\u0431\u0435\u0437\s+\u0434\u0435\u0442\u0430\u043b\p{L}*|\u043e\u0434\u043d\u043e\u0439\s+\u0441\u0442\u0440\u043e\u043a\p{L}*|\u0442\u043e\u043b\u044c\u043a\u043e\s+\u0438\u0442\u043e\u0433|\u043f\u0440\u0438\u0448\p{L}*[\s\S]{0,80}\u0443\u0448\p{L}*[\s\S]{0,80}\u043d\u0435\u0442\u0442\u043e|\u043f\u043e\u043b\u0443\u0447\p{L}*[\s\S]{0,80}\u0437\u0430\u043f\u043b\u0430\u0442\p{L}*[\s\S]{0,80}\u0431\u0435\u0437\s+\u0434\u0435\u0442\u0430\u043b|кто\s+больше\s+всего\s+зан[её]с|кому\s+больше\s+всего\s+ушл\p{L}*|банк\s+не\s+называй\s+обычн\p{L}*\s+клиент\p{L}*|главн\p{L}*\s+клиент|главн\p{L}*\s+поставщик)/iu.test(value);
}
function dateScopeToFilterWindow(dateScope: string | null): Record<string, string> | null {
@ -169,6 +169,37 @@ function dateScopeToFilterWindow(dateScope: string | null): Record<string, strin
return null;
}
function filterWindowFromExtractedFilters(filters: Record<string, unknown> | null): Record<string, string> | null {
if (!filters) {
return null;
}
const asOfDate = typeof filters.as_of_date === "string" ? filters.as_of_date.trim() : "";
if (/^(?:19|20)\d{2}-\d{2}-\d{2}$/.test(asOfDate)) {
return { as_of_date: asOfDate };
}
const periodFrom = typeof filters.period_from === "string" ? filters.period_from.trim() : "";
const periodTo = typeof filters.period_to === "string" ? filters.period_to.trim() : "";
if (/^(?:19|20)\d{2}-\d{2}-\d{2}$/.test(periodFrom) && /^(?:19|20)\d{2}-\d{2}-\d{2}$/.test(periodTo)) {
return {
period_from: periodFrom,
period_to: periodTo
};
}
return null;
}
function hasBoundedFilterWindow(filters: Record<string, unknown> | null): boolean {
if (!filters) {
return false;
}
if (typeof filters.as_of_date === "string" && /^(?:19|20)\d{2}-\d{2}-\d{2}$/.test(filters.as_of_date.trim())) {
return true;
}
const periodFrom = typeof filters.period_from === "string" ? filters.period_from.trim() : "";
const periodTo = typeof filters.period_to === "string" ? filters.period_to.trim() : "";
return /^(?:19|20)\d{2}-\d{2}-\d{2}$/.test(periodFrom) && /^(?:19|20)\d{2}-\d{2}-\d{2}$/.test(periodTo);
}
function looksLikeReliableOrganizationScope(value: string | null): boolean {
const text = compactLower(value);
if (!text) {
@ -197,12 +228,17 @@ function inferBusinessOverviewDiscoveryContextFromSessionItems(
continue;
}
const turnMeaningRef = toRecordObject(turnInput?.turn_meaning_ref);
const filterWindow = dateScopeToFilterWindow(toNonEmptyString(turnMeaningRef?.explicit_date_scope));
const debugFilters = toRecordObject(debug?.extracted_filters);
const filterWindow =
dateScopeToFilterWindow(toNonEmptyString(turnMeaningRef?.explicit_date_scope)) ??
filterWindowFromExtractedFilters(debugFilters);
if (!filterWindow) {
continue;
}
const previousFilters: Record<string, unknown> = { ...filterWindow };
const organization = toNonEmptyString(turnMeaningRef?.explicit_organization_scope);
const organization =
toNonEmptyString(turnMeaningRef?.explicit_organization_scope) ??
toNonEmptyString(debugFilters?.organization);
if (looksLikeReliableOrganizationScope(organization)) {
previousFilters.organization = organization;
}
@ -229,11 +265,7 @@ function mergeBusinessOverviewDateContextForCompactCashflow(input: {
return input.followupContext;
}
const existingFilters = toRecordObject(input.followupContext?.previous_filters);
if (
input.toNonEmptyString(existingFilters?.period_from) ||
input.toNonEmptyString(existingFilters?.period_to) ||
input.toNonEmptyString(existingFilters?.as_of_date)
) {
if (hasBoundedFilterWindow(existingFilters)) {
return input.followupContext;
}
const inferred = inferBusinessOverviewDiscoveryContextFromSessionItems(input.sessionItems, input.toNonEmptyString);
@ -244,12 +276,12 @@ function mergeBusinessOverviewDateContextForCompactCashflow(input: {
...inferred,
...(input.followupContext ?? {}),
previous_filters: {
...(toRecordObject(inferred.previous_filters) ?? {}),
...(toRecordObject(input.followupContext?.previous_filters) ?? {})
...(toRecordObject(input.followupContext?.previous_filters) ?? {}),
...(toRecordObject(inferred.previous_filters) ?? {})
},
root_filters: {
...(toRecordObject(inferred.root_filters) ?? {}),
...(toRecordObject(input.followupContext?.root_filters) ?? {})
...(toRecordObject(input.followupContext?.root_filters) ?? {}),
...(toRecordObject(inferred.root_filters) ?? {})
}
};
}
@ -281,6 +313,16 @@ function sameEntityHint(expected: string | null, actual: string | null): boolean
return left === right || left.includes(right) || right.includes(left);
}
function hasComparisonScopeProofCue(text: string | null): boolean {
const value = compactLower(text);
if (!value) {
return false;
}
return /(?:\bсравни\b|собери\s+коротк\p{L}*\s+итог|коротк\p{L}*\s+сравн|что\s+.*подтверд\p{L}*.*(?:компан|контрагент|свк)|что\s+.*отдельно\s+по\s+(?:группа\s+свк|выбранн\p{L}*\s+контрагент)|какие\s+выводы\s+можно\s+делать\s+и\s+какие\s+нельзя)/iu.test(
value
);
}
function isBusinessOverviewDiscoveryFollowup(
followupContext: Record<string, unknown> | null,
toNonEmptyString: BuildAssistantAddressOrchestrationRuntimeInput["toNonEmptyString"]
@ -489,6 +531,7 @@ function mergeBusinessOverviewProofBundlesFromNavigationState(input: {
followupContext: Record<string, unknown> | null;
sessionAddressNavigationState: unknown;
predecomposeContract: Record<string, unknown> | null;
userMessage: string;
toNonEmptyString: BuildAssistantAddressOrchestrationRuntimeInput["toNonEmptyString"];
}): Record<string, unknown> | null {
const currentValueBundle = toRecordObject(input.followupContext?.previous_discovery_bidirectional_value_flow);
@ -508,6 +551,9 @@ function mergeBusinessOverviewProofBundlesFromNavigationState(input: {
const entities = toRecordObject(input.predecomposeContract?.entities);
const hasCurrentOrganizationSelection = Boolean(input.toNonEmptyString(entities?.organization));
const businessOverviewFollowup = isBusinessOverviewDiscoveryFollowup(input.followupContext, input.toNonEmptyString);
if (!hasComparisonScopeProofCue(input.userMessage)) {
return input.followupContext;
}
if (!businessOverviewFollowup && !hasCurrentOrganizationSelection) {
return input.followupContext;
}
@ -550,9 +596,13 @@ function mergeBusinessOverviewProofBundlesFromNavigationState(input: {
function mergeBusinessOverviewProofBundlesFromSessionItems(input: {
followupContext: Record<string, unknown> | null;
userMessage: string;
sessionItems: unknown[];
toNonEmptyString: BuildAssistantAddressOrchestrationRuntimeInput["toNonEmptyString"];
}): Record<string, unknown> | null {
if (!hasComparisonScopeProofCue(input.userMessage)) {
return input.followupContext;
}
if (!isBusinessOverviewDiscoveryFollowup(input.followupContext, input.toNonEmptyString)) {
return input.followupContext;
}
@ -853,6 +903,7 @@ export async function buildAssistantAddressOrchestrationRuntime(
});
const discoveryFollowupContextWithProofBundles = mergeBusinessOverviewProofBundlesFromSessionItems({
followupContext: discoveryFollowupContext,
userMessage: input.userMessage,
sessionItems: input.sessionItems,
toNonEmptyString: input.toNonEmptyString
});
@ -860,6 +911,7 @@ export async function buildAssistantAddressOrchestrationRuntime(
followupContext: discoveryFollowupContextWithProofBundles,
sessionAddressNavigationState: input.sessionAddressNavigationState,
predecomposeContract,
userMessage: input.userMessage,
toNonEmptyString: input.toNonEmptyString
});
const dialogContinuationContract = input.buildAddressDialogContinuationContractV2(

View File

@ -188,6 +188,21 @@ function normalizeAddressRuntimeMetaForDeep(
};
}
function stripRoutineShortAnswerPrefix(value: string): string {
return String(value ?? "")
.split(/\r?\n/g)
.map((line) =>
line
.replace(
/^((?:\s|\uFEFF)*(?:[-*]\s*)?)(?:\u041a\u043e\u0440\u043e\u0442\u043a\u043e:\s*)+/u,
"$1"
)
.replace(/(:\s*)\u041a\u043e\u0440\u043e\u0442\u043a\u043e:\s*/u, "$1")
)
.join("\n")
.trim();
}
export function runAssistantDeepTurnResponseRuntime(
input: RunAssistantDeepTurnResponseRuntimeInput
): RunAssistantDeepTurnResponseRuntimeOutput {
@ -243,9 +258,11 @@ export function runAssistantDeepTurnResponseRuntime(
currentReplySource: "deep_analysis",
addressRuntimeMeta: addressRuntimeMetaForDeep as unknown as Record<string, unknown> | null
});
const assistantReply = mcpDiscoveryResponsePolicy.applied
? mcpDiscoveryResponsePolicy.reply_text
: packagingRuntime.safeAssistantReply;
const assistantReply = stripRoutineShortAnswerPrefix(
mcpDiscoveryResponsePolicy.applied
? input.sanitizeReply(mcpDiscoveryResponsePolicy.reply_text, packagingRuntime.safeAssistantReply)
: packagingRuntime.safeAssistantReply
);
const replyType = mcpDiscoveryResponsePolicy.applied ? "partial_coverage" : input.composition.reply_type;
const debug = {
...packagingRuntime.debug,

View File

@ -107,6 +107,16 @@ function requestsCounterpartyLeaderSurface(
return /(?:(?:кто|как\p{L}*|покаж\p{L}*|назов\p{L}*|раскро\p{L}*)[\s\S]{0,100}(?:главн\p{L}*|крупнейш\p{L}*|основн\p{L}*|ведущ\p{L}*|топ)[\s\S]{0,80}(?:клиент|заказчик|поставщик|получател)|(?:главн\p{L}*|крупнейш\p{L}*|основн\p{L}*|ведущ\p{L}*)[\s\S]{0,80}(?:клиент|заказчик|поставщик|получател)|(?:топ[-\s]*(?:клиент|заказчик|поставщик|получател))|(?:клиент|поставщик)[\s\S]{0,80}(?:главн|крупнейш|основн|ведущ|топ))/iu.test(text);
}
function requestsBusinessOverviewNextChecks(
turnMeaning: Record<string, unknown> | null,
graph: Record<string, unknown> | null
): boolean {
const text = normalizedTurnAndGraphText(turnMeaning, graph);
return /(?:что\s+(?:дальше\s+)?провер\w*|что\s+провер\w*\s+дальше|следующ\w*\s+шаг|в\s+первую\s+очередь\s+провер\w*|next\s+checks?|next\s+steps?)/iu.test(
text
);
}
function requestsCompactCashflowAnswer(
turnMeaning: Record<string, unknown> | null,
graph: Record<string, unknown> | null
@ -527,6 +537,14 @@ function businessOverviewPeriodText(overview: Record<string, unknown>): string {
return period ? `за ${period}` : "за все доступное проверенное окно";
}
function businessOverviewPeriodPhrase(value: string | null | undefined): string {
const normalized = toNonEmptyString(value);
if (!normalized) {
return "за все доступное проверенное окно";
}
return /^за\s+/iu.test(normalized) ? normalized : `за ${normalized}`;
}
function strongestIncomingYear(overview: Record<string, unknown>): Record<string, unknown> | null {
const years = Array.isArray(overview.yearly_breakdown) ? overview.yearly_breakdown : [];
const sorted = years
@ -1031,7 +1049,12 @@ function buildCompactBusinessOverviewReply(
const compactCashflowRequested = directMoneyOnlyAnswer && requestsCompactCashflowAnswer(turnMeaning, graph);
const cashflowPolarityRequested = compactCashflowRequested && requestsCashflowPolarityAnswer(turnMeaning, graph);
const directAccountingProfitRequested = requestsDirectAccountingProfitAnswer(turnMeaning, graph);
const nextChecksRequested = requestsBusinessOverviewNextChecks(turnMeaning, graph);
const rawMessage = toNonEmptyString(turnMeaning?.raw_message) ?? toNonEmptyString(turnMeaning?.effective_message);
const earningsAmountRequested = /(?:\u0437\u0430\u0440\u0430\u0431\u043e\u0442|\u0434\u043e\u0445\u043e\u0434\p{L}*)/iu.test(
normalizeQuestionText(rawMessage ?? "")
);
const accountingFinancialResultForEarnings = toRecordObject(overview.accounting_financial_result);
const rawMessageComparable = compactComparable(rawMessage);
const organizationScopeComparable = compactComparable(organizationScope);
const plainOrganizationClarificationSelection = Boolean(
@ -1045,18 +1068,65 @@ function buildCompactBusinessOverviewReply(
!/(?:сравн|подтвержд|деньг|сколько|что\s+|покаж|дай|вывод|нельзя|клиент|поставщик|\?)/iu.test(rawMessage)
);
if (
compactCashflowRequested &&
!rankingNeed &&
!profitMarginBoundary &&
!cashflowPolarityRequested &&
earningsAmountRequested &&
accountingFinancialResultForEarnings
) {
const direction = toNonEmptyString(accountingFinancialResultForEarnings.final_result_direction);
const amount = moneyText(accountingFinancialResultForEarnings.final_result_amount_human_ru);
const periodScope = toNonEmptyString(accountingFinancialResultForEarnings.period_scope) ?? period;
const marginPct =
typeof accountingFinancialResultForEarnings.net_margin_to_revenue_pct === "number" &&
Number.isFinite(accountingFinancialResultForEarnings.net_margin_to_revenue_pct)
? `${accountingFinancialResultForEarnings.net_margin_to_revenue_pct}%`
: null;
const directionText =
direction === "profit"
? "\u0443\u0447\u0435\u0442\u043d\u0430\u044f \u043f\u0440\u0438\u0431\u044b\u043b\u044c"
: direction === "loss"
? "\u0443\u0447\u0435\u0442\u043d\u044b\u0439 \u0443\u0431\u044b\u0442\u043e\u043a"
: "\u043d\u0443\u043b\u0435\u0432\u043e\u0439 \u0443\u0447\u0435\u0442\u043d\u044b\u0439 \u0444\u0438\u043d\u0440\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442";
const amountText = amount
? direction === "loss"
? `\u043c\u0438\u043d\u0443\u0441 ${amount}`
: amount
: "\u0441\u0443\u043c\u043c\u0430 \u043d\u0435 \u0440\u0430\u0441\u043f\u043e\u0437\u043d\u0430\u043d\u0430";
lines.push(
`${organizationPrefix}\u0437\u0430 ${periodScope} \u043f\u043e \u0437\u0430\u043a\u0440\u044b\u0442\u0438\u044e \u0441\u0447\u0435\u0442\u043e\u0432 90/91/99 \u0432 1\u0421 \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043d\u0430 ${directionText}: ${amountText}${marginPct ? `; \u043c\u0430\u0440\u0436\u0430 \u043a \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043d\u043d\u043e\u0439 \u0432\u044b\u0440\u0443\u0447\u043a\u0435 ${marginPct}` : ""}.`
);
if (incomingAmount || outgoingAmount || netAmount) {
lines.push(
`\u0414\u043b\u044f \u0441\u0432\u0435\u0440\u043a\u0438 \u043f\u043e \u0434\u0435\u043d\u044c\u0433\u0430\u043c: \u043f\u043e\u043b\u0443\u0447\u0438\u043b\u0438 ${incomingAmount ?? "0 \u0440\u0443\u0431."}; \u0437\u0430\u043f\u043b\u0430\u0442\u0438\u043b\u0438/\u0441\u043f\u0438\u0441\u0430\u043b\u0438 ${outgoingAmount ?? "0 \u0440\u0443\u0431."}; \u0434\u0435\u043d\u0435\u0436\u043d\u043e\u0435 \u043d\u0435\u0442\u0442\u043e ${sentenceAmount(netAmount) ?? netAmount ?? "0 \u0440\u0443\u0431."}.`
);
}
lines.push(
"\u0412\u0430\u0436\u043d\u043e: \u0443\u0447\u0435\u0442\u043d\u044b\u0439 \u0444\u0438\u043d\u0440\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442 \u0438 \u0434\u0432\u0438\u0436\u0435\u043d\u0438\u0435 \u0434\u0435\u043d\u0435\u0433 - \u0440\u0430\u0437\u043d\u044b\u0435 \u043c\u0435\u0442\u0440\u0438\u043a\u0438; \u043d\u0435 \u0441\u043c\u0435\u0448\u0438\u0432\u0430\u044e \u0438\u0445 \u0432 \u043e\u0434\u043d\u0443 \"\u043f\u0440\u0438\u0431\u044b\u043b\u044c\"."
);
return joinBusinessReplyLines(lines);
}
if (compactCashflowRequested && !rankingNeed && (incomingAmount || outgoingAmount || netAmount)) {
const netDisplay = sentenceAmount(netAmount) ?? netAmount ?? "0 \u0440\u0443\u0431.";
const signedNetDisplay =
cashflowPolarityRequested && netDisplay && !String(netDisplay).trim().startsWith("-")
? `+${netDisplay}`
: netDisplay;
const polarityLead = cashflowPolarityRequested
? `\u041a\u043e\u0440\u043e\u0442\u043a\u043e: \u043f\u043e \u0434\u0435\u043d\u044c\u0433\u0430\u043c \u043f\u043b\u044e\u0441. `
: "\u041a\u043e\u0440\u043e\u0442\u043a\u043e: ";
const limitedEarningsAnswer = Boolean(earningsAmountRequested && !accountingFinancialResultForEarnings && limitLine);
const polarityLead = limitedEarningsAnswer
? "\u041a\u043e\u0440\u043e\u0442\u043a\u043e: \u0442\u043e\u0447\u043d\u0443\u044e \u0443\u0447\u0435\u0442\u043d\u0443\u044e \u043f\u0440\u0438\u0431\u044b\u043b\u044c \u0437\u0430 \u044d\u0442\u043e \u043e\u043a\u043d\u043e \u043d\u0435 \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0430\u044e; \u0432 \u043e\u0433\u0440\u0430\u043d\u0438\u0447\u0435\u043d\u043d\u043e\u043c \u0434\u0435\u043d\u0435\u0436\u043d\u043e\u043c \u0441\u0440\u0435\u0437\u0435 "
: cashflowPolarityRequested
? `\u041a\u043e\u0440\u043e\u0442\u043a\u043e: \u043f\u043e \u0434\u0435\u043d\u044c\u0433\u0430\u043c \u043f\u043b\u044e\u0441. `
: "\u041a\u043e\u0440\u043e\u0442\u043a\u043e: ";
lines.push(
`${polarityLead}${organizationPrefix}${period} \u043f\u043e\u043b\u0443\u0447\u0438\u043b\u0438 ${incomingAmount ?? "0 \u0440\u0443\u0431."}; \u0437\u0430\u043f\u043b\u0430\u0442\u0438\u043b\u0438/\u0441\u043f\u0438\u0441\u0430\u043b\u0438 ${outgoingAmount ?? "0 \u0440\u0443\u0431."}; \u0434\u0435\u043d\u0435\u0436\u043d\u043e\u0435 \u043d\u0435\u0442\u0442\u043e ${signedNetDisplay}.`
);
if (limitLine) {
lines.push(limitLine);
}
lines.push(
cashflowPolarityRequested
? "\u042d\u0442\u043e \u0434\u0435\u043d\u0435\u0436\u043d\u044b\u0439 \u043f\u043e\u0442\u043e\u043a \u043f\u043e \u043d\u0430\u0439\u0434\u0435\u043d\u043d\u044b\u043c \u0441\u0442\u0440\u043e\u043a\u0430\u043c 1\u0421, \u043d\u0435 \u0431\u0443\u0445\u0433\u0430\u043b\u0442\u0435\u0440\u0441\u043a\u0438\u0439 \u0444\u0438\u043d\u0440\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442."
@ -1116,12 +1186,8 @@ function buildCompactBusinessOverviewReply(
);
return joinBusinessReplyLines(lines);
}
const headline = toNonEmptyString(draft.headline);
const cleanHeadline = headline?.replace(/^Коротко:\s*/iu, "").trim();
lines.push(
cleanHeadline
? `Коротко: ${localizeLine(cleanHeadline)}`
: "Коротко: нельзя точно подтвердить чистую прибыль и маржу по текущему срезу 1С; есть только ограниченный операционный денежный/товарный сигнал, а не полный отчет о прибыли и не бухгалтерский финансовый результат."
"Коротко: чистую прибыль и маржу не подтверждаю по текущему срезу 1С; видны только ограниченные денежные обороты и расчетное нетто/операционный сигнал, а не полный отчет о прибыли и не бухгалтерский финансовый результат."
);
const boundaryLines = userFacingLines([
...toStringList(draft.confirmed_lines),
@ -1221,10 +1287,13 @@ function buildCompactBusinessOverviewReply(
? `${vendorProcurementQuality.top_non_financial_supplier_share_pct}%`
: null;
const periodScope = toNonEmptyString(vendorProcurementQuality.period_scope) ?? period;
const totalText = totalOutgoing ? `; всего исходящих платежей в проверенном срезе ${totalOutgoing}` : "";
const periodPhrase = businessOverviewPeriodPhrase(periodScope);
const totalText = totalOutgoing
? `; всего исходящих платежей в проверенном срезе ${sentenceAmount(totalOutgoing)}`
: "";
if (status === "financial_institution_leads_outgoing_cash") {
lines.push(
`Коротко: проверка концентрации закупок/исходящих платежей за ${periodScope} не подтверждает зависимость от обычного поставщика: крупнейший получатель исходящих денег ${topOutgoingName ?? "не распознан"}${topOutgoingShare ? ` держит около ${topOutgoingShare}` : ""}${topOutgoingAmount ? ` (${topOutgoingAmount})` : ""}, но по названию это банк/финансовая организация${totalText}.`
`Коротко: проверка концентрации закупок/исходящих платежей ${periodPhrase} не подтверждает зависимость от обычного поставщика: крупнейший получатель исходящих денег ${topOutgoingName ?? "не распознан"}${topOutgoingShare ? ` держит около ${topOutgoingShare}` : ""}${topOutgoingAmount ? ` (${topOutgoingAmount})` : ""}, но по названию это банк/финансовая организация${totalText}.`
);
const financialHintText = financialFlowHintTextRuFromRecord(topOutgoingRecord);
if (financialHintText) {
@ -1237,11 +1306,11 @@ function buildCompactBusinessOverviewReply(
}
} else if (status === "reviewed_procurement_concentration") {
lines.push(
`Коротко: точный риск зависимости от одного поставщика не подтвержден полностью; проверка концентрации закупок/исходящих платежей за ${periodScope} нашла крупнейшего получателя исходящего потока: ${topOutgoingName ?? nonFinancialName ?? "получатель не распознан"}${topOutgoingShare ? ` держит около ${topOutgoingShare}` : nonFinancialShare ? ` держит около ${nonFinancialShare}` : ""}${topOutgoingAmount ? ` (${topOutgoingAmount})` : nonFinancialAmount ? ` (${nonFinancialAmount})` : ""}${totalText}.`
`Коротко: точный риск зависимости от одного поставщика не подтвержден полностью; проверка концентрации закупок/исходящих платежей ${periodPhrase} нашла крупнейшего получателя исходящего потока: ${topOutgoingName ?? nonFinancialName ?? "получатель не распознан"}${topOutgoingShare ? ` держит около ${topOutgoingShare}` : nonFinancialShare ? ` держит около ${nonFinancialShare}` : ""}${topOutgoingAmount ? ` (${topOutgoingAmount})` : nonFinancialAmount ? ` (${nonFinancialAmount})` : ""}${totalText}.`
);
} else {
lines.push(
`Коротко: проверка концентрации закупок/исходящих платежей за ${periodScope} выполнена, но надежной небанковской концентрации поставщика по найденным исходящим платежам не хватает${totalText}.`
`Коротко: проверка концентрации закупок/исходящих платежей ${periodPhrase} выполнена, но надежной небанковской концентрации поставщика по найденным исходящим платежам не хватает${totalText}.`
);
}
const contractText =
@ -1251,7 +1320,7 @@ function buildCompactBusinessOverviewReply(
: ` Договорный профиль: используется ${vendorProcurementQuality.used_contracts} договоров.`
: "";
lines.push(
`Что не доказано этим срезом: надежность поставщика, качество поставок, договорные условия, назначение каждого платежа и полная структура всех расходов.${contractText}`
`Это не аудит поставщиков: срез не доказывает надежность поставщика, качество поставок, договорные условия, назначение каждого платежа и полную структуру всех расходов.${contractText}`
);
return joinBusinessReplyLines(lines);
}
@ -1305,6 +1374,26 @@ function buildCompactBusinessOverviewReply(
return joinBusinessReplyLines(lines);
}
if (
!separateSubject &&
!crossScopeExecutiveSummary &&
!counterpartyLeaderSurfaceRequested &&
!rankingNeed &&
nextChecksRequested
) {
const subject = organizationScope ? `по компании ${organizationScope}` : "по компании";
lines.push(
`В первую очередь ${subject} надо проверить чистую прибыль через закрытие 90/91/99, зависимость от топ-3 клиентов и поставщиков, долги/зависшие расчеты, складские остатки и НДС-позицию.`
);
lines.push(
"Смысл порядка: сначала отделить бухгалтерский финрезультат от денежного нетто, затем проверить концентрацию потоков и только после этого углубляться в склад, долги и налоговую позицию."
);
if (limitLine) {
lines.push(limitLine);
}
return joinBusinessReplyLines(lines);
}
if (
!separateSubject &&
!crossScopeExecutiveSummary &&
@ -1315,9 +1404,9 @@ function buildCompactBusinessOverviewReply(
const subject = organizationScope ?? "компания";
const periodWithoutPrefix = period.replace(/^за\s+/iu, "");
lines.push(
`Коротко: по доступным данным ${subject} выглядит как бизнес с крупными контрактными денежными потоками и заметной зависимостью от нескольких крупных контрагентов, а не как равномерный поток мелких продаж.`
`Коротко: по ограниченному проверенному 1С-срезу ${subject} выглядит как бизнес с крупными контрактными денежными потоками и заметной зависимостью от нескольких крупных контрагентов, а не как равномерный поток мелких продаж; это не аудиторское заключение и не подтвержденная чистая прибыль.`
);
lines.push("Что видно:");
lines.push("Что видно в ограниченном денежном срезе:");
if (incomingAmount) {
lines.push(`- входящие деньги за ${periodWithoutPrefix}: ${incomingAmount};`);
}
@ -1353,9 +1442,11 @@ function buildCompactBusinessOverviewReply(
if (debtLine) {
lines.push(`- ${localizeLine(debtLine)}`);
}
lines.push(
"Ограничение: это оценка по денежным потокам и найденным срезам 1С, не аудиторское заключение и не подтвержденная чистая прибыль."
);
if (!limitLine) {
lines.push(
"Ограничение: это оценка по денежным потокам и найденным срезам 1С, не аудиторское заключение и не подтвержденная чистая прибыль."
);
}
const missingOverviewFamilies: string[] = [];
if (!taxLine) {
missingOverviewFamilies.push("НДС/налоговая позиция без отдельного точного расчета");

View File

@ -87,6 +87,21 @@ function hasInternalMechanics(value: string): boolean {
);
}
function stripRoutineShortAnswerPrefix(value: string): string {
return String(value ?? "")
.split(/\r?\n/g)
.map((line) =>
line
.replace(
/^((?:\s|\uFEFF)*(?:[-*]\s*)?)(?:\u041a\u043e\u0440\u043e\u0442\u043a\u043e:\s*)+/u,
"$1"
)
.replace(/(:\s*)\u041a\u043e\u0440\u043e\u0442\u043a\u043e:\s*/u, "$1")
)
.join("\n")
.trim();
}
function isMcpDiscoveryEntryPointContract(value: unknown): value is AssistantMcpDiscoveryRuntimeEntryPointContract {
const record = toRecordObject(value);
return (
@ -1052,12 +1067,13 @@ export function applyAssistantMcpDiscoveryResponsePolicy(
}
pushReason(reasonCodes, "mcp_discovery_response_policy_candidate_applied");
const replyText = stripRoutineShortAnswerPrefix(String(candidate.reply_text));
return {
schema_version: ASSISTANT_MCP_DISCOVERY_RESPONSE_POLICY_SCHEMA_VERSION,
policy_owner: "assistantMcpDiscoveryResponsePolicy",
decision: "apply_candidate",
applied: true,
reply_text: String(candidate.reply_text),
reply_text: replyText || String(candidate.reply_text),
reply_source: "mcp_discovery_response_candidate_guarded",
candidate,
reason_codes: reasonCodes

View File

@ -225,6 +225,9 @@ function isGarbageSemanticAnchorCandidate(value: string | null): boolean {
/^(?:и\s+)?кто\s+(?:главн\p{L}*|основн\p{L}*|крупн\p{L}*)\s+(?:клиент|покупател|поставщик|контрагент)(?:\s+в)?$/iu.test(
text
) ||
/^(?:и\s+)?(?:главн\p{L}*|основн\p{L}*|крупн\p{L}*)\s+(?:клиент|покупател|поставщик|контрагент)(?:\s+в)?$/iu.test(
text
) ||
/^(?:или\s+)?(?:обычн\p{L}*\s+)?(?:клиент|поставщик|покупател\p{L}*|заказчик|контрагент)(?:\s+или\s+(?:клиент|поставщик|покупател\p{L}*|заказчик|контрагент))?$/iu.test(
text
) ||
@ -900,6 +903,22 @@ function hasDebtDueDateBoundaryFollowupSignal(text: string): boolean {
return hasDueDateCue && hasFollowupShape;
}
function hasSupplierDependencyFollowupSignal(text: string): boolean {
if (!text) {
return false;
}
const normalized = compactLower(text);
const hasSupplierOrOutgoingCue =
/(?:\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u0437\u0430\u043a\u0443\u043f|\u0438\u0441\u0445\u043e\u0434\u044f\u0449|\u043f\u043b\u0430\u0442[\u0435\u0451]\u0436|\u0437\u0430\u043f\u043b\u0430\u0442|supplier|vendor|procurement|outgoing|payout)/iu.test(
normalized
);
const hasDependencyCue =
/(?:\u0437\u0430\u0432\u0438\u0441\u0438\u043c|\u0437\u0430\u0432\u044f\u0437\u0430\u043d|\u043a\u043e\u043d\u0446\u0435\u043d\u0442\u0440\u0430\u0446|\u043a\u043b\u044e\u0447\u0435\u0432|\u043e\u0441\u043d\u043e\u0432\u043d|\u043a\u0440\u0438\u0442\u0438\u0447|dependency|concentration|key|critical)/iu.test(
normalized
);
return hasSupplierOrOutgoingCue && hasDependencyCue;
}
function hasOrganizationLevelDebtDueDateOverviewSignal(text: string): boolean {
if (!text) {
return false;
@ -1079,7 +1098,7 @@ function hasBusinessOverviewContinuationSignal(text: string): boolean {
normalized
);
const hasAnalystContinuationCue =
/(?:можно\s+ли|если\s+нет|proxy|прокси|аудит|оцен|что\s+думаешь|нормальн\p{L}*\s+прибыл|прибыл|марж|рентаб|ликвидн|просроч|качество\s+долг|риск|налогов\p{L}*\s+вывод)/iu.test(
/(?:можно\s+ли|если\s+нет|proxy|прокси|аудит|оцен|что\s+думаешь|нормальн\p{L}*\s+прибыл|прибыл|марж|рентаб|ликвидн|просроч|качество\s+долг|риск|зависим|завязан|концентрац|налогов\p{L}*\s+вывод)/iu.test(
normalized
);
const hasTaxContinuationCue =
@ -1865,7 +1884,9 @@ export function buildAssistantMcpDiscoveryTurnInput(
hasOrganizationLevelDebtDueDateOverviewSignal(rawText)) ||
debtDueDateFollowupBusinessOverviewSignal;
const supplierQualityBusinessOverviewSignal =
(rawBusinessOverviewSignal || seededBusinessOverviewSignal) && hasOrganizationLevelSupplierQualityOverviewSignal(rawText);
((rawBusinessOverviewSignal || seededBusinessOverviewSignal) &&
(hasOrganizationLevelSupplierQualityOverviewSignal(rawText) || hasSupplierDependencyFollowupSignal(rawText))) ||
(businessOverviewContinuationSignal && hasSupplierDependencyFollowupSignal(rawText));
const profitMarginFollowupBusinessOverviewSignal =
businessOverviewContinuationSignal && hasProfitMarginBoundaryFollowupSignal(rawText);
const profitMarginBusinessOverviewSignal =
@ -2458,6 +2479,8 @@ export function buildAssistantMcpDiscoveryTurnInput(
? null
: explicitCurrentCounterpartyOverridesFollowupEntity
? null
: businessOverviewSuppressesFollowupCounterparty
? rawMetadataScopeHint
: rawMetadataScopeHint ??
followupSeed.metadataScopeHint ??
followupSeed.discoveryEntity ??
@ -2685,7 +2708,7 @@ export function buildAssistantMcpDiscoveryTurnInput(
normalizedPredecomposeDateScope &&
normalizedPredecomposeDateScope.startsWith(`${rawDateScope}-`)
);
const followupAllTimeScopeApplied = normalizedFollowupDateScope === "all_time_scope";
const followupAllTimeScopeApplied = normalizedFollowupDateScope === "all_time_scope" && !currentTurnCarriesExplicitPeriod;
const explicitDateScope =
rawAllTimeScopeSignal || followupAllTimeScopeApplied
? null
@ -3081,6 +3104,9 @@ export function buildAssistantMcpDiscoveryTurnInput(
if (debtDueDateFollowupBusinessOverviewSignal) {
pushReason(reasonCodes, "mcp_discovery_business_overview_debt_due_date_followup_boundary");
}
if (supplierQualityBusinessOverviewSignal && hasSupplierDependencyFollowupSignal(rawText)) {
pushReason(reasonCodes, "mcp_discovery_business_overview_supplier_dependency_followup_boundary");
}
if (explicitVatSuppressesBusinessOverviewContinuation) {
pushReason(reasonCodes, "mcp_discovery_business_overview_continuation_suppressed_by_explicit_vat_question");
}

View File

@ -1982,7 +1982,11 @@ function repairAddressMojibake(value) {
}
function sanitizeOutgoingAssistantText(value, fallback = "Не смог сформировать читаемый ответ. Уточните запрос.") {
const repaired = repairAddressMojibake(String(value ?? ""));
const sanitized = String((0, answerComposer_1.sanitizeAssistantReplyForUserFacing)(repaired) ?? "").trim();
const sanitized = String((0, answerComposer_1.sanitizeAssistantReplyForUserFacing)(repaired) ?? "")
.split(/\r?\n/g)
.map((line) => line.replace(/^Коротко:\s*/u, "").replace(/(:\s*)Коротко:\s*/u, "$1"))
.join("\n")
.trim();
if (sanitized) {
return sanitized;
}

View File

@ -301,7 +301,7 @@ export function createAssistantTransitionPolicy(deps) {
return `${match[3]}-${match[2]}-${match[1]}`;
}
function computeMonthWindowFromIso(isoDate) {
function computeMonthWindowFromIso(isoDate, bridgeFacts = {}) {
const match = String(isoDate ?? "").match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (!match) {
return null;
@ -318,11 +318,17 @@ export function createAssistantTransitionPolicy(deps) {
purchase_date: isoDate,
period_from: `${year}-${mm}-01`,
period_to: `${year}-${mm}-${dd}`,
as_of_date: `${year}-${mm}-${dd}`
as_of_date: `${year}-${mm}-${dd}`,
purchase_date_bridge_selected: isoDate,
...bridgeFacts
};
}
function extractEarliestDmyDateFromEntityRefs(entityRefs) {
function uniqueSortedIsoDates(dates) {
return Array.from(new Set(dates.filter(Boolean))).sort();
}
function extractDmyDatesFromEntityRefs(entityRefs) {
const dates = [];
for (const entityRef of Array.isArray(entityRefs) ? entityRefs : []) {
const value = deps.toNonEmptyString(entityRef?.value);
@ -340,40 +346,96 @@ export function createAssistantTransitionPolicy(deps) {
}
}
}
if (dates.length === 0) {
return null;
}
return dates.sort()[0] ?? null;
return uniqueSortedIsoDates(dates);
}
function extractPurchaseDateBridgeWindow(previousAddressItem, addressNavigationState) {
function detectPurchaseDateBridgeBasis(userMessage, alternateMessage) {
const text = [userMessage, alternateMessage]
.map((item) => deps.compactWhitespace(deps.repairAddressMojibake(String(item ?? "")).toLowerCase()).replace(/ё/g, "е"))
.filter(Boolean)
.join(" ");
if (/(?:последн|latest|last|сам[а-я\s-]*поздн)/iu.test(text)) {
return "last_confirmed_purchase";
}
if (/(?:перв|earliest|oldest|ранн|сам[а-я\s-]*стар)/iu.test(text)) {
return "first_confirmed_purchase";
}
return "first_confirmed_purchase_default";
}
function extractPurchaseDateBridgeFacts(previousAddressItem, addressNavigationState, userMessage, alternateMessage) {
const previousFilters = readAddressDebugFilters(previousAddressItem?.debug ?? null);
let firstIsoDate = deps.toNonEmptyString(previousFilters?.purchase_date_bridge_first);
let lastIsoDate = deps.toNonEmptyString(previousFilters?.purchase_date_bridge_last);
const selectedIsoDateFromFilters = deps.toNonEmptyString(previousFilters?.purchase_date_bridge_selected);
if (!firstIsoDate && selectedIsoDateFromFilters) {
firstIsoDate = selectedIsoDateFromFilters;
}
if (!lastIsoDate && selectedIsoDateFromFilters) {
lastIsoDate = selectedIsoDateFromFilters;
}
const replyText = deps.toNonEmptyString(previousAddressItem?.text);
if (replyText) {
if (replyText && (!firstIsoDate || !lastIsoDate)) {
const repairedText = deps.repairAddressMojibake(replyText);
const explicitFirstDateMatch = repairedText.match(/первая\s+найденная\s+дата\s+закупки:\s*(\d{2}\.\d{2}\.\d{4})/iu);
const explicitFirstDateIso = explicitFirstDateMatch ? parseDmyDateToIso(explicitFirstDateMatch[1]) : null;
if (explicitFirstDateIso) {
return computeMonthWindowFromIso(explicitFirstDateIso);
}
const explicitFirstDateMatch = repairedText.match(
/первая\s+(?:найденная|подтвержденная)\s+дата\s+закупки:\s*(\d{2}\.\d{2}\.\d{4})/iu
);
const explicitLastDateMatch = repairedText.match(
/последняя\s+(?:найденная|подтвержденная)\s+дата\s+закупки:\s*(\d{2}\.\d{2}\.\d{4})/iu
);
firstIsoDate = explicitFirstDateMatch ? parseDmyDateToIso(explicitFirstDateMatch[1]) : null;
lastIsoDate = explicitLastDateMatch ? parseDmyDateToIso(explicitLastDateMatch[1]) : null;
}
const navigationSessionState = resolveNavigationSessionContextState(
addressNavigationState,
deps.toNonEmptyString,
deps.normalizeOrganizationScopeValue
);
const focusObject = navigationSessionState.focusObject;
const preferredResultSetId =
deps.toNonEmptyString(focusObject?.provenanceResultSetId) ?? navigationSessionState.activeResultSetId;
const resultSets = Array.isArray(addressNavigationState?.result_sets) ? addressNavigationState.result_sets : [];
const preferredResultSet =
(preferredResultSetId
? resultSets.find((item) => deps.toNonEmptyString(item?.result_set_id) === preferredResultSetId) ?? null
: null) ??
resultSets.find((item) => deps.toNonEmptyString(item?.intent) === "inventory_purchase_provenance_for_item") ??
null;
const earliestIsoDate = extractEarliestDmyDateFromEntityRefs(preferredResultSet?.entity_refs);
return earliestIsoDate ? computeMonthWindowFromIso(earliestIsoDate) : null;
if (!firstIsoDate || !lastIsoDate) {
const navigationSessionState = resolveNavigationSessionContextState(
addressNavigationState,
deps.toNonEmptyString,
deps.normalizeOrganizationScopeValue
);
const focusObject = navigationSessionState.focusObject;
const preferredResultSetId =
deps.toNonEmptyString(focusObject?.provenanceResultSetId) ?? navigationSessionState.activeResultSetId;
const resultSets = Array.isArray(addressNavigationState?.result_sets) ? addressNavigationState.result_sets : [];
const preferredResultSet =
(preferredResultSetId
? resultSets.find((item) => deps.toNonEmptyString(item?.result_set_id) === preferredResultSetId) ?? null
: null) ??
resultSets.find((item) => deps.toNonEmptyString(item?.intent) === "inventory_purchase_provenance_for_item") ??
null;
const datesFromResultSet = extractDmyDatesFromEntityRefs(preferredResultSet?.entity_refs);
firstIsoDate = firstIsoDate ?? datesFromResultSet[0] ?? null;
lastIsoDate = lastIsoDate ?? datesFromResultSet[datesFromResultSet.length - 1] ?? null;
}
if (!firstIsoDate && !lastIsoDate) {
return null;
}
const normalizedFirst = firstIsoDate ?? lastIsoDate;
const normalizedLast = lastIsoDate ?? firstIsoDate;
const basis = detectPurchaseDateBridgeBasis(userMessage, alternateMessage);
const selectedIsoDate = basis === "last_confirmed_purchase" ? normalizedLast : normalizedFirst;
return {
selectedIsoDate,
firstIsoDate: normalizedFirst,
lastIsoDate: normalizedLast,
basis,
hasMultiplePurchaseDates: Boolean(normalizedFirst && normalizedLast && normalizedFirst !== normalizedLast)
};
}
function extractPurchaseDateBridgeWindow(previousAddressItem, addressNavigationState, userMessage, alternateMessage) {
const facts = extractPurchaseDateBridgeFacts(previousAddressItem, addressNavigationState, userMessage, alternateMessage);
if (!facts?.selectedIsoDate) {
return null;
}
return computeMonthWindowFromIso(facts.selectedIsoDate, {
purchase_date_bridge_first: facts.firstIsoDate,
purchase_date_bridge_last: facts.lastIsoDate,
purchase_date_bridge_basis: facts.basis,
purchase_date_bridge_has_multiple: facts.hasMultiplePurchaseDates
});
}
function isUsableFollowupSourceDebug(debug) {
@ -605,11 +667,20 @@ export function createAssistantTransitionPolicy(deps) {
return null;
}
function hasInventoryPurchaseDateVatBridgeSignal(userMessage, alternateMessage, sourceIntentHint, hasInventoryItemFocusHint) {
function hasInventoryPurchaseDateVatBridgeSignal(
userMessage,
alternateMessage,
sourceIntentHint,
hasInventoryItemFocusHint,
sourceHasPurchaseDateBridgeHint = false
) {
const vatPurchaseBridgeContinuation =
sourceIntentHint === "vat_liability_confirmed_for_tax_period" && sourceHasPurchaseDateBridgeHint;
if (
sourceIntentHint !== "inventory_purchase_provenance_for_item" &&
!hasInventoryItemFocusHint &&
!deps.isInventorySelectedObjectIntent(sourceIntentHint)
!deps.isInventorySelectedObjectIntent(sourceIntentHint) &&
!vatPurchaseBridgeContinuation
) {
return false;
}
@ -621,10 +692,13 @@ export function createAssistantTransitionPolicy(deps) {
}
return samples.some(
(sample) =>
/(?:ндс|vat)/iu.test(sample) &&
/(?:на\s+дат[ауеы]\s+покупк|на\s+дат[ауеы]\s+закупк|по\s+дат[еу]\s+покупк|по\s+дат[еу]\s+закупк|дата\s+покупк|дата\s+закупк|purchase\s+date)/iu.test(
sample
)
((/(?:ндс|vat)/iu.test(sample) &&
/(?:на\s+дат[ауеы]\s+покупк|на\s+дат[ауеы]\s+закупк|по\s+дат[еу]\s+покупк|по\s+дат[еу]\s+закупк|дата\s+покупк|дата\s+закупк|purchase\s+date)/iu.test(
sample
)) ||
((vatPurchaseBridgeContinuation || sourceIntentHint === "vat_liability_confirmed_for_tax_period") &&
/(?:перв|последн|earliest|oldest|latest|last)/iu.test(sample) &&
/(?:покупк|закупк|purchase)/iu.test(sample)))
);
}
@ -932,10 +1006,17 @@ export function createAssistantTransitionPolicy(deps) {
sourceDiscoveryPilotScopeHint === "business_overview_route_template_v1";
const navigationSessionState = earlyNavigationSessionState;
const navigationFocusObjectHint = navigationSessionState.focusObject;
const sourceFiltersHint = readAddressDebugFilters(carryoverSourceDebug);
const sourceHasPurchaseDateBridgeHint = Boolean(
deps.toNonEmptyString(sourceFiltersHint?.purchase_date_bridge_selected)
);
const hasNavigationInventoryItemFocusHint = Boolean(
deps.toNonEmptyString(navigationFocusObjectHint?.label) &&
deps.toNonEmptyString(navigationFocusObjectHint?.objectType) === "item" &&
(sourceIntentHint === "inventory_on_hand_as_of_date" ||
sourceIntentHint === "vat_liability_confirmed_for_tax_period" ||
sourceIntentHint === "vat_payable_forecast" ||
sourceIntentHint === "vat_payable_confirmed_as_of_date" ||
sourceIntentHint === "inventory_supplier_stock_overlap_as_of_date" ||
deps.isInventorySelectedObjectIntent(sourceIntentHint))
);
@ -943,7 +1024,8 @@ export function createAssistantTransitionPolicy(deps) {
userMessage,
alternateMessage,
sourceIntentHint,
hasNavigationInventoryItemFocusHint
hasNavigationInventoryItemFocusHint,
sourceHasPurchaseDateBridgeHint
);
const inventoryMarginRankingFollowup = hasInventoryMarginRankingFollowupSignal(
userMessage,
@ -1457,10 +1539,21 @@ export function createAssistantTransitionPolicy(deps) {
readAddressDebugItem(carryoverSourceDebug, deps.toNonEmptyString) ??
deps.toNonEmptyString(previousFilters.item)
) ?? previousAddressItem;
const purchaseBridgeWindow = extractPurchaseDateBridgeWindow(purchaseBridgeItem, addressNavigationState);
const purchaseBridgeWindow = extractPurchaseDateBridgeWindow(
purchaseBridgeItem,
addressNavigationState,
userMessage,
alternateMessage
);
if (purchaseBridgeWindow) {
previousFilters.period_from = purchaseBridgeWindow.period_from;
previousFilters.period_to = purchaseBridgeWindow.period_to;
previousFilters.as_of_date = purchaseBridgeWindow.as_of_date;
previousFilters.purchase_date_bridge_selected = purchaseBridgeWindow.purchase_date_bridge_selected;
previousFilters.purchase_date_bridge_first = purchaseBridgeWindow.purchase_date_bridge_first;
previousFilters.purchase_date_bridge_last = purchaseBridgeWindow.purchase_date_bridge_last;
previousFilters.purchase_date_bridge_basis = purchaseBridgeWindow.purchase_date_bridge_basis;
previousFilters.purchase_date_bridge_has_multiple = purchaseBridgeWindow.purchase_date_bridge_has_multiple;
}
}
previousFilters = applyTemporalCarryoverFilters(

View File

@ -426,6 +426,25 @@ function detectBroadBusinessEvaluation(text) {
family: "broad_business_evaluation"
};
}
if (
/(?:что\s+(?:дальше\s+)?провер\w*|что\s+провер\w*\s+дальше|следующ\w*\s+шаг|в\s+первую\s+очередь\s+провер\w*|next\s+checks?|next\s+steps?)/iu.test(
normalized
)
) {
return {
family: "broad_business_evaluation"
};
}
if (
/(?:поставщик|закуп|исходящ|плат[её]ж|supplier|vendor|procurement|outgoing|payout)/iu.test(normalized) &&
/(?:зависим|завязан|концентрац|ключев|основн|критич|dependency|concentration|key|critical)/iu.test(
normalized
)
) {
return {
family: "broad_business_evaluation"
};
}
if (hasOrganizationLevelEarningsOverviewSignal(normalized)) {
return {
family: "broad_business_evaluation"

View File

@ -163,6 +163,11 @@ export interface AddressFilterSet {
warehouse?: string;
document_type?: string;
document_ref?: string;
purchase_date_bridge_selected?: string;
purchase_date_bridge_first?: string;
purchase_date_bridge_last?: string;
purchase_date_bridge_basis?: string;
purchase_date_bridge_has_multiple?: boolean;
status?: string;
limit?: number;
sort?: "period_desc" | "period_asc";

View File

@ -244,7 +244,12 @@ describe("address follow-up temporal regressions", () => {
organization: 'ООО "Альтернатива Плюс"',
period_from: "2015-02-01",
period_to: "2015-02-28",
as_of_date: "2016-03-31"
as_of_date: "2015-02-28",
purchase_date_bridge_selected: "2015-02-05",
purchase_date_bridge_first: "2015-02-05",
purchase_date_bridge_last: "2015-07-22",
purchase_date_bridge_basis: "first_confirmed_purchase_default",
purchase_date_bridge_has_multiple: true
},
previous_anchor_type: "item",
previous_anchor_value: "Рабочая станция универсального специалиста"
@ -254,7 +259,98 @@ describe("address follow-up temporal regressions", () => {
expect(result?.intent.intent).toBe("vat_liability_confirmed_for_tax_period");
expect(result?.filters.extracted_filters.period_from).toBe("2015-02-01");
expect(result?.filters.extracted_filters.period_to).toBe("2015-02-28");
expect(result?.baseReasons).toContain("period_from_followup_context");
expect(result?.filters.extracted_filters.as_of_date).toBe("2015-02-28");
expect(result?.filters.extracted_filters.purchase_date_bridge_selected).toBe("2015-02-05");
expect(result?.filters.extracted_filters.purchase_date_bridge_has_multiple).toBe(true);
expect(result?.baseReasons).toContain("period_from_purchase_date_bridge_followup_context");
expect(result?.baseReasons).toContain("purchase_date_bridge_from_followup_context");
});
it("does not reuse purchase-date VAT bridge when the next VAT question has an explicit period", () => {
const result = runAddressDecomposeStage("а какой ндс мы должны сгрузить на март 2020?", {
previous_intent: "vat_liability_confirmed_for_tax_period",
target_intent: "vat_liability_confirmed_for_tax_period",
previous_filters: {
item: "Рабочая станция универсального специалиста",
organization: 'ООО "Альтернатива Плюс"',
period_from: "2015-02-01",
period_to: "2015-02-28",
as_of_date: "2015-02-28",
purchase_date_bridge_selected: "2015-02-05",
purchase_date_bridge_first: "2015-02-05",
purchase_date_bridge_last: "2015-07-22",
purchase_date_bridge_basis: "first_confirmed_purchase_default",
purchase_date_bridge_has_multiple: true
},
previous_anchor_type: "organization",
previous_anchor_value: 'ООО "Альтернатива Плюс"'
});
expect(result).not.toBeNull();
expect(result?.intent.intent).toBe("vat_liability_confirmed_for_tax_period");
expect(result?.filters.extracted_filters.period_from).toBe("2020-03-01");
expect(result?.filters.extracted_filters.period_to).toBe("2020-03-31");
expect(result?.filters.extracted_filters.as_of_date).toBeUndefined();
expect(result?.filters.extracted_filters.purchase_date_bridge_selected).toBeUndefined();
expect(result?.baseReasons).toContain("purchase_date_bridge_suppressed_by_explicit_temporal_scope");
expect(result?.baseReasons).not.toContain("period_from_purchase_date_bridge_followup_context");
});
it("keeps first confirmed purchase VAT recalculation in the VAT lane without repeating VAT wording", () => {
const result = runAddressDecomposeStage("если у нее несколько закупок, посчитай по первой подтвержденной покупке", {
previous_intent: "vat_liability_confirmed_for_tax_period",
target_intent: "vat_liability_confirmed_for_tax_period",
previous_filters: {
organization: 'ООО "Альтернатива Плюс"',
period_from: "2015-02-01",
period_to: "2015-02-28",
as_of_date: "2015-02-28",
purchase_date_bridge_selected: "2015-02-05",
purchase_date_bridge_first: "2015-02-05",
purchase_date_bridge_last: "2015-07-22",
purchase_date_bridge_basis: "first_confirmed_purchase_default",
purchase_date_bridge_has_multiple: true
},
previous_anchor_type: "organization",
previous_anchor_value: 'ООО "Альтернатива Плюс"'
});
expect(result).not.toBeNull();
expect(result?.intent.intent).toBe("vat_liability_confirmed_for_tax_period");
expect(result?.filters.extracted_filters.purchase_date_bridge_selected).toBe("2015-02-05");
expect(result?.filters.extracted_filters.purchase_date_bridge_basis).toBe("first_confirmed_purchase");
expect(result?.filters.extracted_filters.period_from).toBe("2015-02-01");
expect(result?.filters.extracted_filters.period_to).toBe("2015-02-28");
expect(result?.baseReasons).toContain("intent_adjusted_to_inventory_purchase_date_vat_bridge");
expect(result?.baseReasons).toContain("purchase_date_bridge_from_followup_context");
});
it("keeps last confirmed purchase VAT recalculation and moves the tax period to the last purchase month", () => {
const result = runAddressDecomposeStage("а теперь по последней подтвержденной покупке", {
previous_intent: "vat_liability_confirmed_for_tax_period",
target_intent: "vat_liability_confirmed_for_tax_period",
previous_filters: {
organization: 'ООО "Альтернатива Плюс"',
period_from: "2015-02-01",
period_to: "2015-02-28",
as_of_date: "2015-02-28",
purchase_date_bridge_selected: "2015-02-05",
purchase_date_bridge_first: "2015-02-05",
purchase_date_bridge_last: "2015-07-22",
purchase_date_bridge_basis: "first_confirmed_purchase",
purchase_date_bridge_has_multiple: true
},
previous_anchor_type: "organization",
previous_anchor_value: 'ООО "Альтернатива Плюс"'
});
expect(result).not.toBeNull();
expect(result?.intent.intent).toBe("vat_liability_confirmed_for_tax_period");
expect(result?.filters.extracted_filters.purchase_date_bridge_selected).toBe("2015-07-22");
expect(result?.filters.extracted_filters.purchase_date_bridge_basis).toBe("last_confirmed_purchase");
expect(result?.filters.extracted_filters.period_from).toBe("2015-07-01");
expect(result?.filters.extracted_filters.period_to).toBe("2015-07-31");
expect(result?.filters.extracted_filters.as_of_date).toBe("2015-07-31");
});
it("clears counterparty noise from bare organization clarification selections that resume inventory", () => {
const result = runAddressDecomposeStage("АЛЬТЕРНАТИВА", {

View File

@ -4,6 +4,44 @@ import { runAddressDecomposeStage } from "../src/services/address_runtime/decomp
import { composeFactualReply } from "../src/services/address_runtime/composeStage";
describe("inventory aging follow-up", () => {
it("clears organization alias item on root old-stock questions with LLM organization semantics", () => {
const result = runAddressDecomposeStage(
'Есть ли у ООО "Альтернатива Плюс" остатки товара, которые закупались очень давно',
null,
{
scope_target_kind: "organization",
scope_target_text: 'ООО "Альтернатива Плюс"',
date_scope_kind: "implicit_current",
self_scope_detected: false,
selected_object_scope_detected: false
}
);
expect(result).not.toBeNull();
expect(result?.intent.intent).toBe("inventory_aging_by_purchase_date");
expect(result?.filters.extracted_filters.organization).toBe('ООО "Альтернатива Плюс"');
expect(result?.filters.extracted_filters.item).toBeUndefined();
expect(result?.baseReasons).toContain("item_cleared_as_organization_scope_alias_for_stock_aging");
});
it("does not mistake an explicit organization name for an inventory item in old-stock questions", () => {
const result = runAddressDecomposeStage('Есть ли у ООО "Альтернатива Плюс" остатки товара, которые закупались очень давно', {
previous_intent: "inventory_aging_by_purchase_date",
previous_filters: {
organization: 'ООО "Альтернатива Плюс"',
as_of_date: "2026-05-25"
},
previous_anchor_type: "organization",
previous_anchor_value: 'ООО "Альтернатива Плюс"'
});
expect(result).not.toBeNull();
expect(result?.intent.intent).toBe("inventory_aging_by_purchase_date");
expect(result?.filters.extracted_filters.organization).toBe('ООО "Альтернатива Плюс"');
expect(result?.filters.extracted_filters.item).toBeUndefined();
expect(result?.baseReasons).toContain("item_cleared_as_organization_scope_alias_for_stock_aging");
});
it("keeps carried date window for 'на эту дату' aging follow-up", () => {
const result = runAddressDecomposeStage("Какие остатки по товарам на эту дату относятся к старым закупкам", {
previous_intent: "inventory_purchase_provenance_for_item",

View File

@ -2182,6 +2182,49 @@ describe("address compose stage utf8 headers", () => {
expect(reply.text).toContain("книг продаж/покупок");
});
it("discloses ambiguous inventory purchase-date VAT bridge instead of hiding the selected date", () => {
const reply = composeFactualReply(
"vat_liability_confirmed_for_tax_period",
[
{
period: "2015-02-28T23:59:59Z",
registrator: "VAT_BOOK_SALES",
account_dt: "68.02",
account_kt: "",
amount: 3500000,
analytics: []
},
{
period: "2015-02-28T23:59:59Z",
registrator: "VAT_BOOK_PURCHASES",
account_dt: "19",
account_kt: "",
amount: 868612,
analytics: []
}
],
{
userMessage: "ндс можешь прикинуть на дату покупки рабочей станции?",
periodFrom: "2015-02-01",
periodTo: "2015-02-28",
asOfDate: "2015-02-28",
organizationHint: "ООО Альтернатива Плюс",
purchaseDateBridge: {
selectedPurchaseDate: "2015-02-05",
firstPurchaseDate: "2015-02-05",
lastPurchaseDate: "2015-07-22",
basis: "first_confirmed_purchase_default",
hasMultiplePurchaseDates: true
},
useRubCurrency: true
}
);
expect(reply.text).toContain("если брать первую подтвержденную дату закупки 05.02.2015");
expect(reply.text).toContain("у позиции несколько подтвержденных дат закупки");
expect(reply.text).toContain("05.02.2015..22.07.2015");
});
it("formats VAT forecast amounts in rubles and emphasizes numbers when requested", () => {
const reply = composeFactualReply(
"vat_payable_forecast",
@ -2906,6 +2949,12 @@ describe("address intent resolver expansion (M2.3a)", () => {
expect(result.reasons).toContain("unicode_business_overview_supplier_quality_deferred_to_discovery");
});
it("defers supplier dependency follow-up wording to business overview discovery instead of bank ops by pseudo-counterparty", () => {
const result = resolveAddressIntent("а по поставщикам и исходящим платежам от кого зависимость?");
expect(result.intent).toBe("unknown");
expect(result.reasons).toContain("unicode_business_overview_supplier_quality_deferred_to_discovery");
});
it("routes reconciliation mismatch wording into open contracts intent", () => {
const result = resolveAddressIntent(
"Покажи контрагентов, по которым сальдо скорее всего не совпадет с их актом сверки. Может, стоит поторопиться и запросить сверку?"
@ -4059,6 +4108,67 @@ describe("address query limited taxonomy and stage diagnostics", { timeout: 9000
expect(result?.debug.route_expectation_status).toBe("matched");
});
it("normalizes carried monthly debt period into VAT tax quarter for same-period follow-up", async () => {
const service = new AddressQueryService();
const result = await service.tryHandle("а какой ндс мы должны примерно заплатить за этот период?", {
followupContext: {
previous_intent: "receivables_confirmed_as_of_date",
previous_filters: {
organization: 'ООО "Альтернатива Плюс"',
period_from: "2017-05-01",
period_to: "2017-05-31",
as_of_date: "2017-05-31"
},
previous_anchor_type: "organization",
previous_anchor_value: 'ООО "Альтернатива Плюс"',
target_intent: "vat_liability_confirmed_for_tax_period"
}
});
expect(result?.handled).toBe(true);
expect(result?.debug.detected_intent).toBe("vat_liability_confirmed_for_tax_period");
expect(result?.debug.selected_recipe).toBe("address_vat_liability_confirmed_tax_period_v1");
expect(result?.debug.extracted_filters.period_from).toBe("2017-04-01");
expect(result?.debug.extracted_filters.period_to).toBe("2017-06-30");
expect(result?.debug.reasons).toContain("period_from_from_followup_context");
expect(result?.debug.reasons).toContain("period_derived_from_tax_quarter_for_confirmed_vat_liability");
expect(String(result?.reply_text ?? "")).toContain("01.04.2017..30.06.2017");
}, 35000);
it("normalizes purchase-date VAT bridge month into the selected date tax quarter", async () => {
const service = new AddressQueryService();
const result = await service.tryHandle("ндс можешь прикинуть на дату покупки рабочей станции?", {
followupContext: {
previous_intent: "inventory_purchase_provenance_for_item",
target_intent: "vat_liability_confirmed_for_tax_period",
previous_filters: {
item: "Рабочая станция универсального специалиста",
organization: 'ООО "Альтернатива Плюс"',
period_from: "2015-02-01",
period_to: "2015-02-28",
as_of_date: "2015-02-28",
purchase_date_bridge_selected: "2015-02-05",
purchase_date_bridge_first: "2015-02-05",
purchase_date_bridge_last: "2015-07-22",
purchase_date_bridge_basis: "first_confirmed_purchase_default",
purchase_date_bridge_has_multiple: true
},
previous_anchor_type: "item",
previous_anchor_value: "Рабочая станция универсального специалиста"
}
});
expect(result?.handled).toBe(true);
expect(result?.debug.detected_intent).toBe("vat_liability_confirmed_for_tax_period");
expect(result?.debug.extracted_filters.period_from).toBe("2015-01-01");
expect(result?.debug.extracted_filters.period_to).toBe("2015-03-31");
expect(result?.debug.extracted_filters.purchase_date_bridge_selected).toBe("2015-02-05");
expect(result?.debug.reasons).toContain("period_from_purchase_date_bridge_followup_context");
expect(result?.debug.reasons).toContain("period_derived_from_tax_quarter_for_confirmed_vat_liability");
expect(String(result?.reply_text ?? "")).toContain("05.02.2015");
expect(String(result?.reply_text ?? "")).toContain("01.01.2015..31.03.2015");
}, 35000);
it("routes customer lifecycle question into dedicated aggregate recipe", async () => {
const service = new AddressQueryService();
const result = await service.tryHandle("Какие заказчики работали с нами в 2020 году?");
@ -5741,6 +5851,24 @@ it("routes old purchase residue questions to aging-by-purchase-date", () => {
expect(plan.query).toContain("ПОДСТРОКА(ЕСТЬNULL(Остатки.Счет.Код, \"\"), 1, 5) = \"41.01\"");
});
it("builds old-stock aging query from positive stock snapshot plus purchase history", () => {
const selected = selectAddressRecipe("inventory_aging_by_purchase_date", {
organization: 'ООО "Альтернатива Плюс"',
as_of_date: "2026-04-18"
});
expect(selected.selected_recipe?.recipe_id).toBe("address_inventory_aging_by_purchase_date_v1");
const plan = buildAddressRecipePlan(selected.selected_recipe!, {
organization: 'ООО "Альтернатива Плюс"',
as_of_date: "2026-04-18"
});
expect(plan.query).toContain("РегистрБухгалтерии.Хозрасчетный.Остатки(");
expect(plan.query).toContain("Документ.ПоступлениеТоваровУслуг.Товары");
expect(plan.query).toContain("Остатки.КоличествоРазвернутыйОстатокДт > 0");
expect(plan.query).toContain("Товары.Номенклатура В");
expect(plan.query).toContain("Товары.Ссылка.Дата <= ДАТАВРЕМЯ(2026, 4, 18, 23, 59, 59)");
});
it("renders confirmed inventory-on-hand snapshot from normalized rows", () => {
const reply = composeFactualReply(
"inventory_on_hand_as_of_date",
@ -5776,6 +5904,59 @@ it("routes old purchase residue questions to aging-by-purchase-date", () => {
expect(reply.semantics?.balance_confirmed).toBe(true);
});
it("renders old-stock aging only for items that are still in positive stock", () => {
const reply = composeFactualReply(
"inventory_aging_by_purchase_date",
[
{
period: "2015-02-05T00:00:00Z",
registrator: "Поступление товаров и услуг 00000000001 от 05.02.2015",
account_dt: "41.01",
account_kt: "",
amount: 1000,
analytics: ["Старый проданный товар", 'ООО "Альтернатива Плюс"', "Поставщик А"],
item: "Старый проданный товар",
organization: 'ООО "Альтернатива Плюс"',
counterparty: "Поставщик А",
quantity: 1
},
{
period: "2017-03-10T00:00:00Z",
registrator: "Поступление товаров и услуг 00000000002 от 10.03.2017",
account_dt: "41.01",
account_kt: "",
amount: 2500,
analytics: ["Товар в остатке", 'ООО "Альтернатива Плюс"', "Поставщик Б"],
item: "Товар в остатке",
organization: 'ООО "Альтернатива Плюс"',
counterparty: "Поставщик Б",
quantity: 2
},
{
period: "2026-04-18T23:59:59Z",
registrator: "Остатки на дату",
account_dt: "41.01",
account_kt: "",
amount: 2500,
analytics: ["Товар в остатке", "Основной склад", 'ООО "Альтернатива Плюс"'],
item: "Товар в остатке",
warehouse: "Основной склад",
organization: 'ООО "Альтернатива Плюс"',
quantity: 2
}
],
{
asOfDate: "2026-04-18",
useRubCurrency: true
}
);
expect(reply.responseType).toBe("FACTUAL_SUMMARY");
expect(reply.text.split("\n")[0]).toContain("Товар в остатке");
expect(reply.text).toContain("положительным остатком");
expect(reply.text).not.toContain("Старый проданный товар");
});
it("sorts inventory-on-hand positions by amount before rendering the top list", () => {
const reply = composeFactualReply(
"inventory_on_hand_as_of_date",

View File

@ -270,7 +270,7 @@ describe("address reply builders regressions", () => {
);
const firstLine = result?.text.split("\n")[0] ?? "";
expect(firstLine).toContain("К самым старым закупкам");
expect(firstLine).toContain("фактических положительных остатков");
expect(firstLine).not.toContain("2026-04-18");
});

View File

@ -245,4 +245,90 @@ describe("assistant deep turn response runtime adapter", () => {
})
);
});
it("sanitizes guarded MCP discovery candidates before finalizing the visible reply", () => {
const runPackagingRuntime = vi.fn(() => ({
messageId: "msg-a1",
investigationStateSnapshot: null,
droppedIntentSegments: [],
analysisContextForContract: null,
routesForDebug: [],
resolvedExecutionState: [],
safeAssistantReplyBase: "bad-base",
safeAssistantReply: "bad deep partial answer",
debug: { trace_id: "trace-1" },
assistantItem: {
message_id: "msg-a1",
session_id: "asst-1",
role: "assistant",
text: "bad deep partial answer",
reply_type: "partial_coverage",
created_at: "2026-04-10T00:00:00.000Z",
trace_id: "trace-1",
debug: null
},
deepAnalysisLogDetails: {}
}));
const sanitizeReply = vi.fn((value: string) =>
value.replace(/^\u041a\u043e\u0440\u043e\u0442\u043a\u043e:\s*/u, "")
);
const runFinalizeDeepTurn = vi.fn((input) => ({
response: {
ok: true,
session_id: "asst-1",
assistant_reply: input.assistantReply,
reply_type: input.replyType,
conversation_item: input.assistantItem,
conversation: [],
debug: input.debug
}
}));
const runtime = runAssistantDeepTurnResponseRuntime(
buildBaseInput({
composition: { reply_type: "partial_coverage" },
sanitizeReply,
addressRuntimeMetaForDeep: {
mcpDiscoveryRuntimeEntryPoint: {
schema_version: "assistant_mcp_discovery_runtime_entry_point_v1",
policy_owner: "assistantMcpDiscoveryRuntimeEntryPoint",
entry_status: "bridge_executed",
hot_runtime_wired: false,
discovery_attempted: true,
turn_input: { should_run_discovery: true },
bridge: {
bridge_status: "answer_draft_ready",
user_facing_response_allowed: true,
business_fact_answer_allowed: true,
requires_user_clarification: false,
answer_draft: {
answer_mode: "confirmed_with_bounded_inference",
headline: "Коротко: Discovery answer",
confirmed_lines: ["Confirmed fact"],
inference_lines: [],
unknown_lines: [],
limitation_lines: [],
next_step_line: null
}
},
reason_codes: []
}
},
runPackagingRuntime,
runFinalizeDeepTurn
})
);
expect(sanitizeReply).toHaveBeenCalledWith(expect.not.stringContaining("Коротко:"), "bad deep partial answer");
expect(runtime.response.assistant_reply).toContain("Discovery answer");
expect(runtime.response.assistant_reply).not.toContain("Коротко:");
expect(runFinalizeDeepTurn).toHaveBeenCalledWith(
expect.objectContaining({
assistantReply: expect.not.stringContaining("Коротко:"),
assistantItem: expect.objectContaining({
text: expect.not.stringContaining("Коротко:")
})
})
);
});
});

View File

@ -202,7 +202,8 @@ describe("assistant MCP discovery response candidate", () => {
})
);
expect(candidate.reply_text).toContain("нельзя точно подтвердить чистую прибыль");
expect(candidate.reply_text).toContain("чистую прибыль и маржу не подтверждаю");
expect(candidate.reply_text).toContain("расчетное нетто");
expect(candidate.reply_text).toContain("полный отчет о прибылях и убытках");
expect(candidate.reply_text).toContain("себестоимости");
expect(candidate.reply_text).not.toContain("47 628 853");
@ -464,6 +465,64 @@ describe("assistant MCP discovery response candidate", () => {
expect(candidate.reply_text).not.toContain("business_overview_route_template_v1");
});
it("formats reviewed supplier dependency answers without duplicated period or punctuation", () => {
const candidate = buildAssistantMcpDiscoveryResponseCandidate(
entryPoint({
turn_input: {
adapter_status: "ready",
turn_meaning_ref: {
asked_domain_family: "business_overview",
asked_action_family: "vendor_risk_procurement_boundary",
unsupported_but_understood_family: "vendor_risk_procurement_boundary"
},
data_need_graph: {
business_fact_family: "business_overview",
ranking_need: null,
reason_codes: ["data_need_graph_family_business_overview"]
}
},
bridge: {
bridge_status: "answer_draft_ready",
user_facing_response_allowed: true,
business_fact_answer_allowed: true,
requires_user_clarification: false,
pilot: {
pilot_scope: "business_overview_route_template_v1",
derived_business_overview: {
vendor_procurement_quality: {
period_scope: "за все доступное проверенное окно",
total_outgoing_amount_human_ru: "137 963 720,37 руб.",
top_outgoing_counterparty: {
axis_value: "ГТК-Интер, ООО",
total_amount_human_ru: "44 943 460 руб."
},
top_outgoing_share_pct: 32.58,
used_contracts: 291,
total_contracts: 394,
used_contract_share_pct: 73.86,
evidence_status: "reviewed_procurement_concentration"
}
}
},
answer_draft: {
answer_mode: "confirmed_with_bounded_inference",
headline: "Vendor route.",
confirmed_lines: [],
inference_lines: [],
unknown_lines: [],
limitation_lines: [],
next_step_line: null
}
}
})
);
expect(candidate.reply_text).toContain("ГТК-Интер, ООО держит около 32.58% (44 943 460 руб.)");
expect(candidate.reply_text).toContain("Это не аудит поставщиков");
expect(candidate.reply_text).not.toContain("за за");
expect(candidate.reply_text).not.toContain("руб..");
});
it("uses a compact direct answer for business-overview top year questions", () => {
const candidate = buildAssistantMcpDiscoveryResponseCandidate(
entryPoint({
@ -705,6 +764,77 @@ describe("assistant MCP discovery response candidate", () => {
expect(candidate.reply_text).not.toContain("wide overview");
});
it("leads earnings wording with accounting result when explicit-period proof is available", () => {
const candidate = buildAssistantMcpDiscoveryResponseCandidate(
entryPoint({
turn_input: {
adapter_status: "ready",
turn_meaning_ref: {
raw_message: "а за 2017 мы скок заработали?",
asked_action_family: "broad_evaluation",
unsupported_but_understood_family: "broad_business_evaluation",
explicit_organization_scope: "ООО Альтернатива Плюс",
explicit_date_scope: "2017"
},
data_need_graph: {
business_fact_family: "business_overview",
ranking_need: null,
reason_codes: [
"data_need_graph_family_business_overview",
"data_need_graph_business_overview_direct_money_answer"
]
}
},
bridge: {
bridge_status: "answer_draft_ready",
user_facing_response_allowed: true,
business_fact_answer_allowed: true,
requires_user_clarification: false,
pilot: {
pilot_scope: "business_overview_route_template_v1",
derived_business_overview: {
period_scope: "2017",
incoming_customer_revenue: {
total_amount_human_ru: "16 932 063,96 руб."
},
outgoing_supplier_payout: {
total_amount_human_ru: "15 617 432,52 руб."
},
net_amount_human_ru: "1 314 631,44 руб.",
net_direction: "net_incoming",
top_customers: [],
top_suppliers: [],
accounting_financial_result: {
final_result_direction: "profit",
final_result_amount_human_ru: "116 938,23 руб.",
net_margin_to_revenue_pct: 1.03,
period_scope: "2017"
}
}
},
answer_draft: {
answer_mode: "confirmed_with_bounded_inference",
headline: "wide overview should not leak",
confirmed_lines: [],
inference_lines: [],
unknown_lines: [],
limitation_lines: [],
next_step_line: null
}
}
})
);
expect(candidate.reply_text).toContain("учетная прибыль");
expect(candidate.reply_text).toContain("116 938,23");
expect(candidate.reply_text).toContain("маржа");
expect(candidate.reply_text).toContain("1.03%");
expect(candidate.reply_text).toContain("денежное нетто");
expect(candidate.reply_text).toContain("1 314 631,44");
expect(candidate.reply_text).toContain("разные метрики");
expect(candidate.reply_text).not.toContain("wide overview");
});
it("uses compact cashflow output for one-line no-breakdown wording", () => {
const candidate = buildAssistantMcpDiscoveryResponseCandidate(
entryPoint({
@ -779,6 +909,72 @@ describe("assistant MCP discovery response candidate", () => {
expect(candidate.reply_text).not.toContain("wide overview");
});
it("marks all-time earnings wording as limited when cashflow probes hit row limits", () => {
const candidate = buildAssistantMcpDiscoveryResponseCandidate(
entryPoint({
turn_input: {
adapter_status: "ready",
turn_meaning_ref: {
raw_message: "сколько вообще денег мы заработали за все время?",
asked_action_family: "broad_evaluation",
unsupported_but_understood_family: "broad_business_evaluation",
explicit_organization_scope: "ООО Альтернатива Плюс"
},
data_need_graph: {
business_fact_family: "business_overview",
ranking_need: null,
reason_codes: [
"data_need_graph_family_business_overview",
"data_need_graph_business_overview_direct_money_answer"
]
}
},
bridge: {
bridge_status: "answer_draft_ready",
user_facing_response_allowed: true,
business_fact_answer_allowed: true,
requires_user_clarification: false,
pilot: {
pilot_scope: "business_overview_route_template_v1",
derived_business_overview: {
incoming_customer_revenue: {
total_amount_human_ru: "285 819 547,57 руб.",
coverage_limited_by_probe_limit: true
},
outgoing_supplier_payout: {
total_amount_human_ru: "137 963 720,37 руб.",
coverage_limited_by_probe_limit: true
},
net_amount_human_ru: "147 855 827,20 руб.",
net_direction: "net_incoming",
top_customers: [],
top_suppliers: [],
accounting_financial_result: null,
coverage_limited_by_probe_limit: true
}
},
answer_draft: {
answer_mode: "confirmed_with_bounded_inference",
headline: "wide overview should not leak",
confirmed_lines: [],
inference_lines: [],
unknown_lines: [],
limitation_lines: [],
next_step_line: null
}
}
})
);
expect(candidate.reply_text).toContain("точную учетную прибыль");
expect(candidate.reply_text).toContain("не подтверждаю");
expect(candidate.reply_text).toContain("ограниченном денежном срезе");
expect(candidate.reply_text).toContain("285 819 547,57");
expect(candidate.reply_text).toContain("147 855 827,20");
expect(candidate.reply_text).toContain("не считаю полным бухгалтерским оборотом");
expect(candidate.reply_text).not.toContain("wide overview");
});
it("keeps broad overview layers when direct-money reason is present", () => {
const candidate = buildAssistantMcpDiscoveryResponseCandidate(
entryPoint({
@ -866,7 +1062,7 @@ describe("assistant MCP discovery response candidate", () => {
})
);
expect(candidate.reply_text).toContain("Что видно:");
expect(candidate.reply_text).toContain("Что видно в ограниченном денежном срезе:");
expect(candidate.reply_text).toContain("НДС:");
expect(candidate.reply_text).toContain("Долги:");
expect(candidate.reply_text).toContain("Склад:");
@ -875,6 +1071,67 @@ describe("assistant MCP discovery response candidate", () => {
expect(candidate.reply_text).not.toContain("wide overview");
});
it("answers business overview next-check follow-ups with a concrete analyst plan first", () => {
const candidate = buildAssistantMcpDiscoveryResponseCandidate(
entryPoint({
turn_input: {
adapter_status: "ready",
turn_meaning_ref: {
raw_message: "что дальше проверять в первую очередь?",
asked_action_family: "broad_evaluation",
unsupported_but_understood_family: "broad_business_evaluation",
explicit_organization_scope: "ООО Альтернатива Плюс"
},
data_need_graph: {
business_fact_family: "business_overview",
ranking_need: null,
reason_codes: ["data_need_graph_family_business_overview"]
}
},
bridge: {
bridge_status: "answer_draft_ready",
user_facing_response_allowed: true,
business_fact_answer_allowed: true,
requires_user_clarification: false,
pilot: {
pilot_scope: "business_overview_route_template_v1",
derived_business_overview: {
incoming_customer_revenue: {
total_amount_human_ru: "285 819 547,57 руб.",
coverage_limited_by_probe_limit: true
},
outgoing_supplier_payout: {
total_amount_human_ru: "137 963 720,37 руб.",
coverage_limited_by_probe_limit: true
},
net_amount_human_ru: "147 855 827,20 руб.",
net_direction: "net_incoming",
top_customers: [{ axis_value: "Комитет государственных услуг г. Москвы", total_amount_human_ru: "133 839 880,82 руб." }],
top_suppliers: [{ axis_value: "ГТК-Интер, ООО", total_amount_human_ru: "44 943 460 руб." }]
}
},
answer_draft: {
answer_mode: "confirmed_with_bounded_inference",
headline: "generic advice should not leak",
confirmed_lines: [],
inference_lines: [],
unknown_lines: [],
limitation_lines: [],
next_step_line: null
}
}
})
);
const firstLine = candidate.reply_text?.split("\n")[0] ?? "";
expect(firstLine).toContain("чистую прибыль через закрытие 90/91/99");
expect(firstLine).toContain("топ-3");
expect(firstLine).toContain("долги");
expect(firstLine).toContain("складские остатки");
expect(candidate.reply_text).toContain("не считаю полным бухгалтерским оборотом");
expect(candidate.reply_text).not.toContain("generic advice");
});
it("keeps counterparty leaders for money followups instead of compact cashflow", () => {
const candidate = buildAssistantMcpDiscoveryResponseCandidate(
entryPoint({
@ -1288,6 +1545,11 @@ describe("assistant MCP discovery response candidate", () => {
})
);
const firstLine = candidate.reply_text?.split("\n")[0] ?? "";
expect(firstLine).toContain("по ограниченному проверенному 1С-срезу");
expect(firstLine).toContain("не аудиторское заключение");
expect(firstLine).toContain("не подтвержденная чистая прибыль");
expect(candidate.reply_text).toContain("Что видно в ограниченном денежном срезе");
expect(candidate.reply_text).toContain("Что не подтверждено в этом срезе");
expect(candidate.reply_text).toContain("НДС");
expect(candidate.reply_text).toContain("долги");

View File

@ -48,6 +48,37 @@ describe("assistant MCP discovery response policy", () => {
expect(result.reason_codes).toContain("mcp_discovery_response_policy_candidate_applied");
});
it("removes routine short-answer prefix from applied guarded candidates", () => {
const result = applyAssistantMcpDiscoveryResponsePolicy({
currentReply: "route is not wired",
currentReplySource: "deterministic_unsupported_current_turn_boundary",
modeDecisionReason: "unsupported_current_turn_meaning_boundary",
addressRuntimeMeta: {
mcpDiscoveryRuntimeEntryPoint: entryPoint({
bridge: {
bridge_status: "answer_draft_ready",
user_facing_response_allowed: true,
business_fact_answer_allowed: true,
requires_user_clarification: false,
answer_draft: {
answer_mode: "confirmed_with_bounded_inference",
headline: "Коротко: Confirmed scoped answer.",
confirmed_lines: ["Коротко: Confirmed fact"],
inference_lines: [],
unknown_lines: [],
limitation_lines: [],
next_step_line: null
}
}
})
}
});
expect(result.applied).toBe(true);
expect(result.reply_text).toContain("Confirmed scoped answer.");
expect(result.reply_text).not.toContain("Коротко:");
});
it("keeps the current reply when the turn is not an unsupported boundary", () => {
const result = applyAssistantMcpDiscoveryResponsePolicy({
currentReply: "regular chat",

View File

@ -2637,6 +2637,70 @@ describe("assistant MCP discovery turn input adapter", () => {
expect(result.data_need_graph?.clarification_gaps).toEqual([]);
});
it("keeps supplier dependency follow-ups in business overview instead of pseudo-counterparty bank operations", () => {
const orgName = "ООО Альтернатива Плюс";
const result = buildAssistantMcpDiscoveryTurnInput({
userMessage: "а по поставщикам и исходящим платежам от кого зависимость?",
assistantTurnMeaning: {
asked_domain_family: "counterparty",
asked_action_family: "list_movements",
explicit_intent_candidate: "bank_operations_by_counterparty",
explicit_entity_candidates: ["поставщикам"]
},
followupContext: {
previous_discovery_pilot_scope: "business_overview_route_template_v1",
previous_discovery_loop_selected_chain_id: "business_overview",
previous_filters: {
organization: orgName
}
}
});
expect(result.adapter_status).toBe("ready");
expect(result.should_run_discovery).toBe(true);
expect(result.semantic_data_need).toBe("business overview evidence with bounded analyst interpretation");
expect(result.data_need_graph?.business_fact_family).toBe("business_overview");
expect(result.turn_meaning_ref).toMatchObject({
asked_domain_family: "business_overview",
asked_action_family: "vendor_risk_procurement_boundary",
explicit_organization_scope: orgName,
unsupported_but_understood_family: "vendor_risk_procurement_boundary",
stale_replay_forbidden: true
});
expect(result.turn_meaning_ref?.explicit_entity_candidates).toBeUndefined();
expect(result.reason_codes).toContain("mcp_discovery_business_overview_supplier_dependency_followup_boundary");
});
it("keeps supplier dependency follow-ups in the procurement boundary after broad-business meaning normalization", () => {
const orgName = "ООО Альтернатива Плюс";
const result = buildAssistantMcpDiscoveryTurnInput({
userMessage: "а по поставщикам и исходящим платежам от кого зависимость?",
assistantTurnMeaning: {
asked_domain_family: "business_overview",
asked_action_family: "broad_evaluation",
explicit_organization_scope: orgName,
unsupported_but_understood_family: "broad_business_evaluation"
},
followupContext: {
previous_discovery_pilot_scope: "business_overview_route_template_v1",
previous_filters: {
organization: orgName
}
}
});
expect(result.adapter_status).toBe("ready");
expect(result.should_run_discovery).toBe(true);
expect(result.turn_meaning_ref).toMatchObject({
asked_domain_family: "business_overview",
asked_action_family: "vendor_risk_procurement_boundary",
explicit_organization_scope: orgName,
unsupported_but_understood_family: "vendor_risk_procurement_boundary",
stale_replay_forbidden: true
});
expect(result.reason_codes).toContain("mcp_discovery_business_overview_supplier_dependency_followup_boundary");
});
it("resumes an open-scope total clarification loop from saved state when the user resolves the pending period with all-time wording", () => {
const orgName = "ООО Альтернатива Плюс";
const result = buildAssistantMcpDiscoveryTurnInput({

View File

@ -214,6 +214,152 @@ describe("assistantTransitionPolicy", () => {
expect(contract.decision).toBe("continue_previous");
});
it("carries explicit purchase-date VAT bridge facts without silently losing ambiguity", () => {
const policy = buildPolicy({
findLastAddressAssistantItem: () => ({
role: "assistant",
text: [
"По позиции Рабочая станция до 31.03.2016 однозначный поставщик не подтвержден.",
"- Первая найденная дата закупки: 05.02.2015.",
"- Последняя найденная дата закупки: 22.07.2015."
].join("\n"),
debug: {
detected_intent: "inventory_purchase_provenance_for_item",
extracted_filters: {
item: "Рабочая станция",
organization: 'ООО "Альтернатива Плюс"',
as_of_date: "2016-03-31"
},
anchor_type: "item",
anchor_value_resolved: "Рабочая станция"
}
})
});
const carryover = policy.resolveAddressFollowupCarryoverContext(
"ндс можешь прикинуть на дату покупки рабочей станции?",
[],
null,
null,
null
);
expect(carryover?.followupContext?.target_intent).toBe("vat_liability_confirmed_for_tax_period");
expect(carryover?.followupContext?.previous_filters).toMatchObject({
period_from: "2015-02-01",
period_to: "2015-02-28",
as_of_date: "2015-02-28",
purchase_date_bridge_selected: "2015-02-05",
purchase_date_bridge_first: "2015-02-05",
purchase_date_bridge_last: "2015-07-22",
purchase_date_bridge_basis: "first_confirmed_purchase_default",
purchase_date_bridge_has_multiple: true
});
});
it("can retarget a follow-up VAT recalculation to the last confirmed purchase date", () => {
const items = [
{
role: "assistant",
text: [
"По позиции Рабочая станция до 31.03.2016 однозначный поставщик не подтвержден.",
"- Первая найденная дата закупки: 05.02.2015.",
"- Последняя найденная дата закупки: 22.07.2015."
].join("\n"),
debug: {
detected_intent: "inventory_purchase_provenance_for_item",
extracted_filters: {
item: "Рабочая станция",
organization: 'ООО "Альтернатива Плюс"'
}
}
},
{
role: "assistant",
text: "Коротко: если брать первую подтвержденную дату закупки 05.02.2015, НДС к уплате 100.",
debug: {
detected_intent: "vat_liability_confirmed_for_tax_period",
extracted_filters: {
item: "Рабочая станция",
organization: 'ООО "Альтернатива Плюс"',
purchase_date_bridge_selected: "2015-02-05"
}
}
}
];
const policy = buildPolicy({
findLastAddressAssistantItem: (sourceItems: unknown[]) => {
const list = sourceItems as Record<string, unknown>[];
return list[list.length - 1];
}
});
const carryover = policy.resolveAddressFollowupCarryoverContext(
"а теперь по последней подтвержденной покупке",
items,
null,
null,
{
session_context: {
active_focus_object: {
object_type: "item",
label: "Рабочая станция"
}
}
}
);
expect(carryover?.followupContext?.target_intent).toBe("vat_liability_confirmed_for_tax_period");
expect(carryover?.followupContext?.previous_filters).toMatchObject({
period_from: "2015-07-01",
period_to: "2015-07-31",
as_of_date: "2015-07-31",
purchase_date_bridge_selected: "2015-07-22",
purchase_date_bridge_basis: "last_confirmed_purchase"
});
});
it("continues purchase-date VAT recalculation from prior VAT bridge filters without relying on active item focus", () => {
const policy = buildPolicy({
findLastAddressAssistantItem: () => ({
role: "assistant",
text: "Коротко: если брать первую подтвержденную дату закупки 05.02.2015, НДС к уплате 100.",
debug: {
detected_intent: "vat_liability_confirmed_for_tax_period",
extracted_filters: {
organization: 'ООО "Альтернатива Плюс"',
period_from: "2015-02-01",
period_to: "2015-02-28",
as_of_date: "2015-02-28",
purchase_date_bridge_selected: "2015-02-05",
purchase_date_bridge_first: "2015-02-05",
purchase_date_bridge_last: "2015-07-22",
purchase_date_bridge_basis: "first_confirmed_purchase_default",
purchase_date_bridge_has_multiple: true
}
}
})
});
const carryover = policy.resolveAddressFollowupCarryoverContext(
"если у нее несколько закупок, посчитай по первой подтвержденной покупке",
[],
null,
null,
null
);
expect(carryover?.followupContext?.target_intent).toBe("vat_liability_confirmed_for_tax_period");
expect(carryover?.followupContext?.previous_filters).toMatchObject({
period_from: "2015-02-01",
period_to: "2015-02-28",
as_of_date: "2015-02-28",
purchase_date_bridge_selected: "2015-02-05",
purchase_date_bridge_basis: "first_confirmed_purchase",
purchase_date_bridge_has_multiple: true
});
});
it("retargets displayed counterparty follow-up through shared referenced-entity carryover", () => {
const policy = buildPolicy({
findLastAddressAssistantItem: () => ({

View File

@ -303,6 +303,22 @@ describe("assistantTurnMeaningPolicy", () => {
expect(supplierQuality.asked_action_family).toBe("broad_evaluation");
expect(supplierQuality.unsupported_but_understood_family).toBe("broad_business_evaluation");
expect(supplierQuality.reason_codes).toContain("broad_business_evaluation_current_turn_signal");
const supplierDependencyFollowup = policy.resolveAssistantTurnMeaning({
rawUserMessage: "а по поставщикам и исходящим платежам от кого зависимость?"
});
expect(supplierDependencyFollowup.explicit_intent_candidate).toBeNull();
expect(supplierDependencyFollowup.asked_domain_family).toBe("business_summary");
expect(supplierDependencyFollowup.asked_action_family).toBe("broad_evaluation");
expect(supplierDependencyFollowup.unsupported_but_understood_family).toBe("broad_business_evaluation");
const nextChecks = policy.resolveAssistantTurnMeaning({
rawUserMessage: "что дальше проверять в первую очередь?"
});
expect(nextChecks.explicit_intent_candidate).toBeNull();
expect(nextChecks.asked_domain_family).toBe("business_summary");
expect(nextChecks.asked_action_family).toBe("broad_evaluation");
expect(nextChecks.unsupported_but_understood_family).toBe("broad_business_evaluation");
});
it("keeps selected-object profitability as an inventory exact intent instead of company overview", () => {

View File

@ -149,6 +149,40 @@ describe("counterparty analytics reply builders", () => {
expect(reply.text).not.toContain("1. 2020 |");
});
it("preserves raw dependency wording when the address lane receives a canonicalized inflow ranking", () => {
const reply = composeFactualReply(
"customer_revenue_and_payments",
[
{
period: "2020-03-01T00:00:00Z",
registrator: "Поступление 1",
account_dt: "",
account_kt: "",
amount: 500,
analytics: ["Малый клиент", "Договор 1"]
},
{
period: "2020-03-02T00:00:00Z",
registrator: "Поступление 2",
account_dt: "",
account_kt: "",
amount: 1200,
analytics: ["Главный клиент", "Договор 2"]
}
],
{
rawUserMessage: "от кого мы больше всего зависим по входящим деньгам?",
userMessage: "определить контрагентов с наибольшим объемом входящих денежных потоков"
}
);
expect(reply.responseType).toBe("FACTUAL_LIST");
expect(reply.text.split("\n")[0]).toContain("Главный сигнал зависимости по входящим деньгам");
expect(reply.text.split("\n")[0]).toContain("Главный клиент");
expect(reply.text).toContain("сигнал концентрации");
expect(reply.text).toContain("не чистая прибыль");
});
it("explains organization activity age as 1C activity rather than legal age", () => {
const reply = composeFactualReply(
"counterparty_activity_lifecycle",

View File

@ -1,6 +1,6 @@
{
"schema_version": "shared_llm_connection_v1",
"updated_at": "2026-04-18T14:51:55.518Z",
"updated_at": "2026-05-25T13:27:06.297Z",
"connection": {
"llmProvider": "local",
"model": "unsloth/qwen3-30b-a3b-instruct-2507",

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

View File

@ -312,7 +312,6 @@ export const apiClient = {
async sendAssistantMessage(input: {
connection: ConnectionState;
prompts: PromptState;
userMessage: string;
sessionId?: string;
promptVersion?: string;
@ -336,10 +335,6 @@ export const apiClient = {
temperature: input.connection.temperature,
maxOutputTokens: input.connection.maxOutputTokens,
promptVersion: input.promptVersion ?? "address_query_runtime_v1",
systemPrompt: input.prompts.systemPrompt,
developerPrompt: input.prompts.developerPrompt,
domainPrompt: input.prompts.domainPrompt,
fewShotExamples: input.prompts.fewShotExamples,
context: {
period_hint: input.context?.periodHint ?? "",
business_context: input.context?.businessContext ?? ""
@ -362,7 +357,6 @@ export const apiClient = {
model?: string;
assistant_prompt_version?: string;
decomposition_prompt_version?: string;
prompt_fingerprint?: string;
};
}): Promise<{ ok: boolean; generation: AutoGenHistoryRecord }> {
return request("/autoruns/autogen/save-assistant-session", {
@ -534,35 +528,5 @@ export const apiClient = {
return request(`/autoruns/autogen/history/${encodeURIComponent(generationId)}`, {
method: "DELETE"
});
},
async generateAutoRunQuestions(input: {
mode: AutoGenMode;
count: number;
domain?: string;
persist_to_eval_cases?: boolean;
generated_by?: string;
llm?: {
llm_provider?: "openai" | "local";
api_key?: string;
model?: string;
base_url?: string;
temperature?: number;
max_output_tokens?: number;
};
context?: {
llm_provider?: string;
model?: string;
assistant_prompt_version?: string;
decomposition_prompt_version?: string;
prompt_fingerprint?: string;
autogen_personality_id?: string;
autogen_personality_prompt?: string;
};
}): Promise<{ ok: boolean; generation: AutoGenHistoryRecord }> {
return request("/autoruns/autogen/generate", {
method: "POST",
body: JSON.stringify(input)
});
}
};

View File

@ -244,7 +244,7 @@ type UnifiedCommentListItem =
};
const DEFAULT_AUTOGEN_SETTINGS: AutoGenSettingsState = {
mode: "codex_creative",
mode: "saved_user_sessions",
count: 24,
personalityId: "general",
personalityPrompts: buildDefaultPersonalityPrompts(),
@ -723,7 +723,6 @@ export function AutoRunsHistoryPanel({
const [dragOverQuestionIndex, setDragOverQuestionIndex] = useState<number | null>(null);
const [activeAsyncJob, setActiveAsyncJob] = useState<AsyncEvalRunJob | null>(null);
const [postAnalysis, setPostAnalysis] = useState<AutoRunPostAnalysisResponse | null>(null);
const [autoGenBusy, setAutoGenBusy] = useState(false);
const [autogenRunBusy, setAutogenRunBusy] = useState(false);
const [postAnalysisBusy, setPostAnalysisBusy] = useState(false);
const [autogenHistoryBusy, setAutogenHistoryBusy] = useState(false);
@ -801,10 +800,6 @@ export function AutoRunsHistoryPanel({
const asyncJobPollTimerRef = useRef<number | null>(null);
const questionEditorRef = useRef<HTMLInputElement | null>(null);
const isSavedUserSessionsMode = autoGenSettings.mode === "saved_user_sessions";
const selectedPersonality = useMemo(
() => autogenPersonalities.find((item) => item.id === autoGenSettings.personalityId) ?? autogenPersonalities[0] ?? AUTOGEN_PERSONALITIES[0],
[autoGenSettings.personalityId, autogenPersonalities]
);
const visibleAutoGenHistory = useMemo(
() => autoGenHistory.filter((item) => item.mode === autoGenSettings.mode),
[autoGenHistory, autoGenSettings.mode]
@ -1104,7 +1099,6 @@ export function AutoRunsHistoryPanel({
try {
const response = await apiClient.sendAssistantMessage({
connection,
prompts,
userMessage,
sessionId: assistantLiveSessionId || undefined,
promptVersion: assistantPromptVersion,
@ -1132,8 +1126,7 @@ export function AutoRunsHistoryPanel({
assistantPromptVersion,
connection,
loadAssistantLiveAnnotationsForSession,
log,
prompts
log
]);
const openAssistantLiveSaveModal = useCallback(() => {
@ -1164,13 +1157,6 @@ export function AutoRunsHistoryPanel({
setAssistantLiveSaveModal((prev) => ({ ...prev, saving: true, error: "" }));
try {
const promptFingerprint = [
prompts.systemPrompt,
prompts.developerPrompt,
prompts.domainPrompt,
prompts.schemaNotes,
prompts.fewShotExamples
].join("||");
const payload = await apiClient.saveAutoRunAssistantSession({
session_id: sessionId,
title,
@ -1179,8 +1165,7 @@ export function AutoRunsHistoryPanel({
llm_provider: connection.llmProvider,
model: connection.model,
assistant_prompt_version: assistantPromptVersion,
decomposition_prompt_version: decompositionPromptVersion,
prompt_fingerprint: promptFingerprint
decomposition_prompt_version: decompositionPromptVersion
}
});
setAutoGenHistory((prev) => [payload.generation, ...prev.filter((item) => item.generation_id !== payload.generation.generation_id)]);
@ -1202,12 +1187,7 @@ export function AutoRunsHistoryPanel({
connection.llmProvider,
connection.model,
decompositionPromptVersion,
log,
prompts.developerPrompt,
prompts.domainPrompt,
prompts.fewShotExamples,
prompts.schemaNotes,
prompts.systemPrompt
log
]);
const commitLimitInput = useCallback(
@ -1363,87 +1343,6 @@ export function AutoRunsHistoryPanel({
}
}, [filters.fromLocal, filters.mode, filters.promptContains, filters.target, filters.toLocal, filters.useMock, log, selectedRunId]);
const generateAutogenBatch = useCallback(async () => {
setAutoGenBusy(true);
setErrorText("");
try {
if (autoGenSettings.mode === "saved_user_sessions") {
throw new Error("Пользовательские сессии сохраняются из живого чата, а не генерируются автоматически.");
}
const activePersonalityPrompt = autoGenSettings.personalityPrompts[autoGenSettings.personalityId] ?? "";
const promptFingerprint = [
prompts.systemPrompt,
prompts.developerPrompt,
prompts.domainPrompt,
prompts.schemaNotes,
prompts.fewShotExamples
]
.join("\n")
.slice(0, 900);
const payload = await apiClient.generateAutoRunQuestions({
mode: autoGenSettings.mode,
count: autoGenSettings.count,
domain: selectedPersonality.domain || undefined,
persist_to_eval_cases: autoGenSettings.persistToEvalCases,
generated_by: autoGenSettings.generatedBy.trim() || undefined,
llm: {
llm_provider: connection.llmProvider,
api_key: connection.apiKey,
model: connection.model,
base_url: connection.baseUrl,
temperature: connection.temperature,
max_output_tokens: connection.maxOutputTokens
},
context: {
llm_provider: connection.llmProvider,
model: connection.model,
assistant_prompt_version: assistantPromptVersion,
decomposition_prompt_version: decompositionPromptVersion,
prompt_fingerprint: promptFingerprint,
autogen_personality_id: selectedPersonality.id,
autogen_personality_prompt: activePersonalityPrompt.trim() || undefined
}
});
log(
`Generated ${payload.generation.count} questions (${payload.generation.mode}) id=${payload.generation.generation_id}` +
(payload.generation.saved_case_set_file ? ` saved=${payload.generation.saved_case_set_file}` : "")
);
setSelectedAutogenGenerationId(payload.generation.generation_id);
setEditableGeneratedQuestions([...(payload.generation.questions ?? [])]);
await loadAutoGenHistory();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setErrorText(`Автогенерация: ${message}`);
log(`Autogen generate error: ${message}`);
} finally {
setAutoGenBusy(false);
}
}, [
assistantPromptVersion,
autoGenSettings.count,
autoGenSettings.generatedBy,
autoGenSettings.mode,
autoGenSettings.personalityId,
autoGenSettings.personalityPrompts,
autoGenSettings.persistToEvalCases,
connection.apiKey,
connection.baseUrl,
connection.llmProvider,
connection.maxOutputTokens,
connection.model,
connection.temperature,
decompositionPromptVersion,
loadAutoGenHistory,
log,
prompts.developerPrompt,
prompts.domainPrompt,
prompts.fewShotExamples,
prompts.schemaNotes,
prompts.systemPrompt,
selectedPersonality.domain,
selectedPersonality.id
]);
const loadCaseDialog = useCallback(
async (runId: string, caseId: string) => {
if (isLiveRunId(runId)) {
@ -2477,12 +2376,7 @@ export function AutoRunsHistoryPanel({
: prev.personalityId;
return {
...prev,
mode:
parsed.autoGenSettings?.mode === "codex_creative" ||
parsed.autoGenSettings?.mode === "qwen_seed" ||
parsed.autoGenSettings?.mode === "saved_user_sessions"
? parsed.autoGenSettings.mode
: prev.mode,
mode: "saved_user_sessions",
count:
typeof parsed.autoGenSettings?.count === "number"
? Math.max(1, Math.min(200, parsed.autoGenSettings.count))
@ -2767,7 +2661,7 @@ export function AutoRunsHistoryPanel({
) : null}
<div className="autoruns-group-heading">
<h4>Автопрогоны</h4>
<h4>Сохранённые сессии</h4>
<button
type="button"
className="autoruns-group-toggle"
@ -2780,20 +2674,19 @@ export function AutoRunsHistoryPanel({
</div>
{autogenGroupExpanded ? (
<>
<div className="autoruns-form-grid">
<label>
Режимы
<select
value={autoGenSettings.mode}
onChange={(event) => setAutoGenSettings((prev) => ({ ...prev, mode: event.target.value as AutoGenMode }))}
>
<option value="codex_creative">codex_creative</option>
<option value="qwen_seed">qwen_seed</option>
<option value="saved_user_sessions">Пользовательские сессии</option>
</select>
</label>
{!isSavedUserSessionsMode ? (
<>
<div className="autoruns-form-grid">
<label>
Режим
<select
value={autoGenSettings.mode}
disabled
onChange={(event) => setAutoGenSettings((prev) => ({ ...prev, mode: event.target.value as AutoGenMode }))}
>
<option value="saved_user_sessions">Пользовательские сессии</option>
</select>
</label>
{!isSavedUserSessionsMode ? (
<>
<label>
Кол-во
<input
@ -2892,28 +2785,22 @@ export function AutoRunsHistoryPanel({
) : null}
<div className="button-row">
{!isSavedUserSessionsMode ? (
<>
<button type="button" disabled={autoGenBusy} onClick={() => void generateAutogenBatch()}>
{autoGenBusy ? "Генерирую..." : "Сгенерировать пачку"}
</button>
<button type="button" className="tab" disabled={autogenHistoryBusy} onClick={() => void loadAutoGenHistory()}>
{autogenHistoryBusy ? "Обновляю..." : "Обновить историю"}
</button>
</>
) : null}
<button
type="button"
className="autoruns-run-launch-btn"
style={isSavedUserSessionsMode ? { display: "none" } : undefined}
disabled={
autogenStopBusy ||
(!autogenRunBusy && (editableGeneratedQuestions.length === 0 || !selectedAutogenGeneration))
}
onClick={() => void (autogenRunBusy ? stopAutogenCampaign() : runAutogenCampaign())}
>
{autogenRunBusy ? (autogenStopBusy ? "Останавливаю..." : "Остановить прогон") : "Запустить прогон"}
<button type="button" className="tab" disabled={autogenHistoryBusy} onClick={() => void loadAutoGenHistory()}>
{autogenHistoryBusy ? "Обновляю..." : "Обновить историю"}
</button>
{!isSavedUserSessionsMode ? (
<button
type="button"
className="autoruns-run-launch-btn"
disabled={
autogenStopBusy ||
(!autogenRunBusy && (editableGeneratedQuestions.length === 0 || !selectedAutogenGeneration))
}
onClick={() => void (autogenRunBusy ? stopAutogenCampaign() : runAutogenCampaign())}
>
{autogenRunBusy ? (autogenStopBusy ? "Останавливаю..." : "Остановить прогон") : "Запустить прогон"}
</button>
) : null}
</div>
<div className="autoruns-form-grid">

View File

@ -111,8 +111,8 @@ export function PromptPanel({
<section className="embedded-panel-section">
<div className="embedded-panel-section-header">
<div>
<h4>Prompt Manager</h4>
<p>Системный, developer и domain уровни управляются отдельно.</p>
<h4>Normalizer / eval prompts</h4>
<p>Эти поля управляют декомпозицией и eval-прогонами, а не live-ответом ассистента.</p>
</div>
</div>
{content}
@ -121,7 +121,7 @@ export function PromptPanel({
}
return (
<PanelFrame title="Prompt Manager" subtitle="Системный, developer и domain уровни управляются отдельно.">
<PanelFrame title="Normalizer / eval prompts" subtitle="Эти поля управляют декомпозицией и eval-прогонами, а не live-ответом ассистента.">
{content}
</PanelFrame>
);

View File

@ -3,6 +3,7 @@ from __future__ import annotations
import argparse
import fnmatch
import json
import os
import re
import subprocess
import sys
@ -435,9 +436,22 @@ def dump_json(payload: Any) -> str:
return json.dumps(payload, ensure_ascii=False, indent=2)
def resolve_windows_extended_path(file_path: Path) -> Path:
if os.name == "nt":
resolved = str(file_path.resolve())
if not resolved.startswith("\\\\?\\"):
if resolved.startswith("\\\\"):
resolved = "\\\\?\\UNC\\" + resolved.lstrip("\\")
else:
resolved = "\\\\?\\" + resolved
return Path(resolved)
return file_path
def write_text(file_path: Path, text: str) -> None:
file_path.parent.mkdir(parents=True, exist_ok=True)
file_path.write_text(text, encoding="utf-8", newline="\n")
target_path = resolve_windows_extended_path(file_path)
target_path.parent.mkdir(parents=True, exist_ok=True)
target_path.write_text(text, encoding="utf-8", newline="\n")
def write_json(file_path: Path, payload: Any) -> None:
@ -3016,6 +3030,14 @@ def build_scenario_step_state(
"entries": entries,
}
step_state["execution_status"] = derive_step_execution_status(reply_type if isinstance(reply_type, str) else None, debug)
if (
"answer_inspection" in normalize_string_list(step_state.get("semantic_tags"))
and step_state["execution_status"] == "needs_exact_capability"
and reply_type in {"factual", "factual_with_explanation"}
and assistant_text.strip()
):
step_state["execution_status"] = "exact"
step_state["answer_inspection_chat_validated"] = True
step_state["acceptance_status"] = step_state["execution_status"]
step_state["status"] = step_state["acceptance_status"]
return validate_step_contract(step_state)

View File

@ -1,6 +1,9 @@
from __future__ import annotations
import os
import shutil
import sys
import tempfile
import unittest
from pathlib import Path
@ -12,6 +15,27 @@ import domain_truth_harness as dth
class DomainCaseLoopStepStateTests(unittest.TestCase):
@unittest.skipIf(os.name != "nt", "extended-length path handling is Windows-specific")
def test_write_json_supports_long_windows_artifact_paths(self) -> None:
temp_dir = tempfile.mkdtemp()
try:
base = Path(temp_dir)
long_path = (
base
/ ("artifact_root_" + "a" * 80)
/ ("scenario_" + "b" * 80)
/ ("step_" + "c" * 80)
/ "assistant_response.json"
)
dcl.write_json(long_path, {"ok": True})
extended_path = dcl.resolve_windows_extended_path(long_path)
self.assertTrue(extended_path.exists())
self.assertIn('"ok": true', extended_path.read_text(encoding="utf-8-sig"))
finally:
shutil.rmtree(dcl.resolve_windows_extended_path(Path(temp_dir)), ignore_errors=True)
def test_preserves_mcp_catalog_alignment_debug_fields(self) -> None:
step_state = dcl.build_scenario_step_state(
scenario_id="planner_alignment_demo",