Починить Hm-прогон ассистента по НДС, остаткам и MCP-ответам

This commit is contained in:
dctouch 2026-05-25 22:51:15 +03:00
parent 36f7b4df03
commit c577ac6b00
37 changed files with 2066 additions and 371 deletions

View File

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

View File

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

View File

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

View File

@ -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
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -170,6 +170,7 @@ function isGarbageSemanticAnchorCandidate(value) {
}
if (/^(?:по\s+)?(?:этим|этими)\s+данн\p{L}*$/iu.test(text) ||
/^(?:и\s+)?кто\s+(?:главн\p{L}*|основн\p{L}*|крупн\p{L}*)\s+(?:клиент|покупател|поставщик|контрагент)(?:\s+в)?$/iu.test(text) ||
/^(?:и\s+)?(?:главн\p{L}*|основн\p{L}*|крупн\p{L}*)\s+(?:клиент|покупател|поставщик|контрагент)(?:\s+в)?$/iu.test(text) ||
/^(?:или\s+)?(?:обычн\p{L}*\s+)?(?:клиент|поставщик|покупател\p{L}*|заказчик|контрагент)(?:\s+или\s+(?:клиент|поставщик|покупател\p{L}*|заказчик|контрагент))?$/iu.test(text) ||
/^(?:что|чего)\s+(?:подтвержден\p{L}*|не\s+хватает)/iu.test(text) ||
/^(?:можно\s+ли|если\s+нет|дай\s+proxy|дай\s+прокси)/iu.test(text)) {
@ -1823,6 +1824,8 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
? null
: explicitCurrentCounterpartyOverridesFollowupEntity
? null
: businessOverviewSuppressesFollowupCounterparty
? rawMetadataScopeHint
: rawMetadataScopeHint ??
followupSeed.metadataScopeHint ??
followupSeed.discoveryEntity ??
@ -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 ??

View File

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

View File

@ -182,7 +182,7 @@ function createAssistantTransitionPolicy(deps) {
}
return `${match[3]}-${match[2]}-${match[1]}`;
}
function computeMonthWindowFromIso(isoDate) {
function computeMonthWindowFromIso(isoDate, bridgeFacts = {}) {
const match = String(isoDate ?? "").match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (!match) {
return null;
@ -199,10 +199,15 @@ function createAssistantTransitionPolicy(deps) {
purchase_date: isoDate,
period_from: `${year}-${mm}-01`,
period_to: `${year}-${mm}-${dd}`,
as_of_date: `${year}-${mm}-${dd}`
as_of_date: `${year}-${mm}-${dd}`,
purchase_date_bridge_selected: isoDate,
...bridgeFacts
};
}
function extractEarliestDmyDateFromEntityRefs(entityRefs) {
function uniqueSortedIsoDates(dates) {
return Array.from(new Set(dates.filter(Boolean))).sort();
}
function extractDmyDatesFromEntityRefs(entityRefs) {
const dates = [];
for (const entityRef of Array.isArray(entityRefs) ? entityRefs : []) {
const value = deps.toNonEmptyString(entityRef?.value);
@ -220,21 +225,41 @@ function createAssistantTransitionPolicy(deps) {
}
}
}
if (dates.length === 0) {
return null;
return uniqueSortedIsoDates(dates);
}
return dates.sort()[0] ?? null;
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;
}
function extractPurchaseDateBridgeWindow(previousAddressItem, addressNavigationState) {
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;
}
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;
@ -244,8 +269,36 @@ function createAssistantTransitionPolicy(deps) {
: 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;
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) {
if (!debug || typeof debug !== "object") {
@ -460,10 +513,12 @@ function createAssistantTransitionPolicy(deps) {
}
return null;
}
function hasInventoryPurchaseDateVatBridgeSignal(userMessage, alternateMessage, sourceIntentHint, hasInventoryItemFocusHint) {
function hasInventoryPurchaseDateVatBridgeSignal(userMessage, alternateMessage, sourceIntentHint, hasInventoryItemFocusHint, sourceHasPurchaseDateBridgeHint = false) {
const vatPurchaseBridgeContinuation = sourceIntentHint === "vat_liability_confirmed_for_tax_period" && sourceHasPurchaseDateBridgeHint;
if (sourceIntentHint !== "inventory_purchase_provenance_for_item" &&
!hasInventoryItemFocusHint &&
!deps.isInventorySelectedObjectIntent(sourceIntentHint)) {
!deps.isInventorySelectedObjectIntent(sourceIntentHint) &&
!vatPurchaseBridgeContinuation) {
return false;
}
const samples = [userMessage, alternateMessage]
@ -472,8 +527,11 @@ function createAssistantTransitionPolicy(deps) {
if (samples.length === 0) {
return false;
}
return samples.some((sample) => /(?:ндс|vat)/iu.test(sample) &&
/(?:на\s+дат[ауеы]\s+покупк|на\s+дат[ауеы]\s+закупк|по\s+дат[еу]\s+покупк|по\s+дат[еу]\s+закупк|дата\s+покупк|дата\s+закупк|purchase\s+date)/iu.test(sample));
return samples.some((sample) => ((/(?:ндс|vat)/iu.test(sample) &&
/(?:на\s+дат[ауеы]\s+покупк|на\s+дат[ауеы]\s+закупк|по\s+дат[еу]\s+покупк|по\s+дат[еу]\s+закупк|дата\s+покупк|дата\s+закупк|purchase\s+date)/iu.test(sample)) ||
((vatPurchaseBridgeContinuation || sourceIntentHint === "vat_liability_confirmed_for_tax_period") &&
/(?:перв|последн|earliest|oldest|latest|last)/iu.test(sample) &&
/(?:покупк|закупк|purchase)/iu.test(sample))));
}
function hasInventoryRootRestatementLikeSignal(userMessage, sourceIntentHint, hasInventoryRootFrame) {
if (!hasInventoryRootFrame) {
@ -701,12 +759,17 @@ function createAssistantTransitionPolicy(deps) {
const hasBusinessOverviewCarryoverSourceHint = sourceDiscoveryPilotScopeHint === "business_overview_route_template_v1";
const navigationSessionState = earlyNavigationSessionState;
const navigationFocusObjectHint = navigationSessionState.focusObject;
const sourceFiltersHint = (0, assistantContinuityPolicy_1.readAddressDebugFilters)(carryoverSourceDebug);
const sourceHasPurchaseDateBridgeHint = Boolean(deps.toNonEmptyString(sourceFiltersHint?.purchase_date_bridge_selected));
const hasNavigationInventoryItemFocusHint = Boolean(deps.toNonEmptyString(navigationFocusObjectHint?.label) &&
deps.toNonEmptyString(navigationFocusObjectHint?.objectType) === "item" &&
(sourceIntentHint === "inventory_on_hand_as_of_date" ||
sourceIntentHint === "vat_liability_confirmed_for_tax_period" ||
sourceIntentHint === "vat_payable_forecast" ||
sourceIntentHint === "vat_payable_confirmed_as_of_date" ||
sourceIntentHint === "inventory_supplier_stock_overlap_as_of_date" ||
deps.isInventorySelectedObjectIntent(sourceIntentHint)));
const inventoryPurchaseDateVatBridge = hasInventoryPurchaseDateVatBridgeSignal(userMessage, alternateMessage, sourceIntentHint, hasNavigationInventoryItemFocusHint);
const inventoryPurchaseDateVatBridge = hasInventoryPurchaseDateVatBridgeSignal(userMessage, alternateMessage, sourceIntentHint, hasNavigationInventoryItemFocusHint, sourceHasPurchaseDateBridgeHint);
const inventoryMarginRankingFollowup = hasInventoryMarginRankingFollowupSignal(userMessage, alternateMessage, sourceIntentHint);
let inventoryShortFollowupPrimary = (deps.isInventorySelectedObjectIntent(sourceIntentHint) || hasNavigationInventoryItemFocusHint) &&
deps.hasShortInventoryObjectFollowupSignal(userMessage);
@ -1092,10 +1155,16 @@ function createAssistantTransitionPolicy(deps) {
: findRecentInventoryPurchaseProvenanceItem(items, deps.toNonEmptyString(navigationFocusObjectLabel) ??
(0, assistantContinuityPolicy_1.readAddressDebugItem)(carryoverSourceDebug, deps.toNonEmptyString) ??
deps.toNonEmptyString(previousFilters.item)) ?? previousAddressItem;
const purchaseBridgeWindow = extractPurchaseDateBridgeWindow(purchaseBridgeItem, addressNavigationState);
const purchaseBridgeWindow = extractPurchaseDateBridgeWindow(purchaseBridgeItem, addressNavigationState, userMessage, alternateMessage);
if (purchaseBridgeWindow) {
previousFilters.period_from = purchaseBridgeWindow.period_from;
previousFilters.period_to = purchaseBridgeWindow.period_to;
previousFilters.as_of_date = purchaseBridgeWindow.as_of_date;
previousFilters.purchase_date_bridge_selected = purchaseBridgeWindow.purchase_date_bridge_selected;
previousFilters.purchase_date_bridge_first = purchaseBridgeWindow.purchase_date_bridge_first;
previousFilters.purchase_date_bridge_last = purchaseBridgeWindow.purchase_date_bridge_last;
previousFilters.purchase_date_bridge_basis = purchaseBridgeWindow.purchase_date_bridge_basis;
previousFilters.purchase_date_bridge_has_multiple = purchaseBridgeWindow.purchase_date_bridge_has_multiple;
}
}
previousFilters = (0, assistantContinuityPolicy_1.applyTemporalCarryoverFilters)(previousFilters, navigationDateScope, continuityTemporalScope, sourceIntentHint, deps.toNonEmptyString);

View File

@ -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
"AUTOGEN_QUESTION_GENERATION_DISABLED",
"Автогенерация вопросов отключена: используйте сохранение живых пользовательских сессий.",
410
);
}
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
);
const generationId = generateAutogenId();
let savedCaseSetFile: string | null = null;
if (persistCaseSet) {
if (!fs.existsSync(EVAL_CASES_DIR)) {
fs.mkdirSync(EVAL_CASES_DIR, { recursive: true });
}
const fileName = buildAutogenCaseSetFileName(mode, generationId);
const filePath = path.resolve(EVAL_CASES_DIR, fileName);
const payload = buildAutogenCaseSetPayload({
generationId,
mode,
domain,
questions
});
fs.writeFileSync(filePath, JSON.stringify(payload, null, 2), "utf-8");
savedCaseSetFile = fileName;
}
const record: AutoGenHistoryRecord = {
generation_id: generationId,
created_at: new Date().toISOString(),
mode,
title: null,
count: questions.length,
domain,
questions,
generated_by: generatedBy,
saved_case_set_file: savedCaseSetFile,
context: context
? {
llm_provider: toStringSafe(context.llm_provider),
model: toStringSafe(context.model),
assistant_prompt_version: toStringSafe(context.assistant_prompt_version),
decomposition_prompt_version: toStringSafe(context.decomposition_prompt_version),
prompt_fingerprint: toStringSafe(context.prompt_fingerprint)
? repairAutogenMojibake(String(context.prompt_fingerprint))
: null,
autogen_personality_id: toStringSafe(context.autogen_personality_id),
autogen_personality_prompt: toStringSafe(context.autogen_personality_prompt)
? repairAutogenMojibake(String(context.autogen_personality_prompt))
: null,
source_session_id: null,
saved_session_file: null,
saved_case_set_kind: "single_turn_list"
}
: null
};
const history = readAutoGenHistory();
history.unshift(record);
writeAutoGenHistory(history.slice(0, 500));
ok(res, {
ok: true,
generation: record
});
} catch (error) {
next(error);
}

View File

@ -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
});

View File

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

View File

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

View File

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

View File

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

View File

@ -11,6 +11,13 @@ export interface ComposeFactualReplyOptions<TVatDirectSourceProbe = unknown> {
asOfDate?: string;
requestedResultMode?: AddressResultMode;
vatDirectSourceProbe?: TVatDirectSourceProbe | null;
purchaseDateBridge?: {
selectedPurchaseDate?: string | null;
firstPurchaseDate?: string | null;
lastPurchaseDate?: string | null;
basis?: string | null;
hasMultiplePurchaseDates?: boolean;
} | null;
emphasizeNumbers?: boolean;
useRubCurrency?: boolean;
}

View File

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

View File

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

View File

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

View File

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

View File

@ -225,6 +225,9 @@ function isGarbageSemanticAnchorCandidate(value: string | null): boolean {
/^(?:и\s+)?кто\s+(?:главн\p{L}*|основн\p{L}*|крупн\p{L}*)\s+(?:клиент|покупател|поставщик|контрагент)(?:\s+в)?$/iu.test(
text
) ||
/^(?:и\s+)?(?:главн\p{L}*|основн\p{L}*|крупн\p{L}*)\s+(?:клиент|покупател|поставщик|контрагент)(?:\s+в)?$/iu.test(
text
) ||
/^(?:или\s+)?(?:обычн\p{L}*\s+)?(?:клиент|поставщик|покупател\p{L}*|заказчик|контрагент)(?:\s+или\s+(?:клиент|поставщик|покупател\p{L}*|заказчик|контрагент))?$/iu.test(
text
) ||
@ -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

View File

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

View File

@ -301,7 +301,7 @@ export function createAssistantTransitionPolicy(deps) {
return `${match[3]}-${match[2]}-${match[1]}`;
}
function computeMonthWindowFromIso(isoDate) {
function computeMonthWindowFromIso(isoDate, bridgeFacts = {}) {
const match = String(isoDate ?? "").match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (!match) {
return null;
@ -318,11 +318,17 @@ export function createAssistantTransitionPolicy(deps) {
purchase_date: isoDate,
period_from: `${year}-${mm}-01`,
period_to: `${year}-${mm}-${dd}`,
as_of_date: `${year}-${mm}-${dd}`
as_of_date: `${year}-${mm}-${dd}`,
purchase_date_bridge_selected: isoDate,
...bridgeFacts
};
}
function extractEarliestDmyDateFromEntityRefs(entityRefs) {
function uniqueSortedIsoDates(dates) {
return Array.from(new Set(dates.filter(Boolean))).sort();
}
function extractDmyDatesFromEntityRefs(entityRefs) {
const dates = [];
for (const entityRef of Array.isArray(entityRefs) ? entityRefs : []) {
const value = deps.toNonEmptyString(entityRef?.value);
@ -340,23 +346,48 @@ 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;
}
if (!firstIsoDate || !lastIsoDate) {
const navigationSessionState = resolveNavigationSessionContextState(
addressNavigationState,
deps.toNonEmptyString,
@ -372,8 +403,39 @@ export function createAssistantTransitionPolicy(deps) {
: 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;
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) &&
((/(?:ндс|vat)/iu.test(sample) &&
/(?:на\s+дат[ауеы]\s+покупк|на\s+дат[ауеы]\s+закупк|по\s+дат[еу]\s+покупк|по\s+дат[еу]\s+закупк|дата\s+покупк|дата\s+закупк|purchase\s+date)/iu.test(
sample
)
)) ||
((vatPurchaseBridgeContinuation || sourceIntentHint === "vat_liability_confirmed_for_tax_period") &&
/(?:перв|последн|earliest|oldest|latest|last)/iu.test(sample) &&
/(?:покупк|закупк|purchase)/iu.test(sample)))
);
}
@ -932,10 +1006,17 @@ export function createAssistantTransitionPolicy(deps) {
sourceDiscoveryPilotScopeHint === "business_overview_route_template_v1";
const navigationSessionState = earlyNavigationSessionState;
const navigationFocusObjectHint = navigationSessionState.focusObject;
const sourceFiltersHint = readAddressDebugFilters(carryoverSourceDebug);
const sourceHasPurchaseDateBridgeHint = Boolean(
deps.toNonEmptyString(sourceFiltersHint?.purchase_date_bridge_selected)
);
const hasNavigationInventoryItemFocusHint = Boolean(
deps.toNonEmptyString(navigationFocusObjectHint?.label) &&
deps.toNonEmptyString(navigationFocusObjectHint?.objectType) === "item" &&
(sourceIntentHint === "inventory_on_hand_as_of_date" ||
sourceIntentHint === "vat_liability_confirmed_for_tax_period" ||
sourceIntentHint === "vat_payable_forecast" ||
sourceIntentHint === "vat_payable_confirmed_as_of_date" ||
sourceIntentHint === "inventory_supplier_stock_overlap_as_of_date" ||
deps.isInventorySelectedObjectIntent(sourceIntentHint))
);
@ -943,7 +1024,8 @@ export function createAssistantTransitionPolicy(deps) {
userMessage,
alternateMessage,
sourceIntentHint,
hasNavigationInventoryItemFocusHint
hasNavigationInventoryItemFocusHint,
sourceHasPurchaseDateBridgeHint
);
const inventoryMarginRankingFollowup = hasInventoryMarginRankingFollowupSignal(
userMessage,
@ -1457,10 +1539,21 @@ export function createAssistantTransitionPolicy(deps) {
readAddressDebugItem(carryoverSourceDebug, deps.toNonEmptyString) ??
deps.toNonEmptyString(previousFilters.item)
) ?? previousAddressItem;
const purchaseBridgeWindow = extractPurchaseDateBridgeWindow(purchaseBridgeItem, addressNavigationState);
const purchaseBridgeWindow = extractPurchaseDateBridgeWindow(
purchaseBridgeItem,
addressNavigationState,
userMessage,
alternateMessage
);
if (purchaseBridgeWindow) {
previousFilters.period_from = purchaseBridgeWindow.period_from;
previousFilters.period_to = purchaseBridgeWindow.period_to;
previousFilters.as_of_date = purchaseBridgeWindow.as_of_date;
previousFilters.purchase_date_bridge_selected = purchaseBridgeWindow.purchase_date_bridge_selected;
previousFilters.purchase_date_bridge_first = purchaseBridgeWindow.purchase_date_bridge_first;
previousFilters.purchase_date_bridge_last = purchaseBridgeWindow.purchase_date_bridge_last;
previousFilters.purchase_date_bridge_basis = purchaseBridgeWindow.purchase_date_bridge_basis;
previousFilters.purchase_date_bridge_has_multiple = purchaseBridgeWindow.purchase_date_bridge_has_multiple;
}
}
previousFilters = applyTemporalCarryoverFilters(

View File

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

View File

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

View File

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

View File

@ -2182,6 +2182,49 @@ describe("address compose stage utf8 headers", () => {
expect(reply.text).toContain("книг продаж/покупок");
});
it("discloses ambiguous inventory purchase-date VAT bridge instead of hiding the selected date", () => {
const reply = composeFactualReply(
"vat_liability_confirmed_for_tax_period",
[
{
period: "2015-02-28T23:59:59Z",
registrator: "VAT_BOOK_SALES",
account_dt: "68.02",
account_kt: "",
amount: 3500000,
analytics: []
},
{
period: "2015-02-28T23:59:59Z",
registrator: "VAT_BOOK_PURCHASES",
account_dt: "19",
account_kt: "",
amount: 868612,
analytics: []
}
],
{
userMessage: "ндс можешь прикинуть на дату покупки рабочей станции?",
periodFrom: "2015-02-01",
periodTo: "2015-02-28",
asOfDate: "2015-02-28",
organizationHint: "ООО Альтернатива Плюс",
purchaseDateBridge: {
selectedPurchaseDate: "2015-02-05",
firstPurchaseDate: "2015-02-05",
lastPurchaseDate: "2015-07-22",
basis: "first_confirmed_purchase_default",
hasMultiplePurchaseDates: true
},
useRubCurrency: true
}
);
expect(reply.text).toContain("если брать первую подтвержденную дату закупки 05.02.2015");
expect(reply.text).toContain("у позиции несколько подтвержденных дат закупки");
expect(reply.text).toContain("05.02.2015..22.07.2015");
});
it("formats VAT forecast amounts in rubles and emphasizes numbers when requested", () => {
const reply = composeFactualReply(
"vat_payable_forecast",
@ -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",

View File

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

View File

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

View File

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

View File

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