diff --git a/docs/orchestration/assistant_stage1_hm_vat_period_oldstock_regression_20260525.json b/docs/orchestration/assistant_stage1_hm_vat_period_oldstock_regression_20260525.json new file mode 100644 index 0000000..0ecb1a7 --- /dev/null +++ b/docs/orchestration/assistant_stage1_hm_vat_period_oldstock_regression_20260525.json @@ -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"] + } + ] +} diff --git a/docs/orchestration/assistant_stage1_y9_inventory_selected_object_regression_20260525.json b/docs/orchestration/assistant_stage1_y9_inventory_selected_object_regression_20260525.json new file mode 100644 index 0000000..f97920a --- /dev/null +++ b/docs/orchestration/assistant_stage1_y9_inventory_selected_object_regression_20260525.json @@ -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"] + } + ] + } + ] +} diff --git a/llm_normalizer/backend/dist/routes/autoRuns.js b/llm_normalizer/backend/dist/routes/autoRuns.js index 4718224..41acec2 100644 --- a/llm_normalizer/backend/dist/routes/autoRuns.js +++ b/llm_normalizer/backend/dist/routes/autoRuns.js @@ -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); diff --git a/llm_normalizer/backend/dist/services/addressQueryService.js b/llm_normalizer/backend/dist/services/addressQueryService.js index 7962d87..c0848ac 100644 --- a/llm_normalizer/backend/dist/services/addressQueryService.js +++ b/llm_normalizer/backend/dist/services/addressQueryService.js @@ -3195,6 +3195,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 }); diff --git a/llm_normalizer/backend/dist/services/addressRecipeCatalog.js b/llm_normalizer/backend/dist/services/addressRecipeCatalog.js index 6a4d038..e601670 100644 --- a/llm_normalizer/backend/dist/services/addressRecipeCatalog.js +++ b/llm_normalizer/backend/dist/services/addressRecipeCatalog.js @@ -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" diff --git a/llm_normalizer/backend/dist/services/address_runtime/composeStage.js b/llm_normalizer/backend/dist/services/address_runtime/composeStage.js index fcd7214..b2e861e 100644 --- a/llm_normalizer/backend/dist/services/address_runtime/composeStage.js +++ b/llm_normalizer/backend/dist/services/address_runtime/composeStage.js @@ -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)}.` diff --git a/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js b/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js index 8bb6d84..22341e0 100644 --- a/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js +++ b/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js @@ -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 { diff --git a/llm_normalizer/backend/dist/services/address_runtime/inventoryReplyBuilders.js b/llm_normalizer/backend/dist/services/address_runtime/inventoryReplyBuilders.js index 7139b94..1ac879b 100644 --- a/llm_normalizer/backend/dist/services/address_runtime/inventoryReplyBuilders.js +++ b/llm_normalizer/backend/dist/services/address_runtime/inventoryReplyBuilders.js @@ -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)); diff --git a/llm_normalizer/backend/dist/services/assistantAddressLaneResponseRuntimeAdapter.js b/llm_normalizer/backend/dist/services/assistantAddressLaneResponseRuntimeAdapter.js index ea7fe54..a96b764 100644 --- a/llm_normalizer/backend/dist/services/assistantAddressLaneResponseRuntimeAdapter.js +++ b/llm_normalizer/backend/dist/services/assistantAddressLaneResponseRuntimeAdapter.js @@ -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); diff --git a/llm_normalizer/backend/dist/services/assistantAddressOrchestrationRuntimeAdapter.js b/llm_normalizer/backend/dist/services/assistantAddressOrchestrationRuntimeAdapter.js index 6ee74ed..211e960 100644 --- a/llm_normalizer/backend/dist/services/assistantAddressOrchestrationRuntimeAdapter.js +++ b/llm_normalizer/backend/dist/services/assistantAddressOrchestrationRuntimeAdapter.js @@ -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); diff --git a/llm_normalizer/backend/dist/services/assistantDeepTurnResponseRuntimeAdapter.js b/llm_normalizer/backend/dist/services/assistantDeepTurnResponseRuntimeAdapter.js index 3b675d3..59f7d24 100644 --- a/llm_normalizer/backend/dist/services/assistantDeepTurnResponseRuntimeAdapter.js +++ b/llm_normalizer/backend/dist/services/assistantDeepTurnResponseRuntimeAdapter.js @@ -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, diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponsePolicy.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponsePolicy.js index 25d7b7c..d60955c 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponsePolicy.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponsePolicy.js @@ -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 diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js index ab77ba0..0f7dbb2 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js @@ -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)) { @@ -1823,11 +1824,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 +2016,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 ?? diff --git a/llm_normalizer/backend/dist/services/assistantService.js b/llm_normalizer/backend/dist/services/assistantService.js index 1253d8d..d42ea6b 100644 --- a/llm_normalizer/backend/dist/services/assistantService.js +++ b/llm_normalizer/backend/dist/services/assistantService.js @@ -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; } diff --git a/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js b/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js index e462453..fdd280c 100644 --- a/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js +++ b/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js @@ -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); diff --git a/llm_normalizer/backend/src/routes/autoRuns.ts b/llm_normalizer/backend/src/routes/autoRuns.ts index 46c8726..743b892 100644 --- a/llm_normalizer/backend/src/routes/autoRuns.ts +++ b/llm_normalizer/backend/src/routes/autoRuns.ts @@ -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); } diff --git a/llm_normalizer/backend/src/services/addressQueryService.ts b/llm_normalizer/backend/src/services/addressQueryService.ts index 8d3d4ae..b49e6a4 100644 --- a/llm_normalizer/backend/src/services/addressQueryService.ts +++ b/llm_normalizer/backend/src/services/addressQueryService.ts @@ -3944,6 +3944,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 }); diff --git a/llm_normalizer/backend/src/services/addressRecipeCatalog.ts b/llm_normalizer/backend/src/services/addressRecipeCatalog.ts index 8886b0d..c485b58 100644 --- a/llm_normalizer/backend/src/services/addressRecipeCatalog.ts +++ b/llm_normalizer/backend/src/services/addressRecipeCatalog.ts @@ -1520,6 +1520,91 @@ function buildInventoryMovementQuery( .replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort)); } +function buildStaticWhereClause(conditions: Array, 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" diff --git a/llm_normalizer/backend/src/services/address_runtime/composeStage.ts b/llm_normalizer/backend/src/services/address_runtime/composeStage.ts index 48bca55..dc07b22 100644 --- a/llm_normalizer/backend/src/services/address_runtime/composeStage.ts +++ b/llm_normalizer/backend/src/services/address_runtime/composeStage.ts @@ -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; + itemOrgKeys: Set; + fullKeys: Set; + orgKeys: Set; +} + +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)}.` diff --git a/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts b/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts index 2a93d9a..2fbfd8f 100644 --- a/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts +++ b/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts @@ -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") ) { diff --git a/llm_normalizer/backend/src/services/address_runtime/inventoryReplyBuilders.ts b/llm_normalizer/backend/src/services/address_runtime/inventoryReplyBuilders.ts index 997edbe..30fd23c 100644 --- a/llm_normalizer/backend/src/services/address_runtime/inventoryReplyBuilders.ts +++ b/llm_normalizer/backend/src/services/address_runtime/inventoryReplyBuilders.ts @@ -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( diff --git a/llm_normalizer/backend/src/services/address_runtime/replyPackaging.ts b/llm_normalizer/backend/src/services/address_runtime/replyPackaging.ts index 1b1beae..6a37fb3 100644 --- a/llm_normalizer/backend/src/services/address_runtime/replyPackaging.ts +++ b/llm_normalizer/backend/src/services/address_runtime/replyPackaging.ts @@ -11,6 +11,13 @@ export interface ComposeFactualReplyOptions { 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; } diff --git a/llm_normalizer/backend/src/services/assistantAddressLaneResponseRuntimeAdapter.ts b/llm_normalizer/backend/src/services/assistantAddressLaneResponseRuntimeAdapter.ts index f10a07f..ef657c6 100644 --- a/llm_normalizer/backend/src/services/assistantAddressLaneResponseRuntimeAdapter.ts +++ b/llm_normalizer/backend/src/services/assistantAddressLaneResponseRuntimeAdapter.ts @@ -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; session: unknown; userMessage?: unknown; }): { reply: string; audit: Record } | 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); diff --git a/llm_normalizer/backend/src/services/assistantAddressOrchestrationRuntimeAdapter.ts b/llm_normalizer/backend/src/services/assistantAddressOrchestrationRuntimeAdapter.ts index cbfdf31..c4a533a 100644 --- a/llm_normalizer/backend/src/services/assistantAddressOrchestrationRuntimeAdapter.ts +++ b/llm_normalizer/backend/src/services/assistantAddressOrchestrationRuntimeAdapter.ts @@ -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 | null { @@ -169,6 +169,37 @@ function dateScopeToFilterWindow(dateScope: string | null): Record | null): Record | 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 | 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 = { ...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 | null, toNonEmptyString: BuildAssistantAddressOrchestrationRuntimeInput["toNonEmptyString"] @@ -489,6 +531,7 @@ function mergeBusinessOverviewProofBundlesFromNavigationState(input: { followupContext: Record | null; sessionAddressNavigationState: unknown; predecomposeContract: Record | null; + userMessage: string; toNonEmptyString: BuildAssistantAddressOrchestrationRuntimeInput["toNonEmptyString"]; }): Record | 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 | null; + userMessage: string; sessionItems: unknown[]; toNonEmptyString: BuildAssistantAddressOrchestrationRuntimeInput["toNonEmptyString"]; }): Record | 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( diff --git a/llm_normalizer/backend/src/services/assistantDeepTurnResponseRuntimeAdapter.ts b/llm_normalizer/backend/src/services/assistantDeepTurnResponseRuntimeAdapter.ts index 3b55bc9..e733f90 100644 --- a/llm_normalizer/backend/src/services/assistantDeepTurnResponseRuntimeAdapter.ts +++ b/llm_normalizer/backend/src/services/assistantDeepTurnResponseRuntimeAdapter.ts @@ -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 | 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, diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponsePolicy.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponsePolicy.ts index 7ec0edb..08ba7f8 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponsePolicy.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponsePolicy.ts @@ -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 diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts index 51c790b..ea7df24 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts @@ -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 ) || @@ -2458,6 +2461,8 @@ export function buildAssistantMcpDiscoveryTurnInput( ? null : explicitCurrentCounterpartyOverridesFollowupEntity ? null + : businessOverviewSuppressesFollowupCounterparty + ? rawMetadataScopeHint : rawMetadataScopeHint ?? followupSeed.metadataScopeHint ?? followupSeed.discoveryEntity ?? @@ -2685,7 +2690,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 diff --git a/llm_normalizer/backend/src/services/assistantService.ts b/llm_normalizer/backend/src/services/assistantService.ts index 7586f46..025439e 100644 --- a/llm_normalizer/backend/src/services/assistantService.ts +++ b/llm_normalizer/backend/src/services/assistantService.ts @@ -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; } diff --git a/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts b/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts index a13f2c1..7999da6 100644 --- a/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts +++ b/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts @@ -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( diff --git a/llm_normalizer/backend/src/types/addressQuery.ts b/llm_normalizer/backend/src/types/addressQuery.ts index cdc394f..83a1627 100644 --- a/llm_normalizer/backend/src/types/addressQuery.ts +++ b/llm_normalizer/backend/src/types/addressQuery.ts @@ -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"; diff --git a/llm_normalizer/backend/tests/addressFollowupTemporalRegression.test.ts b/llm_normalizer/backend/tests/addressFollowupTemporalRegression.test.ts index 538d7b0..cd24a46 100644 --- a/llm_normalizer/backend/tests/addressFollowupTemporalRegression.test.ts +++ b/llm_normalizer/backend/tests/addressFollowupTemporalRegression.test.ts @@ -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("АЛЬТЕРНАТИВА", { diff --git a/llm_normalizer/backend/tests/addressInventoryAgingFollowup.test.ts b/llm_normalizer/backend/tests/addressInventoryAgingFollowup.test.ts index ec04baa..7fd0588 100644 --- a/llm_normalizer/backend/tests/addressInventoryAgingFollowup.test.ts +++ b/llm_normalizer/backend/tests/addressInventoryAgingFollowup.test.ts @@ -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", diff --git a/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts b/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts index 0e03d7d..a417c41 100644 --- a/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts +++ b/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts @@ -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", @@ -5741,6 +5784,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 +5837,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", diff --git a/llm_normalizer/backend/tests/addressReplyBuildersRegression.test.ts b/llm_normalizer/backend/tests/addressReplyBuildersRegression.test.ts index 4a5b52a..12803ed 100644 --- a/llm_normalizer/backend/tests/addressReplyBuildersRegression.test.ts +++ b/llm_normalizer/backend/tests/addressReplyBuildersRegression.test.ts @@ -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"); }); diff --git a/llm_normalizer/backend/tests/assistantDeepTurnResponseRuntimeAdapter.test.ts b/llm_normalizer/backend/tests/assistantDeepTurnResponseRuntimeAdapter.test.ts index 4209bc6..c80cc5f 100644 --- a/llm_normalizer/backend/tests/assistantDeepTurnResponseRuntimeAdapter.test.ts +++ b/llm_normalizer/backend/tests/assistantDeepTurnResponseRuntimeAdapter.test.ts @@ -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("Коротко:") + }) + }) + ); + }); }); diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryResponsePolicy.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryResponsePolicy.test.ts index c385c13..c221f1e 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryResponsePolicy.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryResponsePolicy.test.ts @@ -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", diff --git a/llm_normalizer/backend/tests/assistantTransitionPolicy.test.ts b/llm_normalizer/backend/tests/assistantTransitionPolicy.test.ts index c57a06e..f88028e 100644 --- a/llm_normalizer/backend/tests/assistantTransitionPolicy.test.ts +++ b/llm_normalizer/backend/tests/assistantTransitionPolicy.test.ts @@ -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[]; + 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: () => ({