Починить 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) => { router.post("/api/autoruns/autogen/generate", async (req, res, next) => {
try { try {
const body = toRecord(req.body); throw new http_1.ApiError("AUTOGEN_QUESTION_GENERATION_DISABLED", "Автогенерация вопросов отключена: используйте сохранение живых пользовательских сессий.", 410);
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
});
} }
catch (error) { catch (error) {
next(error); next(error);

View File

@ -3195,6 +3195,17 @@ class AddressQueryService {
asOfDate: typeof filterSet.as_of_date === "string" ? filterSet.as_of_date : undefined, asOfDate: typeof filterSet.as_of_date === "string" ? filterSet.as_of_date : undefined,
requestedResultMode, requestedResultMode,
vatDirectSourceProbe: options.vatDirectSourceProbe ?? undefined, 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, emphasizeNumbers: options.emphasizeNumbers ?? undefined,
useRubCurrency: options.useRubCurrency ?? 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)))) .replace("__WHERE_CLAUSE__", buildWhereClause(filters, "Движения.Период", [inventoryCondition, itemCondition].filter((item) => Boolean(item))))
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort)); .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) { function buildWarehouseReferenceCondition(filters, fieldPaths) {
const warehouse = typeof filters.warehouse === "string" ? filters.warehouse.trim() : ""; const warehouse = typeof filters.warehouse === "string" ? filters.warehouse.trim() : "";
if (!warehouse) { if (!warehouse) {
@ -1922,7 +2001,7 @@ function buildAddressRecipePlan(recipe, filters) {
: recipe.query_template === "inventory_purchase_to_sale_chain_profile" : recipe.query_template === "inventory_purchase_to_sale_chain_profile"
? buildInventoryPurchaseToSaleDocumentQuery(filters, resolvedLimit) ? buildInventoryPurchaseToSaleDocumentQuery(filters, resolvedLimit)
: recipe.query_template === "inventory_aging_by_purchase_date_profile" : recipe.query_template === "inventory_aging_by_purchase_date_profile"
? buildInventoryMovementQuery(filters, resolvedLimit, "dt") ? buildInventoryAgingByPurchaseDateQuery(filters, resolvedLimit)
: recipe.query_template === "inventory_quality_events_profile" : recipe.query_template === "inventory_quality_events_profile"
? buildInventoryQualityEventsQuery(filters, resolvedLimit) ? buildInventoryQualityEventsQuery(filters, resolvedLimit)
: recipe.query_template === "contracts_by_counterparty_profile" : 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); return /(?:дата|дату|дате|момент)\s+(?:покуп|закуп)|(?:покуп|закуп)\S*\s+(?:дат|момент)|purchase\s+date|date\s+of\s+purchase/iu.test(text);
} }
function buildVatPurchaseDateAnchorDisclosureLine(options, periodWindowLabel) { function vatPurchaseDateBasisLabel(basis, hasMultiplePurchaseDates) {
if (!periodWindowLabel || !needsVatPurchaseDateAnchorDisclosure(options.userMessage)) { if (!hasMultiplePurchaseDates) {
return null; 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 asOfDate = normalizeIsoDateOnly(options.asOfDate);
const periodFrom = normalizeIsoDateOnly(options.periodFrom); const periodFrom = normalizeIsoDateOnly(options.periodFrom);
@ -565,9 +589,13 @@ function buildVatPurchaseDateAnchorDisclosureLine(options, periodWindowLabel) {
toTs !== null && toTs !== null &&
asOfTs >= fromTs && asOfTs >= fromTs &&
asOfTs <= toTs) { asOfTs <= toTs) {
return `- Якорь периода: дата покупки ${formatDateRu(asOfDate)} попадает в налоговый период ${periodWindowLabel}; поэтому расчет ниже взят из книг продаж/покупок за это окно.`; return [
`- Якорь периода: дата покупки ${formatDateRu(asOfDate)} попадает в налоговый период ${periodWindowLabel}; поэтому расчет ниже взят из книг продаж/покупок за это окно.`
];
} }
return `- Якорь периода: дата покупки из вопроса/контекста использована для выбора налогового периода ${periodWindowLabel}; сам расчет ниже взят из книг продаж/покупок за это окно.`; return [
`- Якорь периода: дата покупки из вопроса/контекста использована для выбора налогового периода ${periodWindowLabel}; сам расчет ниже взят из книг продаж/покупок за это окно.`
];
} }
function detectRankingLimit(userMessage, fallback = 20) { function detectRankingLimit(userMessage, fallback = 20) {
const text = normalizeQuestionText(userMessage); const text = normalizeQuestionText(userMessage);
@ -996,12 +1024,72 @@ function hasInventoryAccountPrefix(value, prefix) {
.replace(",", "."); .replace(",", ".");
return normalized === prefix || normalized.startsWith(`${prefix}.`) || normalized.startsWith(prefix); 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) { function isInventoryPurchaseMovement(row) {
return hasInventoryAccountPrefix(row.account_dt, "41.01"); return hasInventoryAccountPrefix(row.account_dt, "41.01") && !isInventoryOnHandSnapshotRow(row);
} }
function isInventorySaleMovement(row) { function isInventorySaleMovement(row) {
return hasInventoryAccountPrefix(row.account_kt, "41.01"); 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) { function looksLikeInventoryTraceDocumentToken(value) {
const normalized = String(value ?? "").trim(); const normalized = String(value ?? "").trim();
if (!normalized) { if (!normalized) {
@ -1162,7 +1250,11 @@ function formatCounterpartyItemFlowRows(rows, limit = 12) {
function buildInventoryAgingByItemAggregate(rows, asOfDate) { function buildInventoryAgingByItemAggregate(rows, asOfDate) {
const byItem = new Map(); const byItem = new Map();
const asOfTimestamp = toUtcDayTimestamp(asOfDate); const asOfTimestamp = toUtcDayTimestamp(asOfDate);
const onHandScope = buildInventoryOnHandAgingScope(rows);
for (const row of rows) { for (const row of rows) {
if (!isInventoryPurchaseMovement(row) || !inventoryPurchaseMatchesOnHandAgingScope(row, onHandScope)) {
continue;
}
const item = extractInventoryItemName(row); const item = extractInventoryItemName(row);
if (!item) { if (!item) {
continue; continue;
@ -1245,19 +1337,11 @@ function formatInventoryAgingRows(items, asOfDate, limit = 10) {
const parts = [ const parts = [
`${index + 1}. ${item.item}`, `${index + 1}. ${item.item}`,
`первая закупка: ${inventoryTraceDateLabel(item.firstPurchasePeriod)}`, `первая закупка: ${inventoryTraceDateLabel(item.firstPurchasePeriod)}`,
`последняя закупка: ${inventoryTraceDateLabel(item.lastPurchasePeriod)}`, `последняя закупка: ${inventoryTraceDateLabel(item.lastPurchasePeriod)}`
`документов: ${formatNumberWithDots(item.documentCount)}`,
`операций: ${formatNumberWithDots(item.operations)}`
]; ];
if (item.ageDays !== null) { if (item.ageDays !== null) {
parts.push(`возраст следа на ${formatDateRu(asOfDate)}: ${formatNumberWithDots(item.ageDays)} дн.`); 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) { if (item.counterparties.length > 0) {
parts.push(`поставщики: ${item.counterparties.slice(0, 3).join("; ")}`); 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 formatConfirmedMoney = (value) => (options.useRubCurrency ? formatMoneyRub(value) : formatMoney(value));
const organizationLabel = (0, assistantOrganizationMatcher_1.normalizeOrganizationScopeValue)(options.organizationHint); const organizationLabel = (0, assistantOrganizationMatcher_1.normalizeOrganizationScopeValue)(options.organizationHint);
const organizationScopeLabel = organizationLabel ? ` по организации ${organizationLabel}` : ""; 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 = [ const lines = [
`Коротко: подтвержденный НДС к уплате за налоговый период${organizationScopeLabel}${formatConfirmedMoney(vatToPay)}.`, directVatLine,
"Расчет сделан по книгам продаж и покупок.", "Расчет сделан по книгам продаж и покупок.",
"", "",
"Что вошло в расчет:", "Что вошло в расчет:",
...(organizationLabel ? [`- Организация: ${organizationLabel}.`] : []), ...(organizationLabel ? [`- Организация: ${organizationLabel}.`] : []),
`- Налоговый период расчета: ${periodWindowLabel ?? "не задан (нужен явный период)"}.`, `- Налоговый период расчета: ${periodWindowLabel ?? "не задан (нужен явный период)"}.`,
...(purchaseDateAnchorLine ? [purchaseDateAnchorLine] : []), ...purchaseDateAnchorLines,
`- НДС по книге продаж: ${formatConfirmedMoney(salesVat)}.`, `- НДС по книге продаж: ${formatConfirmedMoney(salesVat)}.`,
`- НДС по книге покупок (вычеты): ${formatConfirmedMoney(purchaseVat)}.`, `- НДС по книге покупок (вычеты): ${formatConfirmedMoney(purchaseVat)}.`,
`- Нетто НДС (книга продаж - книга покупок): ${formatConfirmedMoney(netVat)}.` `- Нетто НДС (книга продаж - книга покупок): ${formatConfirmedMoney(netVat)}.`

View File

@ -460,6 +460,23 @@ function resolveYearFromFilters(filters) {
function hasRelativeYearHint(text) { function hasRelativeYearHint(text) {
return /(?:эт(?:от|ого)(?:\s+же)?\s+год|этого\s+же\s+года|того\s+же\s+года|this\s+year|same\s+year|that\s+year)/iu.test(String(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) { function resolveRelativeMonthPeriodFromInventoryRoot(userMessage, followupContext) {
if (!followupContext || !isInventoryRootFrameIntent(followupContext.root_intent)) { if (!followupContext || !isInventoryRootFrameIntent(followupContext.root_intent)) {
return null; return null;
@ -629,6 +646,34 @@ function hasInventoryPurchaseDateVatBridgeCue(text) {
return (/(?:ндс|vat)/iu.test(normalized) && return (/(?:ндс|vat)/iu.test(normalized) &&
/(?:на\s+дат[ауеы]\s+покупк|на\s+дат[ауеы]\s+закупк|по\s+дат[еу]\s+покупк|по\s+дат[еу]\s+закупк|дата\s+покупк|дата\s+закупк|purchase\s+date)/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) { function hasInventoryMarginRankingFollowupCue(text) {
const normalized = textWithRepairedVariant(String(text ?? "")) const normalized = textWithRepairedVariant(String(text ?? ""))
.toLowerCase() .toLowerCase()
@ -745,6 +790,31 @@ function isShortDebtRoleMirrorFollowup(intent, text, followupContext) {
function mergeFollowupFilters(current, intent, userMessage, followupContext) { function mergeFollowupFilters(current, intent, userMessage, followupContext) {
const merged = { ...current }; const merged = { ...current };
const reasons = []; 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 (!followupContext) {
if ((intent === "list_open_contracts" || if ((intent === "list_open_contracts" ||
intent === "open_contracts_confirmed_as_of_date" || 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"); : "as_of_date_derived_from_period_for_open_contracts");
} }
} }
clearInventoryAgingOrganizationAliasItem(null);
return { filters: merged, reasons }; return { filters: merged, reasons };
} }
const previous = followupContext.previous_filters ?? {}; const previous = followupContext.previous_filters ?? {};
@ -773,6 +844,12 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
const previousAsOfDate = toNonEmptyString(previous.as_of_date); const previousAsOfDate = toNonEmptyString(previous.as_of_date);
const previousPeriodFrom = toNonEmptyString(previous.period_from); const previousPeriodFrom = toNonEmptyString(previous.period_from);
const previousPeriodTo = toNonEmptyString(previous.period_to); 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 relativeMonthFromInventoryRoot = resolveRelativeMonthPeriodFromInventoryRoot(userMessage, followupContext);
const relativeMonthFromFollowupYear = resolveRelativeMonthPeriodFromFollowupYear(userMessage, followupContext); const relativeMonthFromFollowupYear = resolveRelativeMonthPeriodFromFollowupYear(userMessage, followupContext);
const allTimeRequested = hasAllTimeHint(userMessage); const allTimeRequested = hasAllTimeHint(userMessage);
@ -790,6 +867,53 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
merged.organization = previousOrganization; merged.organization = previousOrganization;
reasons.push("organization_from_followup_context"); 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" && if (intent === "inventory_on_hand_as_of_date" &&
followupContext.previous_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" && 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"); reasons.push("period_derived_from_inventory_root_frame_year");
} }
if (intent === "inventory_aging_by_purchase_date") { if (intent === "inventory_aging_by_purchase_date") {
const explicitItemMention = /(?:^|[\s,.;:!?()\-\u2014])(?:товар(?:у|а|ом)?|позици(?:и|я|ю)|item|row|line)(?=$|[\s,.;:!?()\-\u2014])/iu.test(String(userMessage ?? "")); clearInventoryAgingOrganizationAliasItem(previousOrganization);
if (toNonEmptyString(merged.item) && !explicitItemMention) {
delete merged.item;
reasons.push("item_cleared_for_stock_slice_aging");
}
} }
if (!sameDateRequested && if (!sameDateRequested &&
hasFollowupSignalForConfirmed && hasFollowupSignalForConfirmed &&
@ -1317,7 +1437,12 @@ function deriveIntentWithFollowupContext(detectedIntent, userMessage, followupCo
return detectedIntent; return detectedIntent;
} }
const normalizedMessage = String(userMessage ?? ""); 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) { if (!hasFollowupSignal) {
return detectedIntent; return detectedIntent;
} }
@ -1326,7 +1451,6 @@ function deriveIntentWithFollowupContext(detectedIntent, userMessage, followupCo
if (!sourceIntent && !fallbackIntent) { if (!sourceIntent && !fallbackIntent) {
return detectedIntent; return detectedIntent;
} }
const previousFilters = followupContext.previous_filters ?? {};
const previousPeriodFrom = toNonEmptyString(previousFilters.period_from); const previousPeriodFrom = toNonEmptyString(previousFilters.period_from);
const previousPeriodTo = toNonEmptyString(previousFilters.period_to); const previousPeriodTo = toNonEmptyString(previousFilters.period_to);
const previousContract = toNonEmptyString(previousFilters.contract); const previousContract = toNonEmptyString(previousFilters.contract);
@ -1340,6 +1464,7 @@ function deriveIntentWithFollowupContext(detectedIntent, userMessage, followupCo
const samePeriodVatFollowup = isVatFollowup && const samePeriodVatFollowup = isVatFollowup &&
hasSamePeriodHint(normalizedMessage) && hasSamePeriodHint(normalizedMessage) &&
Boolean(previousPeriodFrom || previousPeriodTo); Boolean(previousPeriodFrom || previousPeriodTo);
const previousPurchaseDateBridgeSelected = toNonEmptyString(previousFilters.purchase_date_bridge_selected);
const previousIsInventoryFamily = isInventoryIntent(sourceIntent ?? undefined); const previousIsInventoryFamily = isInventoryIntent(sourceIntent ?? undefined);
const rootIsInventoryFamily = isInventoryIntent(followupContext.root_intent ?? undefined); const rootIsInventoryFamily = isInventoryIntent(followupContext.root_intent ?? undefined);
const inventoryLineageActive = previousIsInventoryFamily || const inventoryLineageActive = previousIsInventoryFamily ||
@ -1361,7 +1486,11 @@ function deriveIntentWithFollowupContext(detectedIntent, userMessage, followupCo
sourceIntent === "open_items_by_counterparty_or_contract"); sourceIntent === "open_items_by_counterparty_or_contract");
const hasExplicitInventoryItemReference = /(?:товар|номенклатур|позици|склад|остат|sku|item|product|товар|номенклатур|позици|склад|остат)/iu.test(normalizedMessage) || hasSelectedObjectInlineSnapshotMetadata(normalizedMessage); const hasExplicitInventoryItemReference = /(?:товар|номенклатур|позици|склад|остат|sku|item|product|товар|номенклатур|позици|склад|остат)/iu.test(normalizedMessage) || hasSelectedObjectInlineSnapshotMetadata(normalizedMessage);
const staleInventoryLineageCanYieldToCounterparty = previousCounterpartyLaneActive && !hasExplicitInventoryItemReference; 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" || const marginRankingLineageActive = sourceIntent === "inventory_margin_ranking_for_nomenclature" ||
fallbackIntent === "inventory_margin_ranking_for_nomenclature" || fallbackIntent === "inventory_margin_ranking_for_nomenclature" ||
followupContext.root_intent === "inventory_margin_ranking_for_nomenclature"; followupContext.root_intent === "inventory_margin_ranking_for_nomenclature";
@ -1382,6 +1511,7 @@ function deriveIntentWithFollowupContext(detectedIntent, userMessage, followupCo
if (inventoryPurchaseDateVatBridge && if (inventoryPurchaseDateVatBridge &&
(detectedIntent.intent === "unknown" || (detectedIntent.intent === "unknown" ||
detectedIntent.intent === sourceIntent || detectedIntent.intent === sourceIntent ||
detectedIntent.intent === "account_balance_snapshot" ||
detectedIntent.intent === "vat_payable_confirmed_as_of_date" || detectedIntent.intent === "vat_payable_confirmed_as_of_date" ||
detectedIntent.intent === "vat_payable_forecast")) { detectedIntent.intent === "vat_payable_forecast")) {
return { return {

View File

@ -57,6 +57,16 @@ function inventoryRequestedPartyMatches(requested, actualParties) {
function inventoryPartyListOrUnknown(parties) { function inventoryPartyListOrUnknown(parties) {
return parties.length > 0 ? parties.slice(0, 4).join("; ") : "не выделен отдельным полем"; 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) { function sumInventoryRowAmount(rows) {
return rows.reduce((sum, row) => sum + (typeof row.amount === "number" && Number.isFinite(row.amount) ? row.amount : 0), 0); 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") { if (intent === "inventory_aging_by_purchase_date") {
const asOfDate = deps.resolvePayablesAsOfDate(options); 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 summary = deps.summarizeInventoryTraceRows(purchaseRows);
const agingItems = deps.buildInventoryAgingByItemAggregate(purchaseRows, asOfDate);
const oldestPurchaseDate = agingItems[0]?.firstPurchasePeriod ?? summary.firstPeriod; const oldestPurchaseDate = agingItems[0]?.firstPurchasePeriod ?? summary.firstPeriod;
const oldestPurchaseAgeDays = agingItems[0]?.ageDays ?? null; const oldestPurchaseAgeDays = agingItems[0]?.ageDays ?? null;
const organizationLabel = agingItems.find((item) => item.organization)?.organization ?? null;
const oldestAnswerPreview = agingItems const oldestAnswerPreview = agingItems
.slice(0, 3) .slice(0, 3)
.map((item) => `${item.item} (${deps.inventoryTraceDateLabel(item.firstPurchasePeriod)})`) .map((item) => `${item.item} (${deps.inventoryTraceDateLabel(item.firstPurchasePeriod)})`)
.join("; "); .join("; ");
const directAnswerLine = agingItems.length > 0 const directAnswerLine = agingItems.length > 0
? `К самым старым закупкам в текущем подтвержденном срезе относятся позиции с самой ранней первой закупкой: ${oldestAnswerPreview}.` ? `Среди фактических положительных остатков есть давно закупавшиеся позиции: ${oldestAnswerPreview}.`
: "По доступному закупочному следу позиции со старыми закупками не материализованы."; : "В фактическом положительном остатке не найдено позиций с подтвержденным старым закупочным следом.";
const lines = [directAnswerLine]; const lines = [directAnswerLine];
(0, inventoryReplyPresentation_1.appendInventoryBulletSection)(lines, "Сводка:", [ const summaryLines = [
`Дата среза: ${deps.formatDateRu(asOfDate)}.`, `Дата среза: ${deps.formatDateRu(asOfDate)}.`,
`Самая ранняя первая закупка среди позиций: ${deps.inventoryTraceDateLabel(oldestPurchaseDate)}.`, `Самая ранняя первая закупка среди позиций: ${deps.inventoryTraceDateLabel(oldestPurchaseDate)}.`,
`Самая поздняя найденная закупка: ${deps.inventoryTraceDateLabel(summary.lastPeriod)}.`, `Самая поздняя найденная закупка: ${deps.inventoryTraceDateLabel(summary.lastPeriod)}.`,
`Позиций в выборке: ${deps.formatNumberWithDots(agingItems.length)}.`, `Позиций в выборке: ${deps.formatNumberWithDots(agingItems.length)}.`,
`Закупочных документов: ${deps.formatNumberWithDots(summary.documents.length)}.`, `Закупочных документов: ${deps.formatNumberWithDots(summary.documents.length)}.`,
`Закупочных операций: ${deps.formatNumberWithDots(purchaseRows.length)}.` `Закупочных операций: ${deps.formatNumberWithDots(purchaseRows.length)}.`
]); ];
if (organizationLabel) {
summaryLines.splice(1, 0, `Организация: ${organizationLabel}.`);
}
(0, inventoryReplyPresentation_1.appendInventoryBulletSection)(lines, "Сводка:", summaryLines);
(0, inventoryReplyPresentation_1.appendInventoryBulletSection)(lines, "Ограничения:", [ (0, inventoryReplyPresentation_1.appendInventoryBulletSection)(lines, "Ограничения:", [
"Без партионного учета этот ответ показывает возраст закупочного следа по товарной позиции, а не возраст конкретного лота." "Берем только позиции с положительным остатком на дату среза; без партионного учета это возраст закупочного следа по номенклатуре, а не доказанный возраст конкретной партии."
]); ]);
if (oldestPurchaseAgeDays !== null) { if (oldestPurchaseAgeDays !== null) {
lines.push(`- Между самой ранней первой закупкой и датой среза прошло ${deps.formatNumberWithDots(oldestPurchaseAgeDays)} дн.`); lines.push(`- Между самой ранней первой закупкой и датой среза прошло ${deps.formatNumberWithDots(oldestPurchaseAgeDays)} дн.`);
@ -411,11 +439,11 @@ function composeInventoryReply(intent, rows, options, deps) {
lines.push(`- Поставщики, встречающиеся в наблюдаемом закупочном следе: ${summary.counterparties.slice(0, 4).join("; ")}.`); lines.push(`- Поставщики, встречающиеся в наблюдаемом закупочном следе: ${summary.counterparties.slice(0, 4).join("; ")}.`);
} }
if (agingItems.length > 0) { 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 { else {
(0, inventoryReplyPresentation_1.appendInventorySection)(lines, "Позиции от самых старых закупок:", [ (0, inventoryReplyPresentation_1.appendInventorySection)(lines, "Позиции от самых старых закупок:", [
"- В доступных данных не найдено закупочных движений для выбранного среза." "- В доступных данных не найдено закупочных движений по позициям, которые есть в положительном остатке на дату среза."
]); ]);
} }
return (0, replyContracts_1.buildFactualSummaryReply)(lines, (0, replyContracts_1.buildConfirmedBalanceSemantics)(agingItems.length > 0 ? "strong" : "medium", agingItems.length > 0)); 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(); 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) { function buildComparisonScopeProofReply(input) {
if (!hasComparisonScopeProofCue(input.userMessage)) {
return null;
}
const entryPoint = toRecordObject(input.debug.assistant_mcp_discovery_entry_point_v1); const entryPoint = toRecordObject(input.debug.assistant_mcp_discovery_entry_point_v1);
const turnInput = toRecordObject(entryPoint?.turn_input); const turnInput = toRecordObject(entryPoint?.turn_input);
const turnMeaning = toRecordObject(turnInput?.turn_meaning_ref); const turnMeaning = toRecordObject(turnInput?.turn_meaning_ref);

View File

@ -62,7 +62,7 @@ function hasCompactCashflowFollowupSignal(text) {
if (!value) { if (!value) {
return false; 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) { function dateScopeToFilterWindow(dateScope) {
if (!dateScope) { if (!dateScope) {
@ -79,6 +79,35 @@ function dateScopeToFilterWindow(dateScope) {
} }
return null; 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) { function looksLikeReliableOrganizationScope(value) {
const text = compactLower(value); const text = compactLower(value);
if (!text) { if (!text) {
@ -103,12 +132,15 @@ function inferBusinessOverviewDiscoveryContextFromSessionItems(sessionItems, toN
continue; continue;
} }
const turnMeaningRef = toRecordObject(turnInput?.turn_meaning_ref); 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) { if (!filterWindow) {
continue; continue;
} }
const previousFilters = { ...filterWindow }; const previousFilters = { ...filterWindow };
const organization = toNonEmptyString(turnMeaningRef?.explicit_organization_scope); const organization = toNonEmptyString(turnMeaningRef?.explicit_organization_scope) ??
toNonEmptyString(debugFilters?.organization);
if (looksLikeReliableOrganizationScope(organization)) { if (looksLikeReliableOrganizationScope(organization)) {
previousFilters.organization = organization; previousFilters.organization = organization;
} }
@ -129,9 +161,7 @@ function mergeBusinessOverviewDateContextForCompactCashflow(input) {
return input.followupContext; return input.followupContext;
} }
const existingFilters = toRecordObject(input.followupContext?.previous_filters); const existingFilters = toRecordObject(input.followupContext?.previous_filters);
if (input.toNonEmptyString(existingFilters?.period_from) || if (hasBoundedFilterWindow(existingFilters)) {
input.toNonEmptyString(existingFilters?.period_to) ||
input.toNonEmptyString(existingFilters?.as_of_date)) {
return input.followupContext; return input.followupContext;
} }
const inferred = inferBusinessOverviewDiscoveryContextFromSessionItems(input.sessionItems, input.toNonEmptyString); const inferred = inferBusinessOverviewDiscoveryContextFromSessionItems(input.sessionItems, input.toNonEmptyString);
@ -142,12 +172,12 @@ function mergeBusinessOverviewDateContextForCompactCashflow(input) {
...inferred, ...inferred,
...(input.followupContext ?? {}), ...(input.followupContext ?? {}),
previous_filters: { previous_filters: {
...(toRecordObject(inferred.previous_filters) ?? {}), ...(toRecordObject(input.followupContext?.previous_filters) ?? {}),
...(toRecordObject(input.followupContext?.previous_filters) ?? {}) ...(toRecordObject(inferred.previous_filters) ?? {})
}, },
root_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); 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) { function isBusinessOverviewDiscoveryFollowup(followupContext, toNonEmptyString) {
if (!followupContext) { if (!followupContext) {
return false; return false;
@ -344,6 +381,9 @@ function mergeBusinessOverviewProofBundlesFromNavigationState(input) {
const entities = toRecordObject(input.predecomposeContract?.entities); const entities = toRecordObject(input.predecomposeContract?.entities);
const hasCurrentOrganizationSelection = Boolean(input.toNonEmptyString(entities?.organization)); const hasCurrentOrganizationSelection = Boolean(input.toNonEmptyString(entities?.organization));
const businessOverviewFollowup = isBusinessOverviewDiscoveryFollowup(input.followupContext, input.toNonEmptyString); const businessOverviewFollowup = isBusinessOverviewDiscoveryFollowup(input.followupContext, input.toNonEmptyString);
if (!hasComparisonScopeProofCue(input.userMessage)) {
return input.followupContext;
}
if (!businessOverviewFollowup && !hasCurrentOrganizationSelection) { if (!businessOverviewFollowup && !hasCurrentOrganizationSelection) {
return input.followupContext; return input.followupContext;
} }
@ -376,6 +416,9 @@ function mergeBusinessOverviewProofBundlesFromNavigationState(input) {
}; };
} }
function mergeBusinessOverviewProofBundlesFromSessionItems(input) { function mergeBusinessOverviewProofBundlesFromSessionItems(input) {
if (!hasComparisonScopeProofCue(input.userMessage)) {
return input.followupContext;
}
if (!isBusinessOverviewDiscoveryFollowup(input.followupContext, input.toNonEmptyString)) { if (!isBusinessOverviewDiscoveryFollowup(input.followupContext, input.toNonEmptyString)) {
return input.followupContext; return input.followupContext;
} }
@ -576,6 +619,7 @@ async function buildAssistantAddressOrchestrationRuntime(input) {
}); });
const discoveryFollowupContextWithProofBundles = mergeBusinessOverviewProofBundlesFromSessionItems({ const discoveryFollowupContextWithProofBundles = mergeBusinessOverviewProofBundlesFromSessionItems({
followupContext: discoveryFollowupContext, followupContext: discoveryFollowupContext,
userMessage: input.userMessage,
sessionItems: input.sessionItems, sessionItems: input.sessionItems,
toNonEmptyString: input.toNonEmptyString toNonEmptyString: input.toNonEmptyString
}); });
@ -583,6 +627,7 @@ async function buildAssistantAddressOrchestrationRuntime(input) {
followupContext: discoveryFollowupContextWithProofBundles, followupContext: discoveryFollowupContextWithProofBundles,
sessionAddressNavigationState: input.sessionAddressNavigationState, sessionAddressNavigationState: input.sessionAddressNavigationState,
predecomposeContract, predecomposeContract,
userMessage: input.userMessage,
toNonEmptyString: input.toNonEmptyString toNonEmptyString: input.toNonEmptyString
}); });
const dialogContinuationContract = input.buildAddressDialogContinuationContractV2(input.userMessage, addressInputMessage, carryover, addressPreDecompose); const dialogContinuationContract = input.buildAddressDialogContinuationContractV2(input.userMessage, addressInputMessage, carryover, addressPreDecompose);

View File

@ -74,6 +74,15 @@ function normalizeAddressRuntimeMetaForDeep(value) {
mcpDiscoveryRuntimeEntryPoint: source.mcpDiscoveryRuntimeEntryPoint ?? null 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) { function runAssistantDeepTurnResponseRuntime(input) {
const runPackagingRuntimeSafe = input.runPackagingRuntime ?? assistantDeepTurnPackagingRuntimeAdapter_1.runAssistantDeepTurnPackagingRuntime; const runPackagingRuntimeSafe = input.runPackagingRuntime ?? assistantDeepTurnPackagingRuntimeAdapter_1.runAssistantDeepTurnPackagingRuntime;
const runFinalizeDeepTurnSafe = input.runFinalizeDeepTurn ?? assistantDeepTurnFinalizeRuntimeAdapter_1.finalizeAssistantDeepTurn; const runFinalizeDeepTurnSafe = input.runFinalizeDeepTurn ?? assistantDeepTurnFinalizeRuntimeAdapter_1.finalizeAssistantDeepTurn;
@ -125,9 +134,9 @@ function runAssistantDeepTurnResponseRuntime(input) {
currentReplySource: "deep_analysis", currentReplySource: "deep_analysis",
addressRuntimeMeta: addressRuntimeMetaForDeep addressRuntimeMeta: addressRuntimeMetaForDeep
}); });
const assistantReply = mcpDiscoveryResponsePolicy.applied const assistantReply = stripRoutineShortAnswerPrefix(mcpDiscoveryResponsePolicy.applied
? mcpDiscoveryResponsePolicy.reply_text ? input.sanitizeReply(mcpDiscoveryResponsePolicy.reply_text, packagingRuntime.safeAssistantReply)
: packagingRuntime.safeAssistantReply; : packagingRuntime.safeAssistantReply);
const replyType = mcpDiscoveryResponsePolicy.applied ? "partial_coverage" : input.composition.reply_type; const replyType = mcpDiscoveryResponsePolicy.applied ? "partial_coverage" : input.composition.reply_type;
const debug = { const debug = {
...packagingRuntime.debug, ...packagingRuntime.debug,

View File

@ -52,6 +52,15 @@ function hasInternalMechanics(value) {
text.includes("catalog_") || text.includes("catalog_") ||
text.includes("select ")); 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) { function isMcpDiscoveryEntryPointContract(value) {
const record = toRecordObject(value); const record = toRecordObject(value);
return (record?.schema_version === "assistant_mcp_discovery_runtime_entry_point_v1" && 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"); pushReason(reasonCodes, "mcp_discovery_response_policy_candidate_applied");
const replyText = stripRoutineShortAnswerPrefix(String(candidate.reply_text));
return { return {
schema_version: exports.ASSISTANT_MCP_DISCOVERY_RESPONSE_POLICY_SCHEMA_VERSION, schema_version: exports.ASSISTANT_MCP_DISCOVERY_RESPONSE_POLICY_SCHEMA_VERSION,
policy_owner: "assistantMcpDiscoveryResponsePolicy", policy_owner: "assistantMcpDiscoveryResponsePolicy",
decision: "apply_candidate", decision: "apply_candidate",
applied: true, applied: true,
reply_text: String(candidate.reply_text), reply_text: replyText || String(candidate.reply_text),
reply_source: "mcp_discovery_response_candidate_guarded", reply_source: "mcp_discovery_response_candidate_guarded",
candidate, candidate,
reason_codes: reasonCodes reason_codes: reasonCodes

View File

@ -170,6 +170,7 @@ function isGarbageSemanticAnchorCandidate(value) {
} }
if (/^(?:по\s+)?(?:этим|этими)\s+данн\p{L}*$/iu.test(text) || if (/^(?:по\s+)?(?:этим|этими)\s+данн\p{L}*$/iu.test(text) ||
/^(?:и\s+)?кто\s+(?:главн\p{L}*|основн\p{L}*|крупн\p{L}*)\s+(?:клиент|покупател|поставщик|контрагент)(?:\s+в)?$/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+)?(?:клиент|поставщик|покупател\p{L}*|заказчик|контрагент)(?:\s+или\s+(?:клиент|поставщик|покупател\p{L}*|заказчик|контрагент))?$/iu.test(text) ||
/^(?:что|чего)\s+(?:подтвержден\p{L}*|не\s+хватает)/iu.test(text) || /^(?:что|чего)\s+(?:подтвержден\p{L}*|не\s+хватает)/iu.test(text) ||
/^(?:можно\s+ли|если\s+нет|дай\s+proxy|дай\s+прокси)/iu.test(text)) { /^(?:можно\s+ли|если\s+нет|дай\s+proxy|дай\s+прокси)/iu.test(text)) {
@ -1823,11 +1824,13 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
? null ? null
: explicitCurrentCounterpartyOverridesFollowupEntity : explicitCurrentCounterpartyOverridesFollowupEntity
? null ? null
: rawMetadataScopeHint ?? : businessOverviewSuppressesFollowupCounterparty
followupSeed.metadataScopeHint ?? ? rawMetadataScopeHint
followupSeed.discoveryEntity ?? : rawMetadataScopeHint ??
followupSeed.metadataSelectedEntitySet ?? followupSeed.metadataScopeHint ??
null; followupSeed.discoveryEntity ??
followupSeed.metadataSelectedEntitySet ??
null;
const metadataScopedLaneWithoutSubject = Boolean((metadataGroundedMovementLaneApplicable || const metadataScopedLaneWithoutSubject = Boolean((metadataGroundedMovementLaneApplicable ||
metadataGroundedDocumentLaneApplicable || metadataGroundedDocumentLaneApplicable ||
explicitVatMovementEvidenceSignal) && explicitVatMovementEvidenceSignal) &&
@ -2013,7 +2016,7 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
/^\d{4}$/.test(rawDateScope) && /^\d{4}$/.test(rawDateScope) &&
normalizedPredecomposeDateScope && normalizedPredecomposeDateScope &&
normalizedPredecomposeDateScope.startsWith(`${rawDateScope}-`)); normalizedPredecomposeDateScope.startsWith(`${rawDateScope}-`));
const followupAllTimeScopeApplied = normalizedFollowupDateScope === "all_time_scope"; const followupAllTimeScopeApplied = normalizedFollowupDateScope === "all_time_scope" && !currentTurnCarriesExplicitPeriod;
const explicitDateScope = rawAllTimeScopeSignal || followupAllTimeScopeApplied const explicitDateScope = rawAllTimeScopeSignal || followupAllTimeScopeApplied
? null ? null
: normalizedAssistantTurnMeaningDateScope ?? : normalizedAssistantTurnMeaningDateScope ??

View File

@ -2025,7 +2025,11 @@ function repairAddressMojibake(value) {
} }
function sanitizeOutgoingAssistantText(value, fallback = "Не смог сформировать читаемый ответ. Уточните запрос.") { function sanitizeOutgoingAssistantText(value, fallback = "Не смог сформировать читаемый ответ. Уточните запрос.") {
const repaired = repairAddressMojibake(String(value ?? "")); 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) { if (sanitized) {
return sanitized; return sanitized;
} }

View File

@ -182,7 +182,7 @@ function createAssistantTransitionPolicy(deps) {
} }
return `${match[3]}-${match[2]}-${match[1]}`; 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})$/); const match = String(isoDate ?? "").match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (!match) { if (!match) {
return null; return null;
@ -199,10 +199,15 @@ function createAssistantTransitionPolicy(deps) {
purchase_date: isoDate, purchase_date: isoDate,
period_from: `${year}-${mm}-01`, period_from: `${year}-${mm}-01`,
period_to: `${year}-${mm}-${dd}`, 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 = []; const dates = [];
for (const entityRef of Array.isArray(entityRefs) ? entityRefs : []) { for (const entityRef of Array.isArray(entityRefs) ? entityRefs : []) {
const value = deps.toNonEmptyString(entityRef?.value); const value = deps.toNonEmptyString(entityRef?.value);
@ -220,32 +225,80 @@ function createAssistantTransitionPolicy(deps) {
} }
} }
} }
if (dates.length === 0) { return uniqueSortedIsoDates(dates);
}
function detectPurchaseDateBridgeBasis(userMessage, alternateMessage) {
const text = [userMessage, alternateMessage]
.map((item) => deps.compactWhitespace(deps.repairAddressMojibake(String(item ?? "")).toLowerCase()).replace(/ё/g, "е"))
.filter(Boolean)
.join(" ");
if (/(?:последн|latest|last|сам[а-я\s-]*поздн)/iu.test(text)) {
return "last_confirmed_purchase";
}
if (/(?:перв|earliest|oldest|ранн|сам[а-я\s-]*стар)/iu.test(text)) {
return "first_confirmed_purchase";
}
return "first_confirmed_purchase_default";
}
function extractPurchaseDateBridgeFacts(previousAddressItem, addressNavigationState, userMessage, alternateMessage) {
const previousFilters = (0, assistantContinuityPolicy_1.readAddressDebugFilters)(previousAddressItem?.debug ?? null);
let firstIsoDate = deps.toNonEmptyString(previousFilters?.purchase_date_bridge_first);
let lastIsoDate = deps.toNonEmptyString(previousFilters?.purchase_date_bridge_last);
const selectedIsoDateFromFilters = deps.toNonEmptyString(previousFilters?.purchase_date_bridge_selected);
if (!firstIsoDate && selectedIsoDateFromFilters) {
firstIsoDate = selectedIsoDateFromFilters;
}
if (!lastIsoDate && selectedIsoDateFromFilters) {
lastIsoDate = selectedIsoDateFromFilters;
}
const replyText = deps.toNonEmptyString(previousAddressItem?.text);
if (replyText && (!firstIsoDate || !lastIsoDate)) {
const repairedText = deps.repairAddressMojibake(replyText);
const explicitFirstDateMatch = repairedText.match(/первая\s+(?:найденная|подтвержденная)\s+дата\s+закупки:\s*(\d{2}\.\d{2}\.\d{4})/iu);
const explicitLastDateMatch = repairedText.match(/последняя\s+(?:найденная|подтвержденная)\s+дата\s+закупки:\s*(\d{2}\.\d{2}\.\d{4})/iu);
firstIsoDate = explicitFirstDateMatch ? parseDmyDateToIso(explicitFirstDateMatch[1]) : null;
lastIsoDate = explicitLastDateMatch ? parseDmyDateToIso(explicitLastDateMatch[1]) : null;
}
if (!firstIsoDate || !lastIsoDate) {
const navigationSessionState = (0, assistantContinuityPolicy_1.resolveNavigationSessionContextState)(addressNavigationState, deps.toNonEmptyString, deps.normalizeOrganizationScopeValue);
const focusObject = navigationSessionState.focusObject;
const preferredResultSetId = deps.toNonEmptyString(focusObject?.provenanceResultSetId) ?? navigationSessionState.activeResultSetId;
const resultSets = Array.isArray(addressNavigationState?.result_sets) ? addressNavigationState.result_sets : [];
const preferredResultSet = (preferredResultSetId
? resultSets.find((item) => deps.toNonEmptyString(item?.result_set_id) === preferredResultSetId) ?? null
: null) ??
resultSets.find((item) => deps.toNonEmptyString(item?.intent) === "inventory_purchase_provenance_for_item") ??
null;
const datesFromResultSet = extractDmyDatesFromEntityRefs(preferredResultSet?.entity_refs);
firstIsoDate = firstIsoDate ?? datesFromResultSet[0] ?? null;
lastIsoDate = lastIsoDate ?? datesFromResultSet[datesFromResultSet.length - 1] ?? null;
}
if (!firstIsoDate && !lastIsoDate) {
return null; return null;
} }
return dates.sort()[0] ?? null; const normalizedFirst = firstIsoDate ?? lastIsoDate;
const normalizedLast = lastIsoDate ?? firstIsoDate;
const basis = detectPurchaseDateBridgeBasis(userMessage, alternateMessage);
const selectedIsoDate = basis === "last_confirmed_purchase" ? normalizedLast : normalizedFirst;
return {
selectedIsoDate,
firstIsoDate: normalizedFirst,
lastIsoDate: normalizedLast,
basis,
hasMultiplePurchaseDates: Boolean(normalizedFirst && normalizedLast && normalizedFirst !== normalizedLast)
};
} }
function extractPurchaseDateBridgeWindow(previousAddressItem, addressNavigationState) { function extractPurchaseDateBridgeWindow(previousAddressItem, addressNavigationState, userMessage, alternateMessage) {
const replyText = deps.toNonEmptyString(previousAddressItem?.text); const facts = extractPurchaseDateBridgeFacts(previousAddressItem, addressNavigationState, userMessage, alternateMessage);
if (replyText) { if (!facts?.selectedIsoDate) {
const repairedText = deps.repairAddressMojibake(replyText); return null;
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 navigationSessionState = (0, assistantContinuityPolicy_1.resolveNavigationSessionContextState)(addressNavigationState, deps.toNonEmptyString, deps.normalizeOrganizationScopeValue); return computeMonthWindowFromIso(facts.selectedIsoDate, {
const focusObject = navigationSessionState.focusObject; purchase_date_bridge_first: facts.firstIsoDate,
const preferredResultSetId = deps.toNonEmptyString(focusObject?.provenanceResultSetId) ?? navigationSessionState.activeResultSetId; purchase_date_bridge_last: facts.lastIsoDate,
const resultSets = Array.isArray(addressNavigationState?.result_sets) ? addressNavigationState.result_sets : []; purchase_date_bridge_basis: facts.basis,
const preferredResultSet = (preferredResultSetId purchase_date_bridge_has_multiple: facts.hasMultiplePurchaseDates
? resultSets.find((item) => deps.toNonEmptyString(item?.result_set_id) === preferredResultSetId) ?? null });
: null) ??
resultSets.find((item) => deps.toNonEmptyString(item?.intent) === "inventory_purchase_provenance_for_item") ??
null;
const earliestIsoDate = extractEarliestDmyDateFromEntityRefs(preferredResultSet?.entity_refs);
return earliestIsoDate ? computeMonthWindowFromIso(earliestIsoDate) : null;
} }
function isUsableFollowupSourceDebug(debug) { function isUsableFollowupSourceDebug(debug) {
if (!debug || typeof debug !== "object") { if (!debug || typeof debug !== "object") {
@ -460,10 +513,12 @@ function createAssistantTransitionPolicy(deps) {
} }
return null; 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" && if (sourceIntentHint !== "inventory_purchase_provenance_for_item" &&
!hasInventoryItemFocusHint && !hasInventoryItemFocusHint &&
!deps.isInventorySelectedObjectIntent(sourceIntentHint)) { !deps.isInventorySelectedObjectIntent(sourceIntentHint) &&
!vatPurchaseBridgeContinuation) {
return false; return false;
} }
const samples = [userMessage, alternateMessage] const samples = [userMessage, alternateMessage]
@ -472,8 +527,11 @@ function createAssistantTransitionPolicy(deps) {
if (samples.length === 0) { if (samples.length === 0) {
return false; return false;
} }
return samples.some((sample) => /(?:ндс|vat)/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)); /(?:на\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) { function hasInventoryRootRestatementLikeSignal(userMessage, sourceIntentHint, hasInventoryRootFrame) {
if (!hasInventoryRootFrame) { if (!hasInventoryRootFrame) {
@ -701,12 +759,17 @@ function createAssistantTransitionPolicy(deps) {
const hasBusinessOverviewCarryoverSourceHint = sourceDiscoveryPilotScopeHint === "business_overview_route_template_v1"; const hasBusinessOverviewCarryoverSourceHint = sourceDiscoveryPilotScopeHint === "business_overview_route_template_v1";
const navigationSessionState = earlyNavigationSessionState; const navigationSessionState = earlyNavigationSessionState;
const navigationFocusObjectHint = navigationSessionState.focusObject; 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) && const hasNavigationInventoryItemFocusHint = Boolean(deps.toNonEmptyString(navigationFocusObjectHint?.label) &&
deps.toNonEmptyString(navigationFocusObjectHint?.objectType) === "item" && deps.toNonEmptyString(navigationFocusObjectHint?.objectType) === "item" &&
(sourceIntentHint === "inventory_on_hand_as_of_date" || (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" || sourceIntentHint === "inventory_supplier_stock_overlap_as_of_date" ||
deps.isInventorySelectedObjectIntent(sourceIntentHint))); deps.isInventorySelectedObjectIntent(sourceIntentHint)));
const inventoryPurchaseDateVatBridge = hasInventoryPurchaseDateVatBridgeSignal(userMessage, alternateMessage, sourceIntentHint, hasNavigationInventoryItemFocusHint); const inventoryPurchaseDateVatBridge = hasInventoryPurchaseDateVatBridgeSignal(userMessage, alternateMessage, sourceIntentHint, hasNavigationInventoryItemFocusHint, sourceHasPurchaseDateBridgeHint);
const inventoryMarginRankingFollowup = hasInventoryMarginRankingFollowupSignal(userMessage, alternateMessage, sourceIntentHint); const inventoryMarginRankingFollowup = hasInventoryMarginRankingFollowupSignal(userMessage, alternateMessage, sourceIntentHint);
let inventoryShortFollowupPrimary = (deps.isInventorySelectedObjectIntent(sourceIntentHint) || hasNavigationInventoryItemFocusHint) && let inventoryShortFollowupPrimary = (deps.isInventorySelectedObjectIntent(sourceIntentHint) || hasNavigationInventoryItemFocusHint) &&
deps.hasShortInventoryObjectFollowupSignal(userMessage); deps.hasShortInventoryObjectFollowupSignal(userMessage);
@ -1092,10 +1155,16 @@ function createAssistantTransitionPolicy(deps) {
: findRecentInventoryPurchaseProvenanceItem(items, deps.toNonEmptyString(navigationFocusObjectLabel) ?? : findRecentInventoryPurchaseProvenanceItem(items, deps.toNonEmptyString(navigationFocusObjectLabel) ??
(0, assistantContinuityPolicy_1.readAddressDebugItem)(carryoverSourceDebug, deps.toNonEmptyString) ?? (0, assistantContinuityPolicy_1.readAddressDebugItem)(carryoverSourceDebug, deps.toNonEmptyString) ??
deps.toNonEmptyString(previousFilters.item)) ?? previousAddressItem; deps.toNonEmptyString(previousFilters.item)) ?? previousAddressItem;
const purchaseBridgeWindow = extractPurchaseDateBridgeWindow(purchaseBridgeItem, addressNavigationState); const purchaseBridgeWindow = extractPurchaseDateBridgeWindow(purchaseBridgeItem, addressNavigationState, userMessage, alternateMessage);
if (purchaseBridgeWindow) { if (purchaseBridgeWindow) {
previousFilters.period_from = purchaseBridgeWindow.period_from; previousFilters.period_from = purchaseBridgeWindow.period_from;
previousFilters.period_to = purchaseBridgeWindow.period_to; 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); 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) => { router.post("/api/autoruns/autogen/generate", async (req, res, next) => {
try { try {
const body = toRecord(req.body); throw new ApiError(
if (!body) { "AUTOGEN_QUESTION_GENERATION_DISABLED",
throw new ApiError("INVALID_AUTOGEN_PAYLOAD", "JSON body is required", 400); "Автогенерация вопросов отключена: используйте сохранение живых пользовательских сессий.",
} 410
const mode = parseAutoGenMode(body.mode);
const count = parseAutogenCount(body.count);
const domain = parseAutogenDomain(body.domain);
const persistCaseSet = toBooleanSafe(body.persist_to_eval_cases) ?? true;
const generatedBy = parseAnnotationAuthor(body.generated_by);
const context = toRecord(body.context);
const llmConfig = parseAutogenLlmRuntimeConfig(body, context);
const personalityPrompt = toStringSafe(context?.autogen_personality_prompt);
if (mode === "saved_user_sessions") {
throw new ApiError(
"AUTOGEN_MODE_NOT_SUPPORTED",
"Use `/api/autoruns/autogen/save-assistant-session` to save user sessions.",
400
);
}
let questions: string[] = [];
if (mode === "qwen_seed") {
if (!llmConfig) {
throw new ApiError(
"AUTOGEN_LLM_CONFIG_REQUIRED",
"Для режима qwen_seed нужен активный LLM-контур (provider/model/baseUrl) из настроек подключения.",
400
);
}
questions = await generateQwenSeedQuestionsLive({
count,
domain,
personalityPrompt,
llmConfig,
client: openaiClient
});
} else {
questions = generateCodexCreativeQuestions(count, domain);
}
questions = Array.from(new Set(questions.map((item) => sanitizeGeneratedQuestion(item)).filter((item) => item.length > 0))).slice(
0,
count
); );
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) { } catch (error) {
next(error); next(error);
} }

View File

@ -3944,6 +3944,18 @@ export class AddressQueryService {
asOfDate: typeof filterSet.as_of_date === "string" ? filterSet.as_of_date : undefined, asOfDate: typeof filterSet.as_of_date === "string" ? filterSet.as_of_date : undefined,
requestedResultMode, requestedResultMode,
vatDirectSourceProbe: options.vatDirectSourceProbe ?? undefined, 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, emphasizeNumbers: options.emphasizeNumbers ?? undefined,
useRubCurrency: options.useRubCurrency ?? undefined useRubCurrency: options.useRubCurrency ?? undefined
}); });

View File

@ -1520,6 +1520,91 @@ function buildInventoryMovementQuery(
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort)); .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 { function buildWarehouseReferenceCondition(filters: AddressFilterSet, fieldPaths: string[]): string | null {
const warehouse = typeof filters.warehouse === "string" ? filters.warehouse.trim() : ""; const warehouse = typeof filters.warehouse === "string" ? filters.warehouse.trim() : "";
if (!warehouse) { if (!warehouse) {
@ -2199,7 +2284,7 @@ export function buildAddressRecipePlan(
: recipe.query_template === "inventory_purchase_to_sale_chain_profile" : recipe.query_template === "inventory_purchase_to_sale_chain_profile"
? buildInventoryPurchaseToSaleDocumentQuery(filters, resolvedLimit) ? buildInventoryPurchaseToSaleDocumentQuery(filters, resolvedLimit)
: recipe.query_template === "inventory_aging_by_purchase_date_profile" : recipe.query_template === "inventory_aging_by_purchase_date_profile"
? buildInventoryMovementQuery(filters, resolvedLimit, "dt") ? buildInventoryAgingByPurchaseDateQuery(filters, resolvedLimit)
: recipe.query_template === "inventory_quality_events_profile" : recipe.query_template === "inventory_quality_events_profile"
? buildInventoryQualityEventsQuery(filters, resolvedLimit) ? buildInventoryQualityEventsQuery(filters, resolvedLimit)
: recipe.query_template === "contracts_by_counterparty_profile" : 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, options: ComposeFactualReplyOptions,
periodWindowLabel: string | null periodWindowLabel: string | null
): string | null { ): string[] {
if (!periodWindowLabel || !needsVatPurchaseDateAnchorDisclosure(options.userMessage)) { const bridge = options.purchaseDateBridge ?? null;
return 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 asOfDate = normalizeIsoDateOnly(options.asOfDate);
@ -765,10 +795,14 @@ function buildVatPurchaseDateAnchorDisclosureLine(
asOfTs >= fromTs && asOfTs >= fromTs &&
asOfTs <= toTs asOfTs <= toTs
) { ) {
return `- Якорь периода: дата покупки ${formatDateRu(asOfDate)} попадает в налоговый период ${periodWindowLabel}; поэтому расчет ниже взят из книг продаж/покупок за это окно.`; return [
`- Якорь периода: дата покупки ${formatDateRu(asOfDate)} попадает в налоговый период ${periodWindowLabel}; поэтому расчет ниже взят из книг продаж/покупок за это окно.`
];
} }
return `- Якорь периода: дата покупки из вопроса/контекста использована для выбора налогового периода ${periodWindowLabel}; сам расчет ниже взят из книг продаж/покупок за это окно.`; return [
`- Якорь периода: дата покупки из вопроса/контекста использована для выбора налогового периода ${periodWindowLabel}; сам расчет ниже взят из книг продаж/покупок за это окно.`
];
} }
function detectRankingLimit(userMessage: string | null | undefined, fallback = 20): number { 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); 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 { 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 { function isInventorySaleMovement(row: ComposeStageRow): boolean {
return hasInventoryAccountPrefix(row.account_kt, "41.01"); 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 { function looksLikeInventoryTraceDocumentToken(value: string): boolean {
const normalized = String(value ?? "").trim(); const normalized = String(value ?? "").trim();
if (!normalized) { if (!normalized) {
@ -1526,8 +1631,12 @@ function buildInventoryAgingByItemAggregate(
} }
>(); >();
const asOfTimestamp = toUtcDayTimestamp(asOfDate); const asOfTimestamp = toUtcDayTimestamp(asOfDate);
const onHandScope = buildInventoryOnHandAgingScope(rows);
for (const row of rows) { for (const row of rows) {
if (!isInventoryPurchaseMovement(row) || !inventoryPurchaseMatchesOnHandAgingScope(row, onHandScope)) {
continue;
}
const item = extractInventoryItemName(row); const item = extractInventoryItemName(row);
if (!item) { if (!item) {
continue; continue;
@ -1613,19 +1722,11 @@ function formatInventoryAgingRows(items: InventoryAgingByItemAggregate[], asOfDa
const parts = [ const parts = [
`${index + 1}. ${item.item}`, `${index + 1}. ${item.item}`,
`первая закупка: ${inventoryTraceDateLabel(item.firstPurchasePeriod)}`, `первая закупка: ${inventoryTraceDateLabel(item.firstPurchasePeriod)}`,
`последняя закупка: ${inventoryTraceDateLabel(item.lastPurchasePeriod)}`, `последняя закупка: ${inventoryTraceDateLabel(item.lastPurchasePeriod)}`
`документов: ${formatNumberWithDots(item.documentCount)}`,
`операций: ${formatNumberWithDots(item.operations)}`
]; ];
if (item.ageDays !== null) { if (item.ageDays !== null) {
parts.push(`возраст следа на ${formatDateRu(asOfDate)}: ${formatNumberWithDots(item.ageDays)} дн.`); 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) { if (item.counterparties.length > 0) {
parts.push(`поставщики: ${item.counterparties.slice(0, 3).join("; ")}`); 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 formatConfirmedMoney = (value: number): string => (options.useRubCurrency ? formatMoneyRub(value) : formatMoney(value));
const organizationLabel = normalizeOrganizationScopeValue(options.organizationHint); const organizationLabel = normalizeOrganizationScopeValue(options.organizationHint);
const organizationScopeLabel = organizationLabel ? ` по организации ${organizationLabel}` : ""; 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 = [ const lines = [
`Коротко: подтвержденный НДС к уплате за налоговый период${organizationScopeLabel}${formatConfirmedMoney(vatToPay)}.`, directVatLine,
"Расчет сделан по книгам продаж и покупок.", "Расчет сделан по книгам продаж и покупок.",
"", "",
"Что вошло в расчет:", "Что вошло в расчет:",
...(organizationLabel ? [`- Организация: ${organizationLabel}.`] : []), ...(organizationLabel ? [`- Организация: ${organizationLabel}.`] : []),
`- Налоговый период расчета: ${periodWindowLabel ?? "не задан (нужен явный период)"}.`, `- Налоговый период расчета: ${periodWindowLabel ?? "не задан (нужен явный период)"}.`,
...(purchaseDateAnchorLine ? [purchaseDateAnchorLine] : []), ...purchaseDateAnchorLines,
`- НДС по книге продаж: ${formatConfirmedMoney(salesVat)}.`, `- НДС по книге продаж: ${formatConfirmedMoney(salesVat)}.`,
`- НДС по книге покупок (вычеты): ${formatConfirmedMoney(purchaseVat)}.`, `- НДС по книге покупок (вычеты): ${formatConfirmedMoney(purchaseVat)}.`,
`- Нетто НДС (книга продаж - книга покупок): ${formatConfirmedMoney(netVat)}.` `- Нетто НДС (книга продаж - книга покупок): ${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( function resolveRelativeMonthPeriodFromInventoryRoot(
userMessage: string, userMessage: string,
followupContext: AddressFollowupContext | null 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 { export function hasInventoryMarginRankingFollowupCue(text: string): boolean {
const normalized = textWithRepairedVariant(String(text ?? "")) const normalized = textWithRepairedVariant(String(text ?? ""))
.toLowerCase() .toLowerCase()
@ -968,6 +1016,34 @@ function mergeFollowupFilters(
): { filters: AddressFilterSet; reasons: string[] } { ): { filters: AddressFilterSet; reasons: string[] } {
const merged: AddressFilterSet = { ...current }; const merged: AddressFilterSet = { ...current };
const reasons: string[] = []; 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 (!followupContext) {
if ( if (
(intent === "list_open_contracts" || (intent === "list_open_contracts" ||
@ -987,6 +1063,7 @@ function mergeFollowupFilters(
); );
} }
} }
clearInventoryAgingOrganizationAliasItem(null);
return { filters: merged, reasons }; return { filters: merged, reasons };
} }
@ -1001,6 +1078,13 @@ function mergeFollowupFilters(
const previousAsOfDate = toNonEmptyString(previous.as_of_date); const previousAsOfDate = toNonEmptyString(previous.as_of_date);
const previousPeriodFrom = toNonEmptyString(previous.period_from); const previousPeriodFrom = toNonEmptyString(previous.period_from);
const previousPeriodTo = toNonEmptyString(previous.period_to); 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 relativeMonthFromInventoryRoot = resolveRelativeMonthPeriodFromInventoryRoot(userMessage, followupContext);
const relativeMonthFromFollowupYear = resolveRelativeMonthPeriodFromFollowupYear(userMessage, followupContext); const relativeMonthFromFollowupYear = resolveRelativeMonthPeriodFromFollowupYear(userMessage, followupContext);
const allTimeRequested = hasAllTimeHint(userMessage); const allTimeRequested = hasAllTimeHint(userMessage);
@ -1020,6 +1104,57 @@ function mergeFollowupFilters(
reasons.push("organization_from_followup_context"); 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 ( if (
intent === "inventory_on_hand_as_of_date" && intent === "inventory_on_hand_as_of_date" &&
followupContext.previous_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"); reasons.push("period_derived_from_inventory_root_frame_year");
} }
if (intent === "inventory_aging_by_purchase_date") { if (intent === "inventory_aging_by_purchase_date") {
const explicitItemMention = /(?:^|[\s,.;:!?()\-\u2014])(?:товар(?:у|а|ом)?|позици(?:и|я|ю)|item|row|line)(?=$|[\s,.;:!?()\-\u2014])/iu.test( clearInventoryAgingOrganizationAliasItem(previousOrganization);
String(userMessage ?? "")
);
if (toNonEmptyString(merged.item) && !explicitItemMention) {
delete merged.item;
reasons.push("item_cleared_for_stock_slice_aging");
}
} }
if ( if (
!sameDateRequested && !sameDateRequested &&
@ -1646,7 +1775,13 @@ function deriveIntentWithFollowupContext(
} }
const normalizedMessage = String(userMessage ?? ""); 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) { if (!hasFollowupSignal) {
return detectedIntent; return detectedIntent;
} }
@ -1655,7 +1790,6 @@ function deriveIntentWithFollowupContext(
if (!sourceIntent && !fallbackIntent) { if (!sourceIntent && !fallbackIntent) {
return detectedIntent; return detectedIntent;
} }
const previousFilters = followupContext.previous_filters ?? {};
const previousPeriodFrom = toNonEmptyString(previousFilters.period_from); const previousPeriodFrom = toNonEmptyString(previousFilters.period_from);
const previousPeriodTo = toNonEmptyString(previousFilters.period_to); const previousPeriodTo = toNonEmptyString(previousFilters.period_to);
const previousContract = toNonEmptyString(previousFilters.contract); const previousContract = toNonEmptyString(previousFilters.contract);
@ -1672,6 +1806,7 @@ function deriveIntentWithFollowupContext(
isVatFollowup && isVatFollowup &&
hasSamePeriodHint(normalizedMessage) && hasSamePeriodHint(normalizedMessage) &&
Boolean(previousPeriodFrom || previousPeriodTo); Boolean(previousPeriodFrom || previousPeriodTo);
const previousPurchaseDateBridgeSelected = toNonEmptyString(previousFilters.purchase_date_bridge_selected);
const previousIsInventoryFamily = isInventoryIntent(sourceIntent ?? undefined); const previousIsInventoryFamily = isInventoryIntent(sourceIntent ?? undefined);
const rootIsInventoryFamily = isInventoryIntent(followupContext.root_intent ?? undefined); const rootIsInventoryFamily = isInventoryIntent(followupContext.root_intent ?? undefined);
const inventoryLineageActive = const inventoryLineageActive =
@ -1700,8 +1835,13 @@ function deriveIntentWithFollowupContext(
) || hasSelectedObjectInlineSnapshotMetadata(normalizedMessage); ) || hasSelectedObjectInlineSnapshotMetadata(normalizedMessage);
const staleInventoryLineageCanYieldToCounterparty = const staleInventoryLineageCanYieldToCounterparty =
previousCounterpartyLaneActive && !hasExplicitInventoryItemReference; previousCounterpartyLaneActive && !hasExplicitInventoryItemReference;
const vatPurchaseDateBridgeContinuation =
sourceIntent === "vat_liability_confirmed_for_tax_period" &&
Boolean(previousPurchaseDateBridgeSelected) &&
hasInventoryPurchaseDateVatBridgeContinuationCue(normalizedMessage);
const inventoryPurchaseDateVatBridge = const inventoryPurchaseDateVatBridge =
inventorySelectedObjectFollowup && hasInventoryPurchaseDateVatBridgeCue(normalizedMessage); (inventorySelectedObjectFollowup && hasInventoryPurchaseDateVatBridgeCue(normalizedMessage)) ||
vatPurchaseDateBridgeContinuation;
const marginRankingLineageActive = const marginRankingLineageActive =
sourceIntent === "inventory_margin_ranking_for_nomenclature" || sourceIntent === "inventory_margin_ranking_for_nomenclature" ||
fallbackIntent === "inventory_margin_ranking_for_nomenclature" || fallbackIntent === "inventory_margin_ranking_for_nomenclature" ||
@ -1728,6 +1868,7 @@ function deriveIntentWithFollowupContext(
inventoryPurchaseDateVatBridge && inventoryPurchaseDateVatBridge &&
(detectedIntent.intent === "unknown" || (detectedIntent.intent === "unknown" ||
detectedIntent.intent === sourceIntent || detectedIntent.intent === sourceIntent ||
detectedIntent.intent === "account_balance_snapshot" ||
detectedIntent.intent === "vat_payable_confirmed_as_of_date" || detectedIntent.intent === "vat_payable_confirmed_as_of_date" ||
detectedIntent.intent === "vat_payable_forecast") 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("; ") : "не выделен отдельным полем"; 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 { function sumInventoryRowAmount(rows: ComposeStageRow[]): number {
return rows.reduce((sum, row) => sum + (typeof row.amount === "number" && Number.isFinite(row.amount) ? row.amount : 0), 0); 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") { if (intent === "inventory_aging_by_purchase_date") {
const asOfDate = deps.resolvePayablesAsOfDate(options); 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 summary = deps.summarizeInventoryTraceRows(purchaseRows);
const agingItems = deps.buildInventoryAgingByItemAggregate(purchaseRows, asOfDate);
const oldestPurchaseDate = agingItems[0]?.firstPurchasePeriod ?? summary.firstPeriod; const oldestPurchaseDate = agingItems[0]?.firstPurchasePeriod ?? summary.firstPeriod;
const oldestPurchaseAgeDays = agingItems[0]?.ageDays ?? null; const oldestPurchaseAgeDays = agingItems[0]?.ageDays ?? null;
const organizationLabel = agingItems.find((item) => item.organization)?.organization ?? null;
const oldestAnswerPreview = agingItems const oldestAnswerPreview = agingItems
.slice(0, 3) .slice(0, 3)
.map((item) => `${item.item} (${deps.inventoryTraceDateLabel(item.firstPurchasePeriod)})`) .map((item) => `${item.item} (${deps.inventoryTraceDateLabel(item.firstPurchasePeriod)})`)
.join("; "); .join("; ");
const directAnswerLine = const directAnswerLine =
agingItems.length > 0 agingItems.length > 0
? `К самым старым закупкам в текущем подтвержденном срезе относятся позиции с самой ранней первой закупкой: ${oldestAnswerPreview}.` ? `Среди фактических положительных остатков есть давно закупавшиеся позиции: ${oldestAnswerPreview}.`
: "По доступному закупочному следу позиции со старыми закупками не материализованы."; : "В фактическом положительном остатке не найдено позиций с подтвержденным старым закупочным следом.";
const lines: string[] = [directAnswerLine]; const lines: string[] = [directAnswerLine];
appendInventoryBulletSection(lines, "Сводка:", [ const summaryLines = [
`Дата среза: ${deps.formatDateRu(asOfDate)}.`, `Дата среза: ${deps.formatDateRu(asOfDate)}.`,
`Самая ранняя первая закупка среди позиций: ${deps.inventoryTraceDateLabel(oldestPurchaseDate)}.`, `Самая ранняя первая закупка среди позиций: ${deps.inventoryTraceDateLabel(oldestPurchaseDate)}.`,
`Самая поздняя найденная закупка: ${deps.inventoryTraceDateLabel(summary.lastPeriod)}.`, `Самая поздняя найденная закупка: ${deps.inventoryTraceDateLabel(summary.lastPeriod)}.`,
`Позиций в выборке: ${deps.formatNumberWithDots(agingItems.length)}.`, `Позиций в выборке: ${deps.formatNumberWithDots(agingItems.length)}.`,
`Закупочных документов: ${deps.formatNumberWithDots(summary.documents.length)}.`, `Закупочных документов: ${deps.formatNumberWithDots(summary.documents.length)}.`,
`Закупочных операций: ${deps.formatNumberWithDots(purchaseRows.length)}.` `Закупочных операций: ${deps.formatNumberWithDots(purchaseRows.length)}.`
]); ];
if (organizationLabel) {
summaryLines.splice(1, 0, `Организация: ${organizationLabel}.`);
}
appendInventoryBulletSection(lines, "Сводка:", summaryLines);
appendInventoryBulletSection(lines, "Ограничения:", [ appendInventoryBulletSection(lines, "Ограничения:", [
"Без партионного учета этот ответ показывает возраст закупочного следа по товарной позиции, а не возраст конкретного лота." "Берем только позиции с положительным остатком на дату среза; без партионного учета это возраст закупочного следа по номенклатуре, а не доказанный возраст конкретной партии."
]); ]);
if (oldestPurchaseAgeDays !== null) { if (oldestPurchaseAgeDays !== null) {
lines.push(`- Между самой ранней первой закупкой и датой среза прошло ${deps.formatNumberWithDots(oldestPurchaseAgeDays)} дн.`); lines.push(`- Между самой ранней первой закупкой и датой среза прошло ${deps.formatNumberWithDots(oldestPurchaseAgeDays)} дн.`);
@ -582,10 +613,10 @@ export function composeInventoryReply(
lines.push(`- Поставщики, встречающиеся в наблюдаемом закупочном следе: ${summary.counterparties.slice(0, 4).join("; ")}.`); lines.push(`- Поставщики, встречающиеся в наблюдаемом закупочном следе: ${summary.counterparties.slice(0, 4).join("; ")}.`);
} }
if (agingItems.length > 0) { if (agingItems.length > 0) {
appendInventorySection(lines, "Позиции от самых старых закупок:", deps.formatInventoryAgingRows(agingItems, asOfDate, 12)); appendInventorySection(lines, "Позиции от самых старых закупок:", deps.formatInventoryAgingRows(agingItems, asOfDate, 5));
} else { } else {
appendInventorySection(lines, "Позиции от самых старых закупок:", [ appendInventorySection(lines, "Позиции от самых старых закупок:", [
"- В доступных данных не найдено закупочных движений для выбранного среза." "- В доступных данных не найдено закупочных движений по позициям, которые есть в положительном остатке на дату среза."
]); ]);
} }
return buildFactualSummaryReply( return buildFactualSummaryReply(

View File

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

View File

@ -120,12 +120,25 @@ function cleanComparisonScopeCompanyLine(line: string, organization: string | nu
return clean.trim(); 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: { function buildComparisonScopeProofReply(input: {
baseReply: string; baseReply: string;
debug: Record<string, unknown>; debug: Record<string, unknown>;
session: unknown; session: unknown;
userMessage?: unknown; userMessage?: unknown;
}): { reply: string; audit: Record<string, unknown> } | null { }): { 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 entryPoint = toRecordObject(input.debug.assistant_mcp_discovery_entry_point_v1);
const turnInput = toRecordObject(entryPoint?.turn_input); const turnInput = toRecordObject(entryPoint?.turn_input);
const turnMeaning = toRecordObject(turnInput?.turn_meaning_ref); const turnMeaning = toRecordObject(turnInput?.turn_meaning_ref);

View File

@ -150,7 +150,7 @@ function hasCompactCashflowFollowupSignal(text: string | null): boolean {
if (!value) { if (!value) {
return false; 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 { function dateScopeToFilterWindow(dateScope: string | null): Record<string, string> | null {
@ -169,6 +169,37 @@ function dateScopeToFilterWindow(dateScope: string | null): Record<string, strin
return null; 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 { function looksLikeReliableOrganizationScope(value: string | null): boolean {
const text = compactLower(value); const text = compactLower(value);
if (!text) { if (!text) {
@ -197,12 +228,17 @@ function inferBusinessOverviewDiscoveryContextFromSessionItems(
continue; continue;
} }
const turnMeaningRef = toRecordObject(turnInput?.turn_meaning_ref); 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) { if (!filterWindow) {
continue; continue;
} }
const previousFilters: Record<string, unknown> = { ...filterWindow }; 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)) { if (looksLikeReliableOrganizationScope(organization)) {
previousFilters.organization = organization; previousFilters.organization = organization;
} }
@ -229,11 +265,7 @@ function mergeBusinessOverviewDateContextForCompactCashflow(input: {
return input.followupContext; return input.followupContext;
} }
const existingFilters = toRecordObject(input.followupContext?.previous_filters); const existingFilters = toRecordObject(input.followupContext?.previous_filters);
if ( if (hasBoundedFilterWindow(existingFilters)) {
input.toNonEmptyString(existingFilters?.period_from) ||
input.toNonEmptyString(existingFilters?.period_to) ||
input.toNonEmptyString(existingFilters?.as_of_date)
) {
return input.followupContext; return input.followupContext;
} }
const inferred = inferBusinessOverviewDiscoveryContextFromSessionItems(input.sessionItems, input.toNonEmptyString); const inferred = inferBusinessOverviewDiscoveryContextFromSessionItems(input.sessionItems, input.toNonEmptyString);
@ -244,12 +276,12 @@ function mergeBusinessOverviewDateContextForCompactCashflow(input: {
...inferred, ...inferred,
...(input.followupContext ?? {}), ...(input.followupContext ?? {}),
previous_filters: { previous_filters: {
...(toRecordObject(inferred.previous_filters) ?? {}), ...(toRecordObject(input.followupContext?.previous_filters) ?? {}),
...(toRecordObject(input.followupContext?.previous_filters) ?? {}) ...(toRecordObject(inferred.previous_filters) ?? {})
}, },
root_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); 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( function isBusinessOverviewDiscoveryFollowup(
followupContext: Record<string, unknown> | null, followupContext: Record<string, unknown> | null,
toNonEmptyString: BuildAssistantAddressOrchestrationRuntimeInput["toNonEmptyString"] toNonEmptyString: BuildAssistantAddressOrchestrationRuntimeInput["toNonEmptyString"]
@ -489,6 +531,7 @@ function mergeBusinessOverviewProofBundlesFromNavigationState(input: {
followupContext: Record<string, unknown> | null; followupContext: Record<string, unknown> | null;
sessionAddressNavigationState: unknown; sessionAddressNavigationState: unknown;
predecomposeContract: Record<string, unknown> | null; predecomposeContract: Record<string, unknown> | null;
userMessage: string;
toNonEmptyString: BuildAssistantAddressOrchestrationRuntimeInput["toNonEmptyString"]; toNonEmptyString: BuildAssistantAddressOrchestrationRuntimeInput["toNonEmptyString"];
}): Record<string, unknown> | null { }): Record<string, unknown> | null {
const currentValueBundle = toRecordObject(input.followupContext?.previous_discovery_bidirectional_value_flow); const currentValueBundle = toRecordObject(input.followupContext?.previous_discovery_bidirectional_value_flow);
@ -508,6 +551,9 @@ function mergeBusinessOverviewProofBundlesFromNavigationState(input: {
const entities = toRecordObject(input.predecomposeContract?.entities); const entities = toRecordObject(input.predecomposeContract?.entities);
const hasCurrentOrganizationSelection = Boolean(input.toNonEmptyString(entities?.organization)); const hasCurrentOrganizationSelection = Boolean(input.toNonEmptyString(entities?.organization));
const businessOverviewFollowup = isBusinessOverviewDiscoveryFollowup(input.followupContext, input.toNonEmptyString); const businessOverviewFollowup = isBusinessOverviewDiscoveryFollowup(input.followupContext, input.toNonEmptyString);
if (!hasComparisonScopeProofCue(input.userMessage)) {
return input.followupContext;
}
if (!businessOverviewFollowup && !hasCurrentOrganizationSelection) { if (!businessOverviewFollowup && !hasCurrentOrganizationSelection) {
return input.followupContext; return input.followupContext;
} }
@ -550,9 +596,13 @@ function mergeBusinessOverviewProofBundlesFromNavigationState(input: {
function mergeBusinessOverviewProofBundlesFromSessionItems(input: { function mergeBusinessOverviewProofBundlesFromSessionItems(input: {
followupContext: Record<string, unknown> | null; followupContext: Record<string, unknown> | null;
userMessage: string;
sessionItems: unknown[]; sessionItems: unknown[];
toNonEmptyString: BuildAssistantAddressOrchestrationRuntimeInput["toNonEmptyString"]; toNonEmptyString: BuildAssistantAddressOrchestrationRuntimeInput["toNonEmptyString"];
}): Record<string, unknown> | null { }): Record<string, unknown> | null {
if (!hasComparisonScopeProofCue(input.userMessage)) {
return input.followupContext;
}
if (!isBusinessOverviewDiscoveryFollowup(input.followupContext, input.toNonEmptyString)) { if (!isBusinessOverviewDiscoveryFollowup(input.followupContext, input.toNonEmptyString)) {
return input.followupContext; return input.followupContext;
} }
@ -853,6 +903,7 @@ export async function buildAssistantAddressOrchestrationRuntime(
}); });
const discoveryFollowupContextWithProofBundles = mergeBusinessOverviewProofBundlesFromSessionItems({ const discoveryFollowupContextWithProofBundles = mergeBusinessOverviewProofBundlesFromSessionItems({
followupContext: discoveryFollowupContext, followupContext: discoveryFollowupContext,
userMessage: input.userMessage,
sessionItems: input.sessionItems, sessionItems: input.sessionItems,
toNonEmptyString: input.toNonEmptyString toNonEmptyString: input.toNonEmptyString
}); });
@ -860,6 +911,7 @@ export async function buildAssistantAddressOrchestrationRuntime(
followupContext: discoveryFollowupContextWithProofBundles, followupContext: discoveryFollowupContextWithProofBundles,
sessionAddressNavigationState: input.sessionAddressNavigationState, sessionAddressNavigationState: input.sessionAddressNavigationState,
predecomposeContract, predecomposeContract,
userMessage: input.userMessage,
toNonEmptyString: input.toNonEmptyString toNonEmptyString: input.toNonEmptyString
}); });
const dialogContinuationContract = input.buildAddressDialogContinuationContractV2( 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( export function runAssistantDeepTurnResponseRuntime(
input: RunAssistantDeepTurnResponseRuntimeInput input: RunAssistantDeepTurnResponseRuntimeInput
): RunAssistantDeepTurnResponseRuntimeOutput { ): RunAssistantDeepTurnResponseRuntimeOutput {
@ -243,9 +258,11 @@ export function runAssistantDeepTurnResponseRuntime(
currentReplySource: "deep_analysis", currentReplySource: "deep_analysis",
addressRuntimeMeta: addressRuntimeMetaForDeep as unknown as Record<string, unknown> | null addressRuntimeMeta: addressRuntimeMetaForDeep as unknown as Record<string, unknown> | null
}); });
const assistantReply = mcpDiscoveryResponsePolicy.applied const assistantReply = stripRoutineShortAnswerPrefix(
? mcpDiscoveryResponsePolicy.reply_text mcpDiscoveryResponsePolicy.applied
: packagingRuntime.safeAssistantReply; ? input.sanitizeReply(mcpDiscoveryResponsePolicy.reply_text, packagingRuntime.safeAssistantReply)
: packagingRuntime.safeAssistantReply
);
const replyType = mcpDiscoveryResponsePolicy.applied ? "partial_coverage" : input.composition.reply_type; const replyType = mcpDiscoveryResponsePolicy.applied ? "partial_coverage" : input.composition.reply_type;
const debug = { const debug = {
...packagingRuntime.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 { function isMcpDiscoveryEntryPointContract(value: unknown): value is AssistantMcpDiscoveryRuntimeEntryPointContract {
const record = toRecordObject(value); const record = toRecordObject(value);
return ( return (
@ -1052,12 +1067,13 @@ export function applyAssistantMcpDiscoveryResponsePolicy(
} }
pushReason(reasonCodes, "mcp_discovery_response_policy_candidate_applied"); pushReason(reasonCodes, "mcp_discovery_response_policy_candidate_applied");
const replyText = stripRoutineShortAnswerPrefix(String(candidate.reply_text));
return { return {
schema_version: ASSISTANT_MCP_DISCOVERY_RESPONSE_POLICY_SCHEMA_VERSION, schema_version: ASSISTANT_MCP_DISCOVERY_RESPONSE_POLICY_SCHEMA_VERSION,
policy_owner: "assistantMcpDiscoveryResponsePolicy", policy_owner: "assistantMcpDiscoveryResponsePolicy",
decision: "apply_candidate", decision: "apply_candidate",
applied: true, applied: true,
reply_text: String(candidate.reply_text), reply_text: replyText || String(candidate.reply_text),
reply_source: "mcp_discovery_response_candidate_guarded", reply_source: "mcp_discovery_response_candidate_guarded",
candidate, candidate,
reason_codes: reasonCodes 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( /^(?:и\s+)?кто\s+(?:главн\p{L}*|основн\p{L}*|крупн\p{L}*)\s+(?:клиент|покупател|поставщик|контрагент)(?:\s+в)?$/iu.test(
text 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( /^(?:или\s+)?(?:обычн\p{L}*\s+)?(?:клиент|поставщик|покупател\p{L}*|заказчик|контрагент)(?:\s+или\s+(?:клиент|поставщик|покупател\p{L}*|заказчик|контрагент))?$/iu.test(
text text
) || ) ||
@ -2458,6 +2461,8 @@ export function buildAssistantMcpDiscoveryTurnInput(
? null ? null
: explicitCurrentCounterpartyOverridesFollowupEntity : explicitCurrentCounterpartyOverridesFollowupEntity
? null ? null
: businessOverviewSuppressesFollowupCounterparty
? rawMetadataScopeHint
: rawMetadataScopeHint ?? : rawMetadataScopeHint ??
followupSeed.metadataScopeHint ?? followupSeed.metadataScopeHint ??
followupSeed.discoveryEntity ?? followupSeed.discoveryEntity ??
@ -2685,7 +2690,7 @@ export function buildAssistantMcpDiscoveryTurnInput(
normalizedPredecomposeDateScope && normalizedPredecomposeDateScope &&
normalizedPredecomposeDateScope.startsWith(`${rawDateScope}-`) normalizedPredecomposeDateScope.startsWith(`${rawDateScope}-`)
); );
const followupAllTimeScopeApplied = normalizedFollowupDateScope === "all_time_scope"; const followupAllTimeScopeApplied = normalizedFollowupDateScope === "all_time_scope" && !currentTurnCarriesExplicitPeriod;
const explicitDateScope = const explicitDateScope =
rawAllTimeScopeSignal || followupAllTimeScopeApplied rawAllTimeScopeSignal || followupAllTimeScopeApplied
? null ? null

View File

@ -1982,7 +1982,11 @@ function repairAddressMojibake(value) {
} }
function sanitizeOutgoingAssistantText(value, fallback = "Не смог сформировать читаемый ответ. Уточните запрос.") { function sanitizeOutgoingAssistantText(value, fallback = "Не смог сформировать читаемый ответ. Уточните запрос.") {
const repaired = repairAddressMojibake(String(value ?? "")); 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) { if (sanitized) {
return sanitized; return sanitized;
} }

View File

@ -301,7 +301,7 @@ export function createAssistantTransitionPolicy(deps) {
return `${match[3]}-${match[2]}-${match[1]}`; 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})$/); const match = String(isoDate ?? "").match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (!match) { if (!match) {
return null; return null;
@ -318,11 +318,17 @@ export function createAssistantTransitionPolicy(deps) {
purchase_date: isoDate, purchase_date: isoDate,
period_from: `${year}-${mm}-01`, period_from: `${year}-${mm}-01`,
period_to: `${year}-${mm}-${dd}`, 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 = []; const dates = [];
for (const entityRef of Array.isArray(entityRefs) ? entityRefs : []) { for (const entityRef of Array.isArray(entityRefs) ? entityRefs : []) {
const value = deps.toNonEmptyString(entityRef?.value); const value = deps.toNonEmptyString(entityRef?.value);
@ -340,40 +346,96 @@ export function createAssistantTransitionPolicy(deps) {
} }
} }
} }
if (dates.length === 0) { return uniqueSortedIsoDates(dates);
return null;
}
return dates.sort()[0] ?? null;
} }
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); const replyText = deps.toNonEmptyString(previousAddressItem?.text);
if (replyText) { if (replyText && (!firstIsoDate || !lastIsoDate)) {
const repairedText = deps.repairAddressMojibake(replyText); const repairedText = deps.repairAddressMojibake(replyText);
const explicitFirstDateMatch = repairedText.match(/первая\s+найденная\s+дата\s+закупки:\s*(\d{2}\.\d{2}\.\d{4})/iu); const explicitFirstDateMatch = repairedText.match(
const explicitFirstDateIso = explicitFirstDateMatch ? parseDmyDateToIso(explicitFirstDateMatch[1]) : null; /первая\s+(?:найденная|подтвержденная)\s+дата\s+закупки:\s*(\d{2}\.\d{2}\.\d{4})/iu
if (explicitFirstDateIso) { );
return computeMonthWindowFromIso(explicitFirstDateIso); const explicitLastDateMatch = repairedText.match(
} /последняя\s+(?:найденная|подтвержденная)\s+дата\s+закупки:\s*(\d{2}\.\d{2}\.\d{4})/iu
);
firstIsoDate = explicitFirstDateMatch ? parseDmyDateToIso(explicitFirstDateMatch[1]) : null;
lastIsoDate = explicitLastDateMatch ? parseDmyDateToIso(explicitLastDateMatch[1]) : null;
} }
const navigationSessionState = resolveNavigationSessionContextState( if (!firstIsoDate || !lastIsoDate) {
addressNavigationState, const navigationSessionState = resolveNavigationSessionContextState(
deps.toNonEmptyString, addressNavigationState,
deps.normalizeOrganizationScopeValue deps.toNonEmptyString,
); deps.normalizeOrganizationScopeValue
const focusObject = navigationSessionState.focusObject; );
const preferredResultSetId = const focusObject = navigationSessionState.focusObject;
deps.toNonEmptyString(focusObject?.provenanceResultSetId) ?? navigationSessionState.activeResultSetId; const preferredResultSetId =
const resultSets = Array.isArray(addressNavigationState?.result_sets) ? addressNavigationState.result_sets : []; deps.toNonEmptyString(focusObject?.provenanceResultSetId) ?? navigationSessionState.activeResultSetId;
const preferredResultSet = const resultSets = Array.isArray(addressNavigationState?.result_sets) ? addressNavigationState.result_sets : [];
(preferredResultSetId const preferredResultSet =
? resultSets.find((item) => deps.toNonEmptyString(item?.result_set_id) === preferredResultSetId) ?? null (preferredResultSetId
: null) ?? ? resultSets.find((item) => deps.toNonEmptyString(item?.result_set_id) === preferredResultSetId) ?? null
resultSets.find((item) => deps.toNonEmptyString(item?.intent) === "inventory_purchase_provenance_for_item") ?? : null) ??
null; resultSets.find((item) => deps.toNonEmptyString(item?.intent) === "inventory_purchase_provenance_for_item") ??
const earliestIsoDate = extractEarliestDmyDateFromEntityRefs(preferredResultSet?.entity_refs); null;
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) { function isUsableFollowupSourceDebug(debug) {
@ -605,11 +667,20 @@ export function createAssistantTransitionPolicy(deps) {
return null; 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 ( if (
sourceIntentHint !== "inventory_purchase_provenance_for_item" && sourceIntentHint !== "inventory_purchase_provenance_for_item" &&
!hasInventoryItemFocusHint && !hasInventoryItemFocusHint &&
!deps.isInventorySelectedObjectIntent(sourceIntentHint) !deps.isInventorySelectedObjectIntent(sourceIntentHint) &&
!vatPurchaseBridgeContinuation
) { ) {
return false; return false;
} }
@ -621,10 +692,13 @@ export function createAssistantTransitionPolicy(deps) {
} }
return samples.some( return samples.some(
(sample) => (sample) =>
/(?:ндс|vat)/iu.test(sample) && ((/(?:ндс|vat)/iu.test(sample) &&
/(?:на\s+дат[ауеы]\s+покупк|на\s+дат[ауеы]\s+закупк|по\s+дат[еу]\s+покупк|по\s+дат[еу]\s+закупк|дата\s+покупк|дата\s+закупк|purchase\s+date)/iu.test( /(?:на\s+дат[ауеы]\s+покупк|на\s+дат[ауеы]\s+закупк|по\s+дат[еу]\s+покупк|по\s+дат[еу]\s+закупк|дата\s+покупк|дата\s+закупк|purchase\s+date)/iu.test(
sample 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"; sourceDiscoveryPilotScopeHint === "business_overview_route_template_v1";
const navigationSessionState = earlyNavigationSessionState; const navigationSessionState = earlyNavigationSessionState;
const navigationFocusObjectHint = navigationSessionState.focusObject; const navigationFocusObjectHint = navigationSessionState.focusObject;
const sourceFiltersHint = readAddressDebugFilters(carryoverSourceDebug);
const sourceHasPurchaseDateBridgeHint = Boolean(
deps.toNonEmptyString(sourceFiltersHint?.purchase_date_bridge_selected)
);
const hasNavigationInventoryItemFocusHint = Boolean( const hasNavigationInventoryItemFocusHint = Boolean(
deps.toNonEmptyString(navigationFocusObjectHint?.label) && deps.toNonEmptyString(navigationFocusObjectHint?.label) &&
deps.toNonEmptyString(navigationFocusObjectHint?.objectType) === "item" && deps.toNonEmptyString(navigationFocusObjectHint?.objectType) === "item" &&
(sourceIntentHint === "inventory_on_hand_as_of_date" || (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" || sourceIntentHint === "inventory_supplier_stock_overlap_as_of_date" ||
deps.isInventorySelectedObjectIntent(sourceIntentHint)) deps.isInventorySelectedObjectIntent(sourceIntentHint))
); );
@ -943,7 +1024,8 @@ export function createAssistantTransitionPolicy(deps) {
userMessage, userMessage,
alternateMessage, alternateMessage,
sourceIntentHint, sourceIntentHint,
hasNavigationInventoryItemFocusHint hasNavigationInventoryItemFocusHint,
sourceHasPurchaseDateBridgeHint
); );
const inventoryMarginRankingFollowup = hasInventoryMarginRankingFollowupSignal( const inventoryMarginRankingFollowup = hasInventoryMarginRankingFollowupSignal(
userMessage, userMessage,
@ -1457,10 +1539,21 @@ export function createAssistantTransitionPolicy(deps) {
readAddressDebugItem(carryoverSourceDebug, deps.toNonEmptyString) ?? readAddressDebugItem(carryoverSourceDebug, deps.toNonEmptyString) ??
deps.toNonEmptyString(previousFilters.item) deps.toNonEmptyString(previousFilters.item)
) ?? previousAddressItem; ) ?? previousAddressItem;
const purchaseBridgeWindow = extractPurchaseDateBridgeWindow(purchaseBridgeItem, addressNavigationState); const purchaseBridgeWindow = extractPurchaseDateBridgeWindow(
purchaseBridgeItem,
addressNavigationState,
userMessage,
alternateMessage
);
if (purchaseBridgeWindow) { if (purchaseBridgeWindow) {
previousFilters.period_from = purchaseBridgeWindow.period_from; previousFilters.period_from = purchaseBridgeWindow.period_from;
previousFilters.period_to = purchaseBridgeWindow.period_to; 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( previousFilters = applyTemporalCarryoverFilters(

View File

@ -163,6 +163,11 @@ export interface AddressFilterSet {
warehouse?: string; warehouse?: string;
document_type?: string; document_type?: string;
document_ref?: 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; status?: string;
limit?: number; limit?: number;
sort?: "period_desc" | "period_asc"; sort?: "period_desc" | "period_asc";

View File

@ -244,7 +244,12 @@ describe("address follow-up temporal regressions", () => {
organization: 'ООО "Альтернатива Плюс"', organization: 'ООО "Альтернатива Плюс"',
period_from: "2015-02-01", period_from: "2015-02-01",
period_to: "2015-02-28", 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_type: "item",
previous_anchor_value: "Рабочая станция универсального специалиста" 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?.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_from).toBe("2015-02-01");
expect(result?.filters.extracted_filters.period_to).toBe("2015-02-28"); 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", () => { it("clears counterparty noise from bare organization clarification selections that resume inventory", () => {
const result = runAddressDecomposeStage("АЛЬТЕРНАТИВА", { 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"; import { composeFactualReply } from "../src/services/address_runtime/composeStage";
describe("inventory aging follow-up", () => { 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", () => { it("keeps carried date window for 'на эту дату' aging follow-up", () => {
const result = runAddressDecomposeStage("Какие остатки по товарам на эту дату относятся к старым закупкам", { const result = runAddressDecomposeStage("Какие остатки по товарам на эту дату относятся к старым закупкам", {
previous_intent: "inventory_purchase_provenance_for_item", previous_intent: "inventory_purchase_provenance_for_item",

View File

@ -2182,6 +2182,49 @@ describe("address compose stage utf8 headers", () => {
expect(reply.text).toContain("книг продаж/покупок"); 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", () => { it("formats VAT forecast amounts in rubles and emphasizes numbers when requested", () => {
const reply = composeFactualReply( const reply = composeFactualReply(
"vat_payable_forecast", "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\""); 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", () => { it("renders confirmed inventory-on-hand snapshot from normalized rows", () => {
const reply = composeFactualReply( const reply = composeFactualReply(
"inventory_on_hand_as_of_date", "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); 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", () => { it("sorts inventory-on-hand positions by amount before rendering the top list", () => {
const reply = composeFactualReply( const reply = composeFactualReply(
"inventory_on_hand_as_of_date", "inventory_on_hand_as_of_date",

View File

@ -270,7 +270,7 @@ describe("address reply builders regressions", () => {
); );
const firstLine = result?.text.split("\n")[0] ?? ""; const firstLine = result?.text.split("\n")[0] ?? "";
expect(firstLine).toContain("К самым старым закупкам"); expect(firstLine).toContain("фактических положительных остатков");
expect(firstLine).not.toContain("2026-04-18"); 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"); 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", () => { it("keeps the current reply when the turn is not an unsupported boundary", () => {
const result = applyAssistantMcpDiscoveryResponsePolicy({ const result = applyAssistantMcpDiscoveryResponsePolicy({
currentReply: "regular chat", currentReply: "regular chat",

View File

@ -214,6 +214,152 @@ describe("assistantTransitionPolicy", () => {
expect(contract.decision).toBe("continue_previous"); 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", () => { it("retargets displayed counterparty follow-up through shared referenced-entity carryover", () => {
const policy = buildPolicy({ const policy = buildPolicy({
findLastAddressAssistantItem: () => ({ findLastAddressAssistantItem: () => ({