Починить Hm-прогон ассистента по НДС, остаткам и MCP-ответам
This commit is contained in:
parent
36f7b4df03
commit
c577ac6b00
|
|
@ -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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -2302,91 +2302,7 @@ function buildAutoRunsRouter(services, openaiClient = new openaiResponsesClient_
|
|||
});
|
||||
router.post("/api/autoruns/autogen/generate", async (req, res, next) => {
|
||||
try {
|
||||
const body = toRecord(req.body);
|
||||
if (!body) {
|
||||
throw new http_1.ApiError("INVALID_AUTOGEN_PAYLOAD", "JSON body is required", 400);
|
||||
}
|
||||
const mode = parseAutoGenMode(body.mode);
|
||||
const count = parseAutogenCount(body.count);
|
||||
const domain = parseAutogenDomain(body.domain);
|
||||
const persistCaseSet = toBooleanSafe(body.persist_to_eval_cases) ?? true;
|
||||
const generatedBy = parseAnnotationAuthor(body.generated_by);
|
||||
const context = toRecord(body.context);
|
||||
const llmConfig = parseAutogenLlmRuntimeConfig(body, context);
|
||||
const personalityPrompt = toStringSafe(context?.autogen_personality_prompt);
|
||||
if (mode === "saved_user_sessions") {
|
||||
throw new http_1.ApiError("AUTOGEN_MODE_NOT_SUPPORTED", "Use `/api/autoruns/autogen/save-assistant-session` to save user sessions.", 400);
|
||||
}
|
||||
let questions = [];
|
||||
if (mode === "qwen_seed") {
|
||||
if (!llmConfig) {
|
||||
throw new http_1.ApiError("AUTOGEN_LLM_CONFIG_REQUIRED", "Для режима qwen_seed нужен активный LLM-контур (provider/model/baseUrl) из настроек подключения.", 400);
|
||||
}
|
||||
questions = await generateQwenSeedQuestionsLive({
|
||||
count,
|
||||
domain,
|
||||
personalityPrompt,
|
||||
llmConfig,
|
||||
client: openaiClient
|
||||
});
|
||||
}
|
||||
else {
|
||||
questions = generateCodexCreativeQuestions(count, domain);
|
||||
}
|
||||
questions = Array.from(new Set(questions.map((item) => sanitizeGeneratedQuestion(item)).filter((item) => item.length > 0))).slice(0, count);
|
||||
const generationId = generateAutogenId();
|
||||
let savedCaseSetFile = null;
|
||||
if (persistCaseSet) {
|
||||
if (!fs_1.default.existsSync(config_1.EVAL_CASES_DIR)) {
|
||||
fs_1.default.mkdirSync(config_1.EVAL_CASES_DIR, { recursive: true });
|
||||
}
|
||||
const fileName = buildAutogenCaseSetFileName(mode, generationId);
|
||||
const filePath = path_1.default.resolve(config_1.EVAL_CASES_DIR, fileName);
|
||||
const payload = buildAutogenCaseSetPayload({
|
||||
generationId,
|
||||
mode,
|
||||
domain,
|
||||
questions
|
||||
});
|
||||
fs_1.default.writeFileSync(filePath, JSON.stringify(payload, null, 2), "utf-8");
|
||||
savedCaseSetFile = fileName;
|
||||
}
|
||||
const record = {
|
||||
generation_id: generationId,
|
||||
created_at: new Date().toISOString(),
|
||||
mode,
|
||||
title: null,
|
||||
count: questions.length,
|
||||
domain,
|
||||
questions,
|
||||
generated_by: generatedBy,
|
||||
saved_case_set_file: savedCaseSetFile,
|
||||
context: context
|
||||
? {
|
||||
llm_provider: toStringSafe(context.llm_provider),
|
||||
model: toStringSafe(context.model),
|
||||
assistant_prompt_version: toStringSafe(context.assistant_prompt_version),
|
||||
decomposition_prompt_version: toStringSafe(context.decomposition_prompt_version),
|
||||
prompt_fingerprint: toStringSafe(context.prompt_fingerprint)
|
||||
? repairAutogenMojibake(String(context.prompt_fingerprint))
|
||||
: null,
|
||||
autogen_personality_id: toStringSafe(context.autogen_personality_id),
|
||||
autogen_personality_prompt: toStringSafe(context.autogen_personality_prompt)
|
||||
? repairAutogenMojibake(String(context.autogen_personality_prompt))
|
||||
: null,
|
||||
source_session_id: null,
|
||||
saved_session_file: null,
|
||||
saved_case_set_kind: "single_turn_list"
|
||||
}
|
||||
: null
|
||||
};
|
||||
const history = readAutoGenHistory();
|
||||
history.unshift(record);
|
||||
writeAutoGenHistory(history.slice(0, 500));
|
||||
(0, http_1.ok)(res, {
|
||||
ok: true,
|
||||
generation: record
|
||||
});
|
||||
throw new http_1.ApiError("AUTOGEN_QUESTION_GENERATION_DISABLED", "Автогенерация вопросов отключена: используйте сохранение живых пользовательских сессий.", 410);
|
||||
}
|
||||
catch (error) {
|
||||
next(error);
|
||||
|
|
|
|||
|
|
@ -3195,6 +3195,17 @@ class AddressQueryService {
|
|||
asOfDate: typeof filterSet.as_of_date === "string" ? filterSet.as_of_date : undefined,
|
||||
requestedResultMode,
|
||||
vatDirectSourceProbe: options.vatDirectSourceProbe ?? undefined,
|
||||
purchaseDateBridge: typeof filterSet.purchase_date_bridge_selected === "string" ||
|
||||
typeof filterSet.purchase_date_bridge_first === "string" ||
|
||||
typeof filterSet.purchase_date_bridge_last === "string"
|
||||
? {
|
||||
selectedPurchaseDate: filterSet.purchase_date_bridge_selected,
|
||||
firstPurchaseDate: filterSet.purchase_date_bridge_first,
|
||||
lastPurchaseDate: filterSet.purchase_date_bridge_last,
|
||||
basis: filterSet.purchase_date_bridge_basis,
|
||||
hasMultiplePurchaseDates: filterSet.purchase_date_bridge_has_multiple === true
|
||||
}
|
||||
: undefined,
|
||||
emphasizeNumbers: options.emphasizeNumbers ?? undefined,
|
||||
useRubCurrency: options.useRubCurrency ?? undefined
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1413,6 +1413,85 @@ function buildInventoryMovementQuery(filters, resolvedLimit, side) {
|
|||
.replace("__WHERE_CLAUSE__", buildWhereClause(filters, "Движения.Период", [inventoryCondition, itemCondition].filter((item) => Boolean(item))))
|
||||
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort));
|
||||
}
|
||||
function buildStaticWhereClause(conditions, indent = " ") {
|
||||
const cleaned = conditions.map((condition) => String(condition ?? "").trim()).filter((condition) => condition.length > 0);
|
||||
return cleaned.length > 0 ? `ГДЕ\n${indent}${cleaned.join(`\n${indent}И `)}` : "";
|
||||
}
|
||||
function resolveInventoryAgingAsOfExpr(filters) {
|
||||
const now = new Date();
|
||||
return ((typeof filters.as_of_date === "string" && filters.as_of_date.trim().length > 0
|
||||
? toDateTimeExpr(filters.as_of_date, true)
|
||||
: null) ??
|
||||
(typeof filters.period_to === "string" && filters.period_to.trim().length > 0
|
||||
? toDateTimeExpr(filters.period_to, true)
|
||||
: null) ??
|
||||
(typeof filters.period_from === "string" && filters.period_from.trim().length > 0
|
||||
? toDateTimeExpr(filters.period_from, true)
|
||||
: null) ??
|
||||
`ДАТАВРЕМЯ(${now.getFullYear()}, ${now.getMonth() + 1}, ${now.getDate()}, 23, 59, 59)`);
|
||||
}
|
||||
function buildInventoryAgingByPurchaseDateQuery(filters, resolvedLimit) {
|
||||
const asOfExpr = resolveInventoryAgingAsOfExpr(filters);
|
||||
const inventoryAccountPredicate = buildAccountPrefixPredicate("Остатки.Счет", ["41.01"]);
|
||||
const onHandScopeConditions = [
|
||||
"Остатки.КоличествоРазвернутыйОстатокДт > 0",
|
||||
inventoryAccountPredicate,
|
||||
buildOrganizationReferenceCondition(filters, ["Остатки.Организация"]),
|
||||
buildWarehouseReferenceCondition(filters, ["Остатки.Субконто3"]),
|
||||
buildInventoryItemReferenceCondition(filters, ["Остатки.Субконто1"])
|
||||
];
|
||||
const onHandWhereClause = buildStaticWhereClause(onHandScopeConditions);
|
||||
const currentStockItemSubquery = [
|
||||
"(ВЫБРАТЬ РАЗЛИЧНЫЕ",
|
||||
" Остатки.Субконто1",
|
||||
" ИЗ",
|
||||
` РегистрБухгалтерии.Хозрасчетный.Остатки(${asOfExpr}, , , ) КАК Остатки`,
|
||||
buildStaticWhereClause(onHandScopeConditions, " "),
|
||||
")"
|
||||
].join("\n");
|
||||
const purchaseWhereClause = buildStaticWhereClause([
|
||||
"Товары.Ссылка.Проведен = ИСТИНА",
|
||||
`Товары.Ссылка.Дата <= ${asOfExpr}`,
|
||||
buildOrganizationReferenceCondition(filters, ["Товары.Ссылка.Организация"]),
|
||||
buildInventoryItemReferenceCondition(filters, ["Товары.Номенклатура"]),
|
||||
`Товары.Номенклатура В ${currentStockItemSubquery}`
|
||||
]);
|
||||
return `
|
||||
ВЫБРАТЬ ПЕРВЫЕ ${resolvedLimit}
|
||||
${asOfExpr} КАК Период,
|
||||
"Остатки на дату" КАК Регистратор,
|
||||
ПРЕДСТАВЛЕНИЕ(Остатки.Счет) КАК СчетДт,
|
||||
"" КАК СчетКт,
|
||||
Остатки.СуммаРазвернутыйОстатокДт КАК Сумма,
|
||||
ПРЕДСТАВЛЕНИЕ(Остатки.Субконто1) КАК Номенклатура,
|
||||
"" КАК Контрагент,
|
||||
"" КАК Договор,
|
||||
ПРЕДСТАВЛЕНИЕ(Остатки.Организация) КАК Организация,
|
||||
ПРЕДСТАВЛЕНИЕ(Остатки.Субконто3) КАК Склад,
|
||||
Остатки.КоличествоРазвернутыйОстатокДт КАК Количество
|
||||
ИЗ
|
||||
РегистрБухгалтерии.Хозрасчетный.Остатки(${asOfExpr}, , , ) КАК Остатки
|
||||
${onHandWhereClause}
|
||||
ОБЪЕДИНИТЬ ВСЕ
|
||||
ВЫБРАТЬ ПЕРВЫЕ ${resolvedLimit}
|
||||
Товары.Ссылка.Дата КАК Период,
|
||||
ПРЕДСТАВЛЕНИЕ(Товары.Ссылка) КАК Регистратор,
|
||||
"41.01" КАК СчетДт,
|
||||
"" КАК СчетКт,
|
||||
Товары.Сумма КАК Сумма,
|
||||
ПРЕДСТАВЛЕНИЕ(Товары.Номенклатура) КАК Номенклатура,
|
||||
ПРЕДСТАВЛЕНИЕ(Товары.Ссылка.Контрагент) КАК Контрагент,
|
||||
ПРЕДСТАВЛЕНИЕ(Товары.Ссылка.ДоговорКонтрагента) КАК Договор,
|
||||
ПРЕДСТАВЛЕНИЕ(Товары.Ссылка.Организация) КАК Организация,
|
||||
"" КАК Склад,
|
||||
Товары.Количество КАК Количество
|
||||
ИЗ
|
||||
Документ.ПоступлениеТоваровУслуг.Товары КАК Товары
|
||||
${purchaseWhereClause}
|
||||
УПОРЯДОЧИТЬ ПО
|
||||
Период ВОЗР
|
||||
`.trim();
|
||||
}
|
||||
function buildWarehouseReferenceCondition(filters, fieldPaths) {
|
||||
const warehouse = typeof filters.warehouse === "string" ? filters.warehouse.trim() : "";
|
||||
if (!warehouse) {
|
||||
|
|
@ -1922,7 +2001,7 @@ function buildAddressRecipePlan(recipe, filters) {
|
|||
: recipe.query_template === "inventory_purchase_to_sale_chain_profile"
|
||||
? buildInventoryPurchaseToSaleDocumentQuery(filters, resolvedLimit)
|
||||
: recipe.query_template === "inventory_aging_by_purchase_date_profile"
|
||||
? buildInventoryMovementQuery(filters, resolvedLimit, "dt")
|
||||
? buildInventoryAgingByPurchaseDateQuery(filters, resolvedLimit)
|
||||
: recipe.query_template === "inventory_quality_events_profile"
|
||||
? buildInventoryQualityEventsQuery(filters, resolvedLimit)
|
||||
: recipe.query_template === "contracts_by_counterparty_profile"
|
||||
|
|
|
|||
|
|
@ -549,9 +549,33 @@ function needsVatPurchaseDateAnchorDisclosure(userMessage) {
|
|||
}
|
||||
return /(?:дата|дату|дате|момент)\s+(?:покуп|закуп)|(?:покуп|закуп)\S*\s+(?:дат|момент)|purchase\s+date|date\s+of\s+purchase/iu.test(text);
|
||||
}
|
||||
function buildVatPurchaseDateAnchorDisclosureLine(options, periodWindowLabel) {
|
||||
if (!periodWindowLabel || !needsVatPurchaseDateAnchorDisclosure(options.userMessage)) {
|
||||
return null;
|
||||
function vatPurchaseDateBasisLabel(basis, hasMultiplePurchaseDates) {
|
||||
if (!hasMultiplePurchaseDates) {
|
||||
return "подтвержденную дату закупки";
|
||||
}
|
||||
if (basis === "last_confirmed_purchase") {
|
||||
return "последнюю подтвержденную дату закупки";
|
||||
}
|
||||
return "первую подтвержденную дату закупки";
|
||||
}
|
||||
function buildVatPurchaseDateAnchorDisclosureLines(options, periodWindowLabel) {
|
||||
const bridge = options.purchaseDateBridge ?? null;
|
||||
const selectedPurchaseDate = normalizeIsoDateOnly(bridge?.selectedPurchaseDate);
|
||||
const firstPurchaseDate = normalizeIsoDateOnly(bridge?.firstPurchaseDate);
|
||||
const lastPurchaseDate = normalizeIsoDateOnly(bridge?.lastPurchaseDate);
|
||||
const hasMultiplePurchaseDates = Boolean(bridge?.hasMultiplePurchaseDates || (firstPurchaseDate && lastPurchaseDate && firstPurchaseDate !== lastPurchaseDate));
|
||||
if (!periodWindowLabel || (!selectedPurchaseDate && !needsVatPurchaseDateAnchorDisclosure(options.userMessage))) {
|
||||
return [];
|
||||
}
|
||||
if (selectedPurchaseDate) {
|
||||
const basisLabel = vatPurchaseDateBasisLabel(bridge?.basis, hasMultiplePurchaseDates);
|
||||
const lines = [
|
||||
`- Якорь периода: для расчета я использую ${basisLabel} ${formatDateRu(selectedPurchaseDate)}; налоговый период ${periodWindowLabel}; сам расчет ниже взят из книг продаж/покупок за это окно.`
|
||||
];
|
||||
if (hasMultiplePurchaseDates && firstPurchaseDate && lastPurchaseDate) {
|
||||
lines.push(`- Важно: у позиции несколько подтвержденных дат закупки (${formatDateRu(firstPurchaseDate)}..${formatDateRu(lastPurchaseDate)}); это расчет по выбранному якорю, а не единственная возможная дата.`);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
const asOfDate = normalizeIsoDateOnly(options.asOfDate);
|
||||
const periodFrom = normalizeIsoDateOnly(options.periodFrom);
|
||||
|
|
@ -565,9 +589,13 @@ function buildVatPurchaseDateAnchorDisclosureLine(options, periodWindowLabel) {
|
|||
toTs !== null &&
|
||||
asOfTs >= fromTs &&
|
||||
asOfTs <= toTs) {
|
||||
return `- Якорь периода: дата покупки ${formatDateRu(asOfDate)} попадает в налоговый период ${periodWindowLabel}; поэтому расчет ниже взят из книг продаж/покупок за это окно.`;
|
||||
return [
|
||||
`- Якорь периода: дата покупки ${formatDateRu(asOfDate)} попадает в налоговый период ${periodWindowLabel}; поэтому расчет ниже взят из книг продаж/покупок за это окно.`
|
||||
];
|
||||
}
|
||||
return `- Якорь периода: дата покупки из вопроса/контекста использована для выбора налогового периода ${periodWindowLabel}; сам расчет ниже взят из книг продаж/покупок за это окно.`;
|
||||
return [
|
||||
`- Якорь периода: дата покупки из вопроса/контекста использована для выбора налогового периода ${periodWindowLabel}; сам расчет ниже взят из книг продаж/покупок за это окно.`
|
||||
];
|
||||
}
|
||||
function detectRankingLimit(userMessage, fallback = 20) {
|
||||
const text = normalizeQuestionText(userMessage);
|
||||
|
|
@ -996,12 +1024,72 @@ function hasInventoryAccountPrefix(value, prefix) {
|
|||
.replace(",", ".");
|
||||
return normalized === prefix || normalized.startsWith(`${prefix}.`) || normalized.startsWith(prefix);
|
||||
}
|
||||
function isInventoryOnHandSnapshotRow(row) {
|
||||
const registrator = String(row.registrator ?? "").trim().toLowerCase();
|
||||
return /(?:остатки\s+на\s+дату|stock\s*on\s*hand|balance\s+as\s+of)/iu.test(registrator);
|
||||
}
|
||||
function isInventoryPurchaseMovement(row) {
|
||||
return hasInventoryAccountPrefix(row.account_dt, "41.01");
|
||||
return hasInventoryAccountPrefix(row.account_dt, "41.01") && !isInventoryOnHandSnapshotRow(row);
|
||||
}
|
||||
function isInventorySaleMovement(row) {
|
||||
return hasInventoryAccountPrefix(row.account_kt, "41.01");
|
||||
}
|
||||
function inventoryAgingKeyPart(value) {
|
||||
return normalizeEntityToken(value) ?? "";
|
||||
}
|
||||
function buildInventoryOnHandAgingScope(rows) {
|
||||
const scope = {
|
||||
itemKeys: new Set(),
|
||||
itemOrgKeys: new Set(),
|
||||
fullKeys: new Set(),
|
||||
orgKeys: new Set()
|
||||
};
|
||||
for (const row of rows) {
|
||||
if (!isInventoryOnHandSnapshotRow(row)) {
|
||||
continue;
|
||||
}
|
||||
const quantity = extractInventoryQuantity(row);
|
||||
if (quantity === null || quantity <= 0) {
|
||||
continue;
|
||||
}
|
||||
const itemKey = inventoryAgingKeyPart(extractInventoryItemName(row));
|
||||
if (!itemKey) {
|
||||
continue;
|
||||
}
|
||||
const organizationKey = inventoryAgingKeyPart(extractInventoryOrganizationName(row));
|
||||
const warehouseKey = inventoryAgingKeyPart(extractInventoryWarehouseName(row));
|
||||
scope.itemKeys.add(itemKey);
|
||||
if (organizationKey) {
|
||||
scope.orgKeys.add(organizationKey);
|
||||
scope.itemOrgKeys.add(`${itemKey}|${organizationKey}`);
|
||||
}
|
||||
if (organizationKey || warehouseKey) {
|
||||
scope.fullKeys.add(`${itemKey}|${warehouseKey}|${organizationKey}`);
|
||||
}
|
||||
}
|
||||
return scope;
|
||||
}
|
||||
function inventoryPurchaseMatchesOnHandAgingScope(row, scope) {
|
||||
if (scope.itemKeys.size === 0) {
|
||||
return true;
|
||||
}
|
||||
const itemKey = inventoryAgingKeyPart(extractInventoryItemName(row));
|
||||
if (!itemKey || !scope.itemKeys.has(itemKey)) {
|
||||
return false;
|
||||
}
|
||||
const organizationKey = inventoryAgingKeyPart(extractInventoryOrganizationName(row));
|
||||
const warehouseKey = inventoryAgingKeyPart(extractInventoryWarehouseName(row));
|
||||
if (scope.fullKeys.has(`${itemKey}|${warehouseKey}|${organizationKey}`)) {
|
||||
return true;
|
||||
}
|
||||
if (organizationKey && scope.itemOrgKeys.has(`${itemKey}|${organizationKey}`)) {
|
||||
return true;
|
||||
}
|
||||
if (organizationKey && scope.orgKeys.size > 0) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
function looksLikeInventoryTraceDocumentToken(value) {
|
||||
const normalized = String(value ?? "").trim();
|
||||
if (!normalized) {
|
||||
|
|
@ -1162,7 +1250,11 @@ function formatCounterpartyItemFlowRows(rows, limit = 12) {
|
|||
function buildInventoryAgingByItemAggregate(rows, asOfDate) {
|
||||
const byItem = new Map();
|
||||
const asOfTimestamp = toUtcDayTimestamp(asOfDate);
|
||||
const onHandScope = buildInventoryOnHandAgingScope(rows);
|
||||
for (const row of rows) {
|
||||
if (!isInventoryPurchaseMovement(row) || !inventoryPurchaseMatchesOnHandAgingScope(row, onHandScope)) {
|
||||
continue;
|
||||
}
|
||||
const item = extractInventoryItemName(row);
|
||||
if (!item) {
|
||||
continue;
|
||||
|
|
@ -1245,19 +1337,11 @@ function formatInventoryAgingRows(items, asOfDate, limit = 10) {
|
|||
const parts = [
|
||||
`${index + 1}. ${item.item}`,
|
||||
`первая закупка: ${inventoryTraceDateLabel(item.firstPurchasePeriod)}`,
|
||||
`последняя закупка: ${inventoryTraceDateLabel(item.lastPurchasePeriod)}`,
|
||||
`документов: ${formatNumberWithDots(item.documentCount)}`,
|
||||
`операций: ${formatNumberWithDots(item.operations)}`
|
||||
`последняя закупка: ${inventoryTraceDateLabel(item.lastPurchasePeriod)}`
|
||||
];
|
||||
if (item.ageDays !== null) {
|
||||
parts.push(`возраст следа на ${formatDateRu(asOfDate)}: ${formatNumberWithDots(item.ageDays)} дн.`);
|
||||
}
|
||||
if (item.warehouse) {
|
||||
parts.push(`склад: ${item.warehouse}`);
|
||||
}
|
||||
if (item.organization) {
|
||||
parts.push(`организация: ${item.organization}`);
|
||||
}
|
||||
if (item.counterparties.length > 0) {
|
||||
parts.push(`поставщики: ${item.counterparties.slice(0, 3).join("; ")}`);
|
||||
}
|
||||
|
|
@ -3060,15 +3144,27 @@ function composeFactualReplyBody(intent, rows, options = {}) {
|
|||
const formatConfirmedMoney = (value) => (options.useRubCurrency ? formatMoneyRub(value) : formatMoney(value));
|
||||
const organizationLabel = (0, assistantOrganizationMatcher_1.normalizeOrganizationScopeValue)(options.organizationHint);
|
||||
const organizationScopeLabel = organizationLabel ? ` по организации ${organizationLabel}` : "";
|
||||
const purchaseDateAnchorLine = buildVatPurchaseDateAnchorDisclosureLine(options, periodWindowLabel);
|
||||
const purchaseDateAnchorLines = buildVatPurchaseDateAnchorDisclosureLines(options, periodWindowLabel);
|
||||
const selectedPurchaseDate = normalizeIsoDateOnly(options.purchaseDateBridge?.selectedPurchaseDate);
|
||||
const hasMultiplePurchaseDates = Boolean(options.purchaseDateBridge?.hasMultiplePurchaseDates ||
|
||||
(normalizeIsoDateOnly(options.purchaseDateBridge?.firstPurchaseDate) &&
|
||||
normalizeIsoDateOnly(options.purchaseDateBridge?.lastPurchaseDate) &&
|
||||
normalizeIsoDateOnly(options.purchaseDateBridge?.firstPurchaseDate) !==
|
||||
normalizeIsoDateOnly(options.purchaseDateBridge?.lastPurchaseDate)));
|
||||
const purchaseBasisLabel = vatPurchaseDateBasisLabel(options.purchaseDateBridge?.basis, hasMultiplePurchaseDates);
|
||||
const directVatLine = selectedPurchaseDate
|
||||
? hasMultiplePurchaseDates
|
||||
? `Коротко: если брать ${purchaseBasisLabel} ${formatDateRu(selectedPurchaseDate)}, подтвержденный НДС к уплате за налоговый период${organizationScopeLabel} — ${formatConfirmedMoney(vatToPay)}.`
|
||||
: `Коротко: по дате покупки ${formatDateRu(selectedPurchaseDate)} подтвержденный НДС к уплате за налоговый период${organizationScopeLabel} — ${formatConfirmedMoney(vatToPay)}.`
|
||||
: `Коротко: подтвержденный НДС к уплате за налоговый период${organizationScopeLabel} — ${formatConfirmedMoney(vatToPay)}.`;
|
||||
const lines = [
|
||||
`Коротко: подтвержденный НДС к уплате за налоговый период${organizationScopeLabel} — ${formatConfirmedMoney(vatToPay)}.`,
|
||||
directVatLine,
|
||||
"Расчет сделан по книгам продаж и покупок.",
|
||||
"",
|
||||
"Что вошло в расчет:",
|
||||
...(organizationLabel ? [`- Организация: ${organizationLabel}.`] : []),
|
||||
`- Налоговый период расчета: ${periodWindowLabel ?? "не задан (нужен явный период)"}.`,
|
||||
...(purchaseDateAnchorLine ? [purchaseDateAnchorLine] : []),
|
||||
...purchaseDateAnchorLines,
|
||||
`- НДС по книге продаж: ${formatConfirmedMoney(salesVat)}.`,
|
||||
`- НДС по книге покупок (вычеты): ${formatConfirmedMoney(purchaseVat)}.`,
|
||||
`- Нетто НДС (книга продаж - книга покупок): ${formatConfirmedMoney(netVat)}.`
|
||||
|
|
|
|||
|
|
@ -460,6 +460,23 @@ function resolveYearFromFilters(filters) {
|
|||
function hasRelativeYearHint(text) {
|
||||
return /(?:эт(?:от|ого)(?:\s+же)?\s+год|этого\s+же\s+года|того\s+же\s+года|this\s+year|same\s+year|that\s+year)/iu.test(String(text ?? ""));
|
||||
}
|
||||
function resolveMonthPeriodFromIsoDate(isoDate) {
|
||||
const match = String(isoDate ?? "").match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const year = Number(match[1]);
|
||||
const month = Number(match[2]);
|
||||
if (!Number.isFinite(year) || !Number.isFinite(month) || month < 1 || month > 12) {
|
||||
return null;
|
||||
}
|
||||
const lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate();
|
||||
return {
|
||||
period_from: `${match[1]}-${match[2]}-01`,
|
||||
period_to: `${match[1]}-${match[2]}-${String(lastDay).padStart(2, "0")}`,
|
||||
as_of_date: `${match[1]}-${match[2]}-${String(lastDay).padStart(2, "0")}`
|
||||
};
|
||||
}
|
||||
function resolveRelativeMonthPeriodFromInventoryRoot(userMessage, followupContext) {
|
||||
if (!followupContext || !isInventoryRootFrameIntent(followupContext.root_intent)) {
|
||||
return null;
|
||||
|
|
@ -629,6 +646,34 @@ function hasInventoryPurchaseDateVatBridgeCue(text) {
|
|||
return (/(?:ндс|vat)/iu.test(normalized) &&
|
||||
/(?:на\s+дат[ауеы]\s+покупк|на\s+дат[ауеы]\s+закупк|по\s+дат[еу]\s+покупк|по\s+дат[еу]\s+закупк|дата\s+покупк|дата\s+закупк|purchase\s+date)/iu.test(normalized));
|
||||
}
|
||||
function hasInventoryPurchaseDateVatBridgeContinuationCue(text) {
|
||||
const normalized = textWithRepairedVariant(String(text ?? ""))
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/ё/g, "е");
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
const mentionsPurchase = /(?:покупк|закупк|purchase)/iu.test(normalized);
|
||||
const mentionsFirstOrLast = /(?:перв|последн|earliest|oldest|latest|last)/iu.test(normalized);
|
||||
return mentionsPurchase && mentionsFirstOrLast;
|
||||
}
|
||||
function purchaseDateBridgeBasisFromMessage(userMessage) {
|
||||
const normalized = textWithRepairedVariant(String(userMessage ?? ""))
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/ё/g, "е");
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
if (/(?:последн|latest|last)/iu.test(normalized) && /(?:покупк|закупк|purchase)/iu.test(normalized)) {
|
||||
return "last_confirmed_purchase";
|
||||
}
|
||||
if (/(?:перв|earliest|oldest)/iu.test(normalized) && /(?:покупк|закупк|purchase)/iu.test(normalized)) {
|
||||
return "first_confirmed_purchase";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function hasInventoryMarginRankingFollowupCue(text) {
|
||||
const normalized = textWithRepairedVariant(String(text ?? ""))
|
||||
.toLowerCase()
|
||||
|
|
@ -745,6 +790,31 @@ function isShortDebtRoleMirrorFollowup(intent, text, followupContext) {
|
|||
function mergeFollowupFilters(current, intent, userMessage, followupContext) {
|
||||
const merged = { ...current };
|
||||
const reasons = [];
|
||||
const clearInventoryAgingOrganizationAliasItem = (organizationHint) => {
|
||||
if (intent !== "inventory_aging_by_purchase_date") {
|
||||
return;
|
||||
}
|
||||
const explicitItemMention = /(?:^|[\s,.;:!?()\-\u2014])(?:товар(?:у|а|ом)?|позици(?:и|я|ю)|item|row|line)(?=$|[\s,.;:!?()\-\u2014])/iu.test(String(userMessage ?? ""));
|
||||
const agingItem = toNonEmptyString(merged.item);
|
||||
const agingOrganization = toNonEmptyString(merged.organization) ?? organizationHint;
|
||||
const normalizedAgingItem = (0, assistantOrganizationMatcher_1.normalizeOrganizationScopeSearchText)(agingItem ?? "");
|
||||
const normalizedAgingOrganization = (0, assistantOrganizationMatcher_1.normalizeOrganizationScopeSearchText)(agingOrganization ?? "");
|
||||
const itemLooksLikeOrganizationScope = Boolean(agingItem &&
|
||||
agingOrganization &&
|
||||
((0, assistantOrganizationMatcher_1.organizationsLikelySameEntity)(agingItem, agingOrganization) ||
|
||||
(normalizedAgingItem &&
|
||||
normalizedAgingOrganization &&
|
||||
(normalizedAgingOrganization.includes(normalizedAgingItem) ||
|
||||
normalizedAgingItem.includes(normalizedAgingOrganization)))));
|
||||
if (agingItem && itemLooksLikeOrganizationScope) {
|
||||
delete merged.item;
|
||||
reasons.push("item_cleared_as_organization_scope_alias_for_stock_aging");
|
||||
}
|
||||
else if (agingItem && !explicitItemMention) {
|
||||
delete merged.item;
|
||||
reasons.push("item_cleared_for_stock_slice_aging");
|
||||
}
|
||||
};
|
||||
if (!followupContext) {
|
||||
if ((intent === "list_open_contracts" ||
|
||||
intent === "open_contracts_confirmed_as_of_date" ||
|
||||
|
|
@ -760,6 +830,7 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
|
|||
: "as_of_date_derived_from_period_for_open_contracts");
|
||||
}
|
||||
}
|
||||
clearInventoryAgingOrganizationAliasItem(null);
|
||||
return { filters: merged, reasons };
|
||||
}
|
||||
const previous = followupContext.previous_filters ?? {};
|
||||
|
|
@ -773,6 +844,12 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
|
|||
const previousAsOfDate = toNonEmptyString(previous.as_of_date);
|
||||
const previousPeriodFrom = toNonEmptyString(previous.period_from);
|
||||
const previousPeriodTo = toNonEmptyString(previous.period_to);
|
||||
const previousPurchaseDateBridgeSelected = toNonEmptyString(previous.purchase_date_bridge_selected);
|
||||
const previousPurchaseDateBridgeFirst = toNonEmptyString(previous.purchase_date_bridge_first);
|
||||
const previousPurchaseDateBridgeLast = toNonEmptyString(previous.purchase_date_bridge_last);
|
||||
const previousPurchaseDateBridgeBasis = toNonEmptyString(previous.purchase_date_bridge_basis);
|
||||
const previousPurchaseDateBridgeHasMultiple = previous.purchase_date_bridge_has_multiple === true ||
|
||||
String(previous.purchase_date_bridge_has_multiple ?? "").trim().toLowerCase() === "true";
|
||||
const relativeMonthFromInventoryRoot = resolveRelativeMonthPeriodFromInventoryRoot(userMessage, followupContext);
|
||||
const relativeMonthFromFollowupYear = resolveRelativeMonthPeriodFromFollowupYear(userMessage, followupContext);
|
||||
const allTimeRequested = hasAllTimeHint(userMessage);
|
||||
|
|
@ -790,6 +867,53 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
|
|||
merged.organization = previousOrganization;
|
||||
reasons.push("organization_from_followup_context");
|
||||
}
|
||||
if (intent === "vat_liability_confirmed_for_tax_period" && previousPurchaseDateBridgeSelected) {
|
||||
const requestedBridgeBasis = purchaseDateBridgeBasisFromMessage(userMessage);
|
||||
const shouldCarryPurchaseDateBridge = !currentHasExplicitTemporalScope &&
|
||||
(Boolean(requestedBridgeBasis) ||
|
||||
hasInventoryPurchaseDateVatBridgeCue(userMessage) ||
|
||||
hasInventoryPurchaseDateVatBridgeContinuationCue(userMessage));
|
||||
if (!shouldCarryPurchaseDateBridge) {
|
||||
delete merged.purchase_date_bridge_selected;
|
||||
delete merged.purchase_date_bridge_first;
|
||||
delete merged.purchase_date_bridge_last;
|
||||
delete merged.purchase_date_bridge_basis;
|
||||
delete merged.purchase_date_bridge_has_multiple;
|
||||
reasons.push(currentHasExplicitTemporalScope
|
||||
? "purchase_date_bridge_suppressed_by_explicit_temporal_scope"
|
||||
: "purchase_date_bridge_not_reused_without_bridge_cue");
|
||||
}
|
||||
else {
|
||||
const selectedBridgeDate = requestedBridgeBasis === "last_confirmed_purchase"
|
||||
? previousPurchaseDateBridgeLast ?? previousPurchaseDateBridgeSelected
|
||||
: requestedBridgeBasis === "first_confirmed_purchase"
|
||||
? previousPurchaseDateBridgeFirst ?? previousPurchaseDateBridgeSelected
|
||||
: previousPurchaseDateBridgeSelected;
|
||||
merged.purchase_date_bridge_selected = selectedBridgeDate;
|
||||
const selectedBridgeWindow = resolveMonthPeriodFromIsoDate(selectedBridgeDate);
|
||||
if (selectedBridgeWindow) {
|
||||
merged.period_from = selectedBridgeWindow.period_from;
|
||||
merged.period_to = selectedBridgeWindow.period_to;
|
||||
merged.as_of_date = selectedBridgeWindow.as_of_date;
|
||||
reasons.push("period_from_purchase_date_bridge_followup_context");
|
||||
reasons.push("period_to_purchase_date_bridge_followup_context");
|
||||
}
|
||||
if (previousPurchaseDateBridgeFirst) {
|
||||
merged.purchase_date_bridge_first = previousPurchaseDateBridgeFirst;
|
||||
}
|
||||
if (previousPurchaseDateBridgeLast) {
|
||||
merged.purchase_date_bridge_last = previousPurchaseDateBridgeLast;
|
||||
}
|
||||
const effectiveBridgeBasis = requestedBridgeBasis ?? previousPurchaseDateBridgeBasis;
|
||||
if (effectiveBridgeBasis) {
|
||||
merged.purchase_date_bridge_basis = effectiveBridgeBasis;
|
||||
}
|
||||
if (previousPurchaseDateBridgeHasMultiple) {
|
||||
merged.purchase_date_bridge_has_multiple = true;
|
||||
}
|
||||
reasons.push("purchase_date_bridge_from_followup_context");
|
||||
}
|
||||
}
|
||||
if (intent === "inventory_on_hand_as_of_date" &&
|
||||
followupContext.previous_intent === "inventory_on_hand_as_of_date" &&
|
||||
followupContext.target_intent === "inventory_on_hand_as_of_date" &&
|
||||
|
|
@ -1099,11 +1223,7 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
|
|||
reasons.push("period_derived_from_inventory_root_frame_year");
|
||||
}
|
||||
if (intent === "inventory_aging_by_purchase_date") {
|
||||
const explicitItemMention = /(?:^|[\s,.;:!?()\-\u2014])(?:товар(?:у|а|ом)?|позици(?:и|я|ю)|item|row|line)(?=$|[\s,.;:!?()\-\u2014])/iu.test(String(userMessage ?? ""));
|
||||
if (toNonEmptyString(merged.item) && !explicitItemMention) {
|
||||
delete merged.item;
|
||||
reasons.push("item_cleared_for_stock_slice_aging");
|
||||
}
|
||||
clearInventoryAgingOrganizationAliasItem(previousOrganization);
|
||||
}
|
||||
if (!sameDateRequested &&
|
||||
hasFollowupSignalForConfirmed &&
|
||||
|
|
@ -1317,7 +1437,12 @@ function deriveIntentWithFollowupContext(detectedIntent, userMessage, followupCo
|
|||
return detectedIntent;
|
||||
}
|
||||
const normalizedMessage = String(userMessage ?? "");
|
||||
const hasFollowupSignal = hasAddressFollowupContextSignal(normalizedMessage);
|
||||
const genericFollowupSignal = hasAddressFollowupContextSignal(normalizedMessage);
|
||||
const previousFilters = followupContext.previous_filters ?? {};
|
||||
const purchaseBridgeContinuationSignal = followupContext.previous_intent === "vat_liability_confirmed_for_tax_period" &&
|
||||
Boolean(toNonEmptyString(previousFilters.purchase_date_bridge_selected)) &&
|
||||
hasInventoryPurchaseDateVatBridgeContinuationCue(normalizedMessage);
|
||||
const hasFollowupSignal = genericFollowupSignal || purchaseBridgeContinuationSignal;
|
||||
if (!hasFollowupSignal) {
|
||||
return detectedIntent;
|
||||
}
|
||||
|
|
@ -1326,7 +1451,6 @@ function deriveIntentWithFollowupContext(detectedIntent, userMessage, followupCo
|
|||
if (!sourceIntent && !fallbackIntent) {
|
||||
return detectedIntent;
|
||||
}
|
||||
const previousFilters = followupContext.previous_filters ?? {};
|
||||
const previousPeriodFrom = toNonEmptyString(previousFilters.period_from);
|
||||
const previousPeriodTo = toNonEmptyString(previousFilters.period_to);
|
||||
const previousContract = toNonEmptyString(previousFilters.contract);
|
||||
|
|
@ -1340,6 +1464,7 @@ function deriveIntentWithFollowupContext(detectedIntent, userMessage, followupCo
|
|||
const samePeriodVatFollowup = isVatFollowup &&
|
||||
hasSamePeriodHint(normalizedMessage) &&
|
||||
Boolean(previousPeriodFrom || previousPeriodTo);
|
||||
const previousPurchaseDateBridgeSelected = toNonEmptyString(previousFilters.purchase_date_bridge_selected);
|
||||
const previousIsInventoryFamily = isInventoryIntent(sourceIntent ?? undefined);
|
||||
const rootIsInventoryFamily = isInventoryIntent(followupContext.root_intent ?? undefined);
|
||||
const inventoryLineageActive = previousIsInventoryFamily ||
|
||||
|
|
@ -1361,7 +1486,11 @@ function deriveIntentWithFollowupContext(detectedIntent, userMessage, followupCo
|
|||
sourceIntent === "open_items_by_counterparty_or_contract");
|
||||
const hasExplicitInventoryItemReference = /(?:товар|номенклатур|позици|склад|остат|sku|item|product|товар|номенклатур|позици|склад|остат)/iu.test(normalizedMessage) || hasSelectedObjectInlineSnapshotMetadata(normalizedMessage);
|
||||
const staleInventoryLineageCanYieldToCounterparty = previousCounterpartyLaneActive && !hasExplicitInventoryItemReference;
|
||||
const inventoryPurchaseDateVatBridge = inventorySelectedObjectFollowup && hasInventoryPurchaseDateVatBridgeCue(normalizedMessage);
|
||||
const vatPurchaseDateBridgeContinuation = sourceIntent === "vat_liability_confirmed_for_tax_period" &&
|
||||
Boolean(previousPurchaseDateBridgeSelected) &&
|
||||
hasInventoryPurchaseDateVatBridgeContinuationCue(normalizedMessage);
|
||||
const inventoryPurchaseDateVatBridge = (inventorySelectedObjectFollowup && hasInventoryPurchaseDateVatBridgeCue(normalizedMessage)) ||
|
||||
vatPurchaseDateBridgeContinuation;
|
||||
const marginRankingLineageActive = sourceIntent === "inventory_margin_ranking_for_nomenclature" ||
|
||||
fallbackIntent === "inventory_margin_ranking_for_nomenclature" ||
|
||||
followupContext.root_intent === "inventory_margin_ranking_for_nomenclature";
|
||||
|
|
@ -1382,6 +1511,7 @@ function deriveIntentWithFollowupContext(detectedIntent, userMessage, followupCo
|
|||
if (inventoryPurchaseDateVatBridge &&
|
||||
(detectedIntent.intent === "unknown" ||
|
||||
detectedIntent.intent === sourceIntent ||
|
||||
detectedIntent.intent === "account_balance_snapshot" ||
|
||||
detectedIntent.intent === "vat_payable_confirmed_as_of_date" ||
|
||||
detectedIntent.intent === "vat_payable_forecast")) {
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -57,6 +57,16 @@ function inventoryRequestedPartyMatches(requested, actualParties) {
|
|||
function inventoryPartyListOrUnknown(parties) {
|
||||
return parties.length > 0 ? parties.slice(0, 4).join("; ") : "не выделен отдельным полем";
|
||||
}
|
||||
function normalizeInventoryReplyEntityToken(value) {
|
||||
const normalized = String(value ?? "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/ё/gu, "е")
|
||||
.replace(/[^a-zа-я0-9]+/giu, " ")
|
||||
.replace(/\s+/gu, " ")
|
||||
.trim();
|
||||
return normalized || null;
|
||||
}
|
||||
function sumInventoryRowAmount(rows) {
|
||||
return rows.reduce((sum, row) => sum + (typeof row.amount === "number" && Number.isFinite(row.amount) ? row.amount : 0), 0);
|
||||
}
|
||||
|
|
@ -380,29 +390,47 @@ function composeInventoryReply(intent, rows, options, deps) {
|
|||
}
|
||||
if (intent === "inventory_aging_by_purchase_date") {
|
||||
const asOfDate = deps.resolvePayablesAsOfDate(options);
|
||||
const purchaseRows = rows.filter((row) => deps.isInventoryPurchaseMovement(row));
|
||||
const agingItems = deps.buildInventoryAgingByItemAggregate(rows, asOfDate);
|
||||
const agingItemTokens = new Set(agingItems
|
||||
.map((item) => normalizeInventoryReplyEntityToken(item.item))
|
||||
.filter((item) => Boolean(item)));
|
||||
const purchaseRows = rows.filter((row) => {
|
||||
if (!deps.isInventoryPurchaseMovement(row)) {
|
||||
return false;
|
||||
}
|
||||
if (agingItemTokens.size === 0) {
|
||||
return true;
|
||||
}
|
||||
const item = deps.summarizeInventoryTraceRows([row]).item;
|
||||
const itemToken = normalizeInventoryReplyEntityToken(item);
|
||||
return Boolean(itemToken && agingItemTokens.has(itemToken));
|
||||
});
|
||||
const summary = deps.summarizeInventoryTraceRows(purchaseRows);
|
||||
const agingItems = deps.buildInventoryAgingByItemAggregate(purchaseRows, asOfDate);
|
||||
const oldestPurchaseDate = agingItems[0]?.firstPurchasePeriod ?? summary.firstPeriod;
|
||||
const oldestPurchaseAgeDays = agingItems[0]?.ageDays ?? null;
|
||||
const organizationLabel = agingItems.find((item) => item.organization)?.organization ?? null;
|
||||
const oldestAnswerPreview = agingItems
|
||||
.slice(0, 3)
|
||||
.map((item) => `${item.item} (${deps.inventoryTraceDateLabel(item.firstPurchasePeriod)})`)
|
||||
.join("; ");
|
||||
const directAnswerLine = agingItems.length > 0
|
||||
? `К самым старым закупкам в текущем подтвержденном срезе относятся позиции с самой ранней первой закупкой: ${oldestAnswerPreview}.`
|
||||
: "По доступному закупочному следу позиции со старыми закупками не материализованы.";
|
||||
? `Среди фактических положительных остатков есть давно закупавшиеся позиции: ${oldestAnswerPreview}.`
|
||||
: "В фактическом положительном остатке не найдено позиций с подтвержденным старым закупочным следом.";
|
||||
const lines = [directAnswerLine];
|
||||
(0, inventoryReplyPresentation_1.appendInventoryBulletSection)(lines, "Сводка:", [
|
||||
const summaryLines = [
|
||||
`Дата среза: ${deps.formatDateRu(asOfDate)}.`,
|
||||
`Самая ранняя первая закупка среди позиций: ${deps.inventoryTraceDateLabel(oldestPurchaseDate)}.`,
|
||||
`Самая поздняя найденная закупка: ${deps.inventoryTraceDateLabel(summary.lastPeriod)}.`,
|
||||
`Позиций в выборке: ${deps.formatNumberWithDots(agingItems.length)}.`,
|
||||
`Закупочных документов: ${deps.formatNumberWithDots(summary.documents.length)}.`,
|
||||
`Закупочных операций: ${deps.formatNumberWithDots(purchaseRows.length)}.`
|
||||
]);
|
||||
];
|
||||
if (organizationLabel) {
|
||||
summaryLines.splice(1, 0, `Организация: ${organizationLabel}.`);
|
||||
}
|
||||
(0, inventoryReplyPresentation_1.appendInventoryBulletSection)(lines, "Сводка:", summaryLines);
|
||||
(0, inventoryReplyPresentation_1.appendInventoryBulletSection)(lines, "Ограничения:", [
|
||||
"Без партионного учета этот ответ показывает возраст закупочного следа по товарной позиции, а не возраст конкретного лота."
|
||||
"Берем только позиции с положительным остатком на дату среза; без партионного учета это возраст закупочного следа по номенклатуре, а не доказанный возраст конкретной партии."
|
||||
]);
|
||||
if (oldestPurchaseAgeDays !== null) {
|
||||
lines.push(`- Между самой ранней первой закупкой и датой среза прошло ${deps.formatNumberWithDots(oldestPurchaseAgeDays)} дн.`);
|
||||
|
|
@ -411,11 +439,11 @@ function composeInventoryReply(intent, rows, options, deps) {
|
|||
lines.push(`- Поставщики, встречающиеся в наблюдаемом закупочном следе: ${summary.counterparties.slice(0, 4).join("; ")}.`);
|
||||
}
|
||||
if (agingItems.length > 0) {
|
||||
(0, inventoryReplyPresentation_1.appendInventorySection)(lines, "Позиции от самых старых закупок:", deps.formatInventoryAgingRows(agingItems, asOfDate, 12));
|
||||
(0, inventoryReplyPresentation_1.appendInventorySection)(lines, "Позиции от самых старых закупок:", deps.formatInventoryAgingRows(agingItems, asOfDate, 5));
|
||||
}
|
||||
else {
|
||||
(0, inventoryReplyPresentation_1.appendInventorySection)(lines, "Позиции от самых старых закупок:", [
|
||||
"- В доступных данных не найдено закупочных движений для выбранного среза."
|
||||
"- В доступных данных не найдено закупочных движений по позициям, которые есть в положительном остатке на дату среза."
|
||||
]);
|
||||
}
|
||||
return (0, replyContracts_1.buildFactualSummaryReply)(lines, (0, replyContracts_1.buildConfirmedBalanceSemantics)(agingItems.length > 0 ? "strong" : "medium", agingItems.length > 0));
|
||||
|
|
|
|||
|
|
@ -69,7 +69,17 @@ function cleanComparisonScopeCompanyLine(line, organization) {
|
|||
}
|
||||
return clean.trim();
|
||||
}
|
||||
function hasComparisonScopeProofCue(value) {
|
||||
const text = toNullableString(value)?.toLocaleLowerCase("ru-RU").replace(/\s+/gu, " ").trim();
|
||||
if (!text) {
|
||||
return false;
|
||||
}
|
||||
return /(?:\bсравни\b|собери\s+коротк\p{L}*\s+итог|коротк\p{L}*\s+сравн|что\s+.*подтверд\p{L}*.*(?:компан|контрагент|свк)|что\s+.*отдельно\s+по\s+(?:группа\s+свк|выбранн\p{L}*\s+контрагент)|какие\s+выводы\s+можно\s+делать\s+и\s+какие\s+нельзя)/iu.test(text);
|
||||
}
|
||||
function buildComparisonScopeProofReply(input) {
|
||||
if (!hasComparisonScopeProofCue(input.userMessage)) {
|
||||
return null;
|
||||
}
|
||||
const entryPoint = toRecordObject(input.debug.assistant_mcp_discovery_entry_point_v1);
|
||||
const turnInput = toRecordObject(entryPoint?.turn_input);
|
||||
const turnMeaning = toRecordObject(turnInput?.turn_meaning_ref);
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ function hasCompactCashflowFollowupSignal(text) {
|
|||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
return /(?:\u0431\u0435\u0437\s+\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\p{L}*|\u0431\u0435\u0437\s+\u0440\u0430\u0437\u0431\u0438\u0432\p{L}*|\u0431\u0435\u0437\s+\u0440\u0430\u0437\u0440\u0435\u0437\p{L}*|\u0431\u0435\u0437\s+\u0434\u0435\u0442\u0430\u043b\p{L}*|\u043e\u0434\u043d\u043e\u0439\s+\u0441\u0442\u0440\u043e\u043a\p{L}*|\u0442\u043e\u043b\u044c\u043a\u043e\s+\u0438\u0442\u043e\u0433|\u043f\u0440\u0438\u0448\p{L}*[\s\S]{0,80}\u0443\u0448\p{L}*[\s\S]{0,80}\u043d\u0435\u0442\u0442\u043e|\u043f\u043e\u043b\u0443\u0447\p{L}*[\s\S]{0,80}\u0437\u0430\u043f\u043b\u0430\u0442\p{L}*[\s\S]{0,80}\u0431\u0435\u0437\s+\u0434\u0435\u0442\u0430\u043b)/iu.test(value);
|
||||
return /(?:\u0431\u0435\u0437\s+\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\p{L}*|\u0431\u0435\u0437\s+\u0440\u0430\u0437\u0431\u0438\u0432\p{L}*|\u0431\u0435\u0437\s+\u0440\u0430\u0437\u0440\u0435\u0437\p{L}*|\u0431\u0435\u0437\s+\u0434\u0435\u0442\u0430\u043b\p{L}*|\u043e\u0434\u043d\u043e\u0439\s+\u0441\u0442\u0440\u043e\u043a\p{L}*|\u0442\u043e\u043b\u044c\u043a\u043e\s+\u0438\u0442\u043e\u0433|\u043f\u0440\u0438\u0448\p{L}*[\s\S]{0,80}\u0443\u0448\p{L}*[\s\S]{0,80}\u043d\u0435\u0442\u0442\u043e|\u043f\u043e\u043b\u0443\u0447\p{L}*[\s\S]{0,80}\u0437\u0430\u043f\u043b\u0430\u0442\p{L}*[\s\S]{0,80}\u0431\u0435\u0437\s+\u0434\u0435\u0442\u0430\u043b|кто\s+больше\s+всего\s+зан[её]с|кому\s+больше\s+всего\s+ушл\p{L}*|банк\s+не\s+называй\s+обычн\p{L}*\s+клиент\p{L}*|главн\p{L}*\s+клиент|главн\p{L}*\s+поставщик)/iu.test(value);
|
||||
}
|
||||
function dateScopeToFilterWindow(dateScope) {
|
||||
if (!dateScope) {
|
||||
|
|
@ -79,6 +79,35 @@ function dateScopeToFilterWindow(dateScope) {
|
|||
}
|
||||
return null;
|
||||
}
|
||||
function filterWindowFromExtractedFilters(filters) {
|
||||
if (!filters) {
|
||||
return null;
|
||||
}
|
||||
const asOfDate = typeof filters.as_of_date === "string" ? filters.as_of_date.trim() : "";
|
||||
if (/^(?:19|20)\d{2}-\d{2}-\d{2}$/.test(asOfDate)) {
|
||||
return { as_of_date: asOfDate };
|
||||
}
|
||||
const periodFrom = typeof filters.period_from === "string" ? filters.period_from.trim() : "";
|
||||
const periodTo = typeof filters.period_to === "string" ? filters.period_to.trim() : "";
|
||||
if (/^(?:19|20)\d{2}-\d{2}-\d{2}$/.test(periodFrom) && /^(?:19|20)\d{2}-\d{2}-\d{2}$/.test(periodTo)) {
|
||||
return {
|
||||
period_from: periodFrom,
|
||||
period_to: periodTo
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function hasBoundedFilterWindow(filters) {
|
||||
if (!filters) {
|
||||
return false;
|
||||
}
|
||||
if (typeof filters.as_of_date === "string" && /^(?:19|20)\d{2}-\d{2}-\d{2}$/.test(filters.as_of_date.trim())) {
|
||||
return true;
|
||||
}
|
||||
const periodFrom = typeof filters.period_from === "string" ? filters.period_from.trim() : "";
|
||||
const periodTo = typeof filters.period_to === "string" ? filters.period_to.trim() : "";
|
||||
return /^(?:19|20)\d{2}-\d{2}-\d{2}$/.test(periodFrom) && /^(?:19|20)\d{2}-\d{2}-\d{2}$/.test(periodTo);
|
||||
}
|
||||
function looksLikeReliableOrganizationScope(value) {
|
||||
const text = compactLower(value);
|
||||
if (!text) {
|
||||
|
|
@ -103,12 +132,15 @@ function inferBusinessOverviewDiscoveryContextFromSessionItems(sessionItems, toN
|
|||
continue;
|
||||
}
|
||||
const turnMeaningRef = toRecordObject(turnInput?.turn_meaning_ref);
|
||||
const filterWindow = dateScopeToFilterWindow(toNonEmptyString(turnMeaningRef?.explicit_date_scope));
|
||||
const debugFilters = toRecordObject(debug?.extracted_filters);
|
||||
const filterWindow = dateScopeToFilterWindow(toNonEmptyString(turnMeaningRef?.explicit_date_scope)) ??
|
||||
filterWindowFromExtractedFilters(debugFilters);
|
||||
if (!filterWindow) {
|
||||
continue;
|
||||
}
|
||||
const previousFilters = { ...filterWindow };
|
||||
const organization = toNonEmptyString(turnMeaningRef?.explicit_organization_scope);
|
||||
const organization = toNonEmptyString(turnMeaningRef?.explicit_organization_scope) ??
|
||||
toNonEmptyString(debugFilters?.organization);
|
||||
if (looksLikeReliableOrganizationScope(organization)) {
|
||||
previousFilters.organization = organization;
|
||||
}
|
||||
|
|
@ -129,9 +161,7 @@ function mergeBusinessOverviewDateContextForCompactCashflow(input) {
|
|||
return input.followupContext;
|
||||
}
|
||||
const existingFilters = toRecordObject(input.followupContext?.previous_filters);
|
||||
if (input.toNonEmptyString(existingFilters?.period_from) ||
|
||||
input.toNonEmptyString(existingFilters?.period_to) ||
|
||||
input.toNonEmptyString(existingFilters?.as_of_date)) {
|
||||
if (hasBoundedFilterWindow(existingFilters)) {
|
||||
return input.followupContext;
|
||||
}
|
||||
const inferred = inferBusinessOverviewDiscoveryContextFromSessionItems(input.sessionItems, input.toNonEmptyString);
|
||||
|
|
@ -142,12 +172,12 @@ function mergeBusinessOverviewDateContextForCompactCashflow(input) {
|
|||
...inferred,
|
||||
...(input.followupContext ?? {}),
|
||||
previous_filters: {
|
||||
...(toRecordObject(inferred.previous_filters) ?? {}),
|
||||
...(toRecordObject(input.followupContext?.previous_filters) ?? {})
|
||||
...(toRecordObject(input.followupContext?.previous_filters) ?? {}),
|
||||
...(toRecordObject(inferred.previous_filters) ?? {})
|
||||
},
|
||||
root_filters: {
|
||||
...(toRecordObject(inferred.root_filters) ?? {}),
|
||||
...(toRecordObject(input.followupContext?.root_filters) ?? {})
|
||||
...(toRecordObject(input.followupContext?.root_filters) ?? {}),
|
||||
...(toRecordObject(inferred.root_filters) ?? {})
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -172,6 +202,13 @@ function sameEntityHint(expected, actual) {
|
|||
}
|
||||
return left === right || left.includes(right) || right.includes(left);
|
||||
}
|
||||
function hasComparisonScopeProofCue(text) {
|
||||
const value = compactLower(text);
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
return /(?:\bсравни\b|собери\s+коротк\p{L}*\s+итог|коротк\p{L}*\s+сравн|что\s+.*подтверд\p{L}*.*(?:компан|контрагент|свк)|что\s+.*отдельно\s+по\s+(?:группа\s+свк|выбранн\p{L}*\s+контрагент)|какие\s+выводы\s+можно\s+делать\s+и\s+какие\s+нельзя)/iu.test(value);
|
||||
}
|
||||
function isBusinessOverviewDiscoveryFollowup(followupContext, toNonEmptyString) {
|
||||
if (!followupContext) {
|
||||
return false;
|
||||
|
|
@ -344,6 +381,9 @@ function mergeBusinessOverviewProofBundlesFromNavigationState(input) {
|
|||
const entities = toRecordObject(input.predecomposeContract?.entities);
|
||||
const hasCurrentOrganizationSelection = Boolean(input.toNonEmptyString(entities?.organization));
|
||||
const businessOverviewFollowup = isBusinessOverviewDiscoveryFollowup(input.followupContext, input.toNonEmptyString);
|
||||
if (!hasComparisonScopeProofCue(input.userMessage)) {
|
||||
return input.followupContext;
|
||||
}
|
||||
if (!businessOverviewFollowup && !hasCurrentOrganizationSelection) {
|
||||
return input.followupContext;
|
||||
}
|
||||
|
|
@ -376,6 +416,9 @@ function mergeBusinessOverviewProofBundlesFromNavigationState(input) {
|
|||
};
|
||||
}
|
||||
function mergeBusinessOverviewProofBundlesFromSessionItems(input) {
|
||||
if (!hasComparisonScopeProofCue(input.userMessage)) {
|
||||
return input.followupContext;
|
||||
}
|
||||
if (!isBusinessOverviewDiscoveryFollowup(input.followupContext, input.toNonEmptyString)) {
|
||||
return input.followupContext;
|
||||
}
|
||||
|
|
@ -576,6 +619,7 @@ async function buildAssistantAddressOrchestrationRuntime(input) {
|
|||
});
|
||||
const discoveryFollowupContextWithProofBundles = mergeBusinessOverviewProofBundlesFromSessionItems({
|
||||
followupContext: discoveryFollowupContext,
|
||||
userMessage: input.userMessage,
|
||||
sessionItems: input.sessionItems,
|
||||
toNonEmptyString: input.toNonEmptyString
|
||||
});
|
||||
|
|
@ -583,6 +627,7 @@ async function buildAssistantAddressOrchestrationRuntime(input) {
|
|||
followupContext: discoveryFollowupContextWithProofBundles,
|
||||
sessionAddressNavigationState: input.sessionAddressNavigationState,
|
||||
predecomposeContract,
|
||||
userMessage: input.userMessage,
|
||||
toNonEmptyString: input.toNonEmptyString
|
||||
});
|
||||
const dialogContinuationContract = input.buildAddressDialogContinuationContractV2(input.userMessage, addressInputMessage, carryover, addressPreDecompose);
|
||||
|
|
|
|||
|
|
@ -74,6 +74,15 @@ function normalizeAddressRuntimeMetaForDeep(value) {
|
|||
mcpDiscoveryRuntimeEntryPoint: source.mcpDiscoveryRuntimeEntryPoint ?? null
|
||||
};
|
||||
}
|
||||
function stripRoutineShortAnswerPrefix(value) {
|
||||
return String(value ?? "")
|
||||
.split(/\r?\n/g)
|
||||
.map((line) => line
|
||||
.replace(/^((?:\s|\uFEFF)*(?:[-*]\s*)?)(?:\u041a\u043e\u0440\u043e\u0442\u043a\u043e:\s*)+/u, "$1")
|
||||
.replace(/(:\s*)\u041a\u043e\u0440\u043e\u0442\u043a\u043e:\s*/u, "$1"))
|
||||
.join("\n")
|
||||
.trim();
|
||||
}
|
||||
function runAssistantDeepTurnResponseRuntime(input) {
|
||||
const runPackagingRuntimeSafe = input.runPackagingRuntime ?? assistantDeepTurnPackagingRuntimeAdapter_1.runAssistantDeepTurnPackagingRuntime;
|
||||
const runFinalizeDeepTurnSafe = input.runFinalizeDeepTurn ?? assistantDeepTurnFinalizeRuntimeAdapter_1.finalizeAssistantDeepTurn;
|
||||
|
|
@ -125,9 +134,9 @@ function runAssistantDeepTurnResponseRuntime(input) {
|
|||
currentReplySource: "deep_analysis",
|
||||
addressRuntimeMeta: addressRuntimeMetaForDeep
|
||||
});
|
||||
const assistantReply = mcpDiscoveryResponsePolicy.applied
|
||||
? mcpDiscoveryResponsePolicy.reply_text
|
||||
: packagingRuntime.safeAssistantReply;
|
||||
const assistantReply = stripRoutineShortAnswerPrefix(mcpDiscoveryResponsePolicy.applied
|
||||
? input.sanitizeReply(mcpDiscoveryResponsePolicy.reply_text, packagingRuntime.safeAssistantReply)
|
||||
: packagingRuntime.safeAssistantReply);
|
||||
const replyType = mcpDiscoveryResponsePolicy.applied ? "partial_coverage" : input.composition.reply_type;
|
||||
const debug = {
|
||||
...packagingRuntime.debug,
|
||||
|
|
|
|||
|
|
@ -52,6 +52,15 @@ function hasInternalMechanics(value) {
|
|||
text.includes("catalog_") ||
|
||||
text.includes("select "));
|
||||
}
|
||||
function stripRoutineShortAnswerPrefix(value) {
|
||||
return String(value ?? "")
|
||||
.split(/\r?\n/g)
|
||||
.map((line) => line
|
||||
.replace(/^((?:\s|\uFEFF)*(?:[-*]\s*)?)(?:\u041a\u043e\u0440\u043e\u0442\u043a\u043e:\s*)+/u, "$1")
|
||||
.replace(/(:\s*)\u041a\u043e\u0440\u043e\u0442\u043a\u043e:\s*/u, "$1"))
|
||||
.join("\n")
|
||||
.trim();
|
||||
}
|
||||
function isMcpDiscoveryEntryPointContract(value) {
|
||||
const record = toRecordObject(value);
|
||||
return (record?.schema_version === "assistant_mcp_discovery_runtime_entry_point_v1" &&
|
||||
|
|
@ -797,12 +806,13 @@ function applyAssistantMcpDiscoveryResponsePolicy(input) {
|
|||
};
|
||||
}
|
||||
pushReason(reasonCodes, "mcp_discovery_response_policy_candidate_applied");
|
||||
const replyText = stripRoutineShortAnswerPrefix(String(candidate.reply_text));
|
||||
return {
|
||||
schema_version: exports.ASSISTANT_MCP_DISCOVERY_RESPONSE_POLICY_SCHEMA_VERSION,
|
||||
policy_owner: "assistantMcpDiscoveryResponsePolicy",
|
||||
decision: "apply_candidate",
|
||||
applied: true,
|
||||
reply_text: String(candidate.reply_text),
|
||||
reply_text: replyText || String(candidate.reply_text),
|
||||
reply_source: "mcp_discovery_response_candidate_guarded",
|
||||
candidate,
|
||||
reason_codes: reasonCodes
|
||||
|
|
|
|||
|
|
@ -170,6 +170,7 @@ function isGarbageSemanticAnchorCandidate(value) {
|
|||
}
|
||||
if (/^(?:по\s+)?(?:этим|этими)\s+данн\p{L}*$/iu.test(text) ||
|
||||
/^(?:и\s+)?кто\s+(?:главн\p{L}*|основн\p{L}*|крупн\p{L}*)\s+(?:клиент|покупател|поставщик|контрагент)(?:\s+в)?$/iu.test(text) ||
|
||||
/^(?:и\s+)?(?:главн\p{L}*|основн\p{L}*|крупн\p{L}*)\s+(?:клиент|покупател|поставщик|контрагент)(?:\s+в)?$/iu.test(text) ||
|
||||
/^(?:или\s+)?(?:обычн\p{L}*\s+)?(?:клиент|поставщик|покупател\p{L}*|заказчик|контрагент)(?:\s+или\s+(?:клиент|поставщик|покупател\p{L}*|заказчик|контрагент))?$/iu.test(text) ||
|
||||
/^(?:что|чего)\s+(?:подтвержден\p{L}*|не\s+хватает)/iu.test(text) ||
|
||||
/^(?:можно\s+ли|если\s+нет|дай\s+proxy|дай\s+прокси)/iu.test(text)) {
|
||||
|
|
@ -1823,11 +1824,13 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
|
|||
? null
|
||||
: explicitCurrentCounterpartyOverridesFollowupEntity
|
||||
? null
|
||||
: rawMetadataScopeHint ??
|
||||
followupSeed.metadataScopeHint ??
|
||||
followupSeed.discoveryEntity ??
|
||||
followupSeed.metadataSelectedEntitySet ??
|
||||
null;
|
||||
: businessOverviewSuppressesFollowupCounterparty
|
||||
? rawMetadataScopeHint
|
||||
: rawMetadataScopeHint ??
|
||||
followupSeed.metadataScopeHint ??
|
||||
followupSeed.discoveryEntity ??
|
||||
followupSeed.metadataSelectedEntitySet ??
|
||||
null;
|
||||
const metadataScopedLaneWithoutSubject = Boolean((metadataGroundedMovementLaneApplicable ||
|
||||
metadataGroundedDocumentLaneApplicable ||
|
||||
explicitVatMovementEvidenceSignal) &&
|
||||
|
|
@ -2013,7 +2016,7 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
|
|||
/^\d{4}$/.test(rawDateScope) &&
|
||||
normalizedPredecomposeDateScope &&
|
||||
normalizedPredecomposeDateScope.startsWith(`${rawDateScope}-`));
|
||||
const followupAllTimeScopeApplied = normalizedFollowupDateScope === "all_time_scope";
|
||||
const followupAllTimeScopeApplied = normalizedFollowupDateScope === "all_time_scope" && !currentTurnCarriesExplicitPeriod;
|
||||
const explicitDateScope = rawAllTimeScopeSignal || followupAllTimeScopeApplied
|
||||
? null
|
||||
: normalizedAssistantTurnMeaningDateScope ??
|
||||
|
|
|
|||
|
|
@ -2025,7 +2025,11 @@ function repairAddressMojibake(value) {
|
|||
}
|
||||
function sanitizeOutgoingAssistantText(value, fallback = "Не смог сформировать читаемый ответ. Уточните запрос.") {
|
||||
const repaired = repairAddressMojibake(String(value ?? ""));
|
||||
const sanitized = String((0, answerComposer_1.sanitizeAssistantReplyForUserFacing)(repaired) ?? "").trim();
|
||||
const sanitized = String((0, answerComposer_1.sanitizeAssistantReplyForUserFacing)(repaired) ?? "")
|
||||
.split(/\r?\n/g)
|
||||
.map((line) => line.replace(/^Коротко:\s*/u, "").replace(/(:\s*)Коротко:\s*/u, "$1"))
|
||||
.join("\n")
|
||||
.trim();
|
||||
if (sanitized) {
|
||||
return sanitized;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -182,7 +182,7 @@ function createAssistantTransitionPolicy(deps) {
|
|||
}
|
||||
return `${match[3]}-${match[2]}-${match[1]}`;
|
||||
}
|
||||
function computeMonthWindowFromIso(isoDate) {
|
||||
function computeMonthWindowFromIso(isoDate, bridgeFacts = {}) {
|
||||
const match = String(isoDate ?? "").match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||
if (!match) {
|
||||
return null;
|
||||
|
|
@ -199,10 +199,15 @@ function createAssistantTransitionPolicy(deps) {
|
|||
purchase_date: isoDate,
|
||||
period_from: `${year}-${mm}-01`,
|
||||
period_to: `${year}-${mm}-${dd}`,
|
||||
as_of_date: `${year}-${mm}-${dd}`
|
||||
as_of_date: `${year}-${mm}-${dd}`,
|
||||
purchase_date_bridge_selected: isoDate,
|
||||
...bridgeFacts
|
||||
};
|
||||
}
|
||||
function extractEarliestDmyDateFromEntityRefs(entityRefs) {
|
||||
function uniqueSortedIsoDates(dates) {
|
||||
return Array.from(new Set(dates.filter(Boolean))).sort();
|
||||
}
|
||||
function extractDmyDatesFromEntityRefs(entityRefs) {
|
||||
const dates = [];
|
||||
for (const entityRef of Array.isArray(entityRefs) ? entityRefs : []) {
|
||||
const value = deps.toNonEmptyString(entityRef?.value);
|
||||
|
|
@ -220,32 +225,80 @@ function createAssistantTransitionPolicy(deps) {
|
|||
}
|
||||
}
|
||||
}
|
||||
if (dates.length === 0) {
|
||||
return uniqueSortedIsoDates(dates);
|
||||
}
|
||||
function detectPurchaseDateBridgeBasis(userMessage, alternateMessage) {
|
||||
const text = [userMessage, alternateMessage]
|
||||
.map((item) => deps.compactWhitespace(deps.repairAddressMojibake(String(item ?? "")).toLowerCase()).replace(/ё/g, "е"))
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
if (/(?:последн|latest|last|сам[а-я\s-]*поздн)/iu.test(text)) {
|
||||
return "last_confirmed_purchase";
|
||||
}
|
||||
if (/(?:перв|earliest|oldest|ранн|сам[а-я\s-]*стар)/iu.test(text)) {
|
||||
return "first_confirmed_purchase";
|
||||
}
|
||||
return "first_confirmed_purchase_default";
|
||||
}
|
||||
function extractPurchaseDateBridgeFacts(previousAddressItem, addressNavigationState, userMessage, alternateMessage) {
|
||||
const previousFilters = (0, assistantContinuityPolicy_1.readAddressDebugFilters)(previousAddressItem?.debug ?? null);
|
||||
let firstIsoDate = deps.toNonEmptyString(previousFilters?.purchase_date_bridge_first);
|
||||
let lastIsoDate = deps.toNonEmptyString(previousFilters?.purchase_date_bridge_last);
|
||||
const selectedIsoDateFromFilters = deps.toNonEmptyString(previousFilters?.purchase_date_bridge_selected);
|
||||
if (!firstIsoDate && selectedIsoDateFromFilters) {
|
||||
firstIsoDate = selectedIsoDateFromFilters;
|
||||
}
|
||||
if (!lastIsoDate && selectedIsoDateFromFilters) {
|
||||
lastIsoDate = selectedIsoDateFromFilters;
|
||||
}
|
||||
const replyText = deps.toNonEmptyString(previousAddressItem?.text);
|
||||
if (replyText && (!firstIsoDate || !lastIsoDate)) {
|
||||
const repairedText = deps.repairAddressMojibake(replyText);
|
||||
const explicitFirstDateMatch = repairedText.match(/первая\s+(?:найденная|подтвержденная)\s+дата\s+закупки:\s*(\d{2}\.\d{2}\.\d{4})/iu);
|
||||
const explicitLastDateMatch = repairedText.match(/последняя\s+(?:найденная|подтвержденная)\s+дата\s+закупки:\s*(\d{2}\.\d{2}\.\d{4})/iu);
|
||||
firstIsoDate = explicitFirstDateMatch ? parseDmyDateToIso(explicitFirstDateMatch[1]) : null;
|
||||
lastIsoDate = explicitLastDateMatch ? parseDmyDateToIso(explicitLastDateMatch[1]) : null;
|
||||
}
|
||||
if (!firstIsoDate || !lastIsoDate) {
|
||||
const navigationSessionState = (0, assistantContinuityPolicy_1.resolveNavigationSessionContextState)(addressNavigationState, deps.toNonEmptyString, deps.normalizeOrganizationScopeValue);
|
||||
const focusObject = navigationSessionState.focusObject;
|
||||
const preferredResultSetId = deps.toNonEmptyString(focusObject?.provenanceResultSetId) ?? navigationSessionState.activeResultSetId;
|
||||
const resultSets = Array.isArray(addressNavigationState?.result_sets) ? addressNavigationState.result_sets : [];
|
||||
const preferredResultSet = (preferredResultSetId
|
||||
? resultSets.find((item) => deps.toNonEmptyString(item?.result_set_id) === preferredResultSetId) ?? null
|
||||
: null) ??
|
||||
resultSets.find((item) => deps.toNonEmptyString(item?.intent) === "inventory_purchase_provenance_for_item") ??
|
||||
null;
|
||||
const datesFromResultSet = extractDmyDatesFromEntityRefs(preferredResultSet?.entity_refs);
|
||||
firstIsoDate = firstIsoDate ?? datesFromResultSet[0] ?? null;
|
||||
lastIsoDate = lastIsoDate ?? datesFromResultSet[datesFromResultSet.length - 1] ?? null;
|
||||
}
|
||||
if (!firstIsoDate && !lastIsoDate) {
|
||||
return null;
|
||||
}
|
||||
return dates.sort()[0] ?? null;
|
||||
const normalizedFirst = firstIsoDate ?? lastIsoDate;
|
||||
const normalizedLast = lastIsoDate ?? firstIsoDate;
|
||||
const basis = detectPurchaseDateBridgeBasis(userMessage, alternateMessage);
|
||||
const selectedIsoDate = basis === "last_confirmed_purchase" ? normalizedLast : normalizedFirst;
|
||||
return {
|
||||
selectedIsoDate,
|
||||
firstIsoDate: normalizedFirst,
|
||||
lastIsoDate: normalizedLast,
|
||||
basis,
|
||||
hasMultiplePurchaseDates: Boolean(normalizedFirst && normalizedLast && normalizedFirst !== normalizedLast)
|
||||
};
|
||||
}
|
||||
function extractPurchaseDateBridgeWindow(previousAddressItem, addressNavigationState) {
|
||||
const replyText = deps.toNonEmptyString(previousAddressItem?.text);
|
||||
if (replyText) {
|
||||
const repairedText = deps.repairAddressMojibake(replyText);
|
||||
const explicitFirstDateMatch = repairedText.match(/первая\s+найденная\s+дата\s+закупки:\s*(\d{2}\.\d{2}\.\d{4})/iu);
|
||||
const explicitFirstDateIso = explicitFirstDateMatch ? parseDmyDateToIso(explicitFirstDateMatch[1]) : null;
|
||||
if (explicitFirstDateIso) {
|
||||
return computeMonthWindowFromIso(explicitFirstDateIso);
|
||||
}
|
||||
function extractPurchaseDateBridgeWindow(previousAddressItem, addressNavigationState, userMessage, alternateMessage) {
|
||||
const facts = extractPurchaseDateBridgeFacts(previousAddressItem, addressNavigationState, userMessage, alternateMessage);
|
||||
if (!facts?.selectedIsoDate) {
|
||||
return null;
|
||||
}
|
||||
const navigationSessionState = (0, assistantContinuityPolicy_1.resolveNavigationSessionContextState)(addressNavigationState, deps.toNonEmptyString, deps.normalizeOrganizationScopeValue);
|
||||
const focusObject = navigationSessionState.focusObject;
|
||||
const preferredResultSetId = deps.toNonEmptyString(focusObject?.provenanceResultSetId) ?? navigationSessionState.activeResultSetId;
|
||||
const resultSets = Array.isArray(addressNavigationState?.result_sets) ? addressNavigationState.result_sets : [];
|
||||
const preferredResultSet = (preferredResultSetId
|
||||
? resultSets.find((item) => deps.toNonEmptyString(item?.result_set_id) === preferredResultSetId) ?? null
|
||||
: null) ??
|
||||
resultSets.find((item) => deps.toNonEmptyString(item?.intent) === "inventory_purchase_provenance_for_item") ??
|
||||
null;
|
||||
const earliestIsoDate = extractEarliestDmyDateFromEntityRefs(preferredResultSet?.entity_refs);
|
||||
return earliestIsoDate ? computeMonthWindowFromIso(earliestIsoDate) : null;
|
||||
return computeMonthWindowFromIso(facts.selectedIsoDate, {
|
||||
purchase_date_bridge_first: facts.firstIsoDate,
|
||||
purchase_date_bridge_last: facts.lastIsoDate,
|
||||
purchase_date_bridge_basis: facts.basis,
|
||||
purchase_date_bridge_has_multiple: facts.hasMultiplePurchaseDates
|
||||
});
|
||||
}
|
||||
function isUsableFollowupSourceDebug(debug) {
|
||||
if (!debug || typeof debug !== "object") {
|
||||
|
|
@ -460,10 +513,12 @@ function createAssistantTransitionPolicy(deps) {
|
|||
}
|
||||
return null;
|
||||
}
|
||||
function hasInventoryPurchaseDateVatBridgeSignal(userMessage, alternateMessage, sourceIntentHint, hasInventoryItemFocusHint) {
|
||||
function hasInventoryPurchaseDateVatBridgeSignal(userMessage, alternateMessage, sourceIntentHint, hasInventoryItemFocusHint, sourceHasPurchaseDateBridgeHint = false) {
|
||||
const vatPurchaseBridgeContinuation = sourceIntentHint === "vat_liability_confirmed_for_tax_period" && sourceHasPurchaseDateBridgeHint;
|
||||
if (sourceIntentHint !== "inventory_purchase_provenance_for_item" &&
|
||||
!hasInventoryItemFocusHint &&
|
||||
!deps.isInventorySelectedObjectIntent(sourceIntentHint)) {
|
||||
!deps.isInventorySelectedObjectIntent(sourceIntentHint) &&
|
||||
!vatPurchaseBridgeContinuation) {
|
||||
return false;
|
||||
}
|
||||
const samples = [userMessage, alternateMessage]
|
||||
|
|
@ -472,8 +527,11 @@ function createAssistantTransitionPolicy(deps) {
|
|||
if (samples.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return samples.some((sample) => /(?:ндс|vat)/iu.test(sample) &&
|
||||
/(?:на\s+дат[ауеы]\s+покупк|на\s+дат[ауеы]\s+закупк|по\s+дат[еу]\s+покупк|по\s+дат[еу]\s+закупк|дата\s+покупк|дата\s+закупк|purchase\s+date)/iu.test(sample));
|
||||
return samples.some((sample) => ((/(?:ндс|vat)/iu.test(sample) &&
|
||||
/(?:на\s+дат[ауеы]\s+покупк|на\s+дат[ауеы]\s+закупк|по\s+дат[еу]\s+покупк|по\s+дат[еу]\s+закупк|дата\s+покупк|дата\s+закупк|purchase\s+date)/iu.test(sample)) ||
|
||||
((vatPurchaseBridgeContinuation || sourceIntentHint === "vat_liability_confirmed_for_tax_period") &&
|
||||
/(?:перв|последн|earliest|oldest|latest|last)/iu.test(sample) &&
|
||||
/(?:покупк|закупк|purchase)/iu.test(sample))));
|
||||
}
|
||||
function hasInventoryRootRestatementLikeSignal(userMessage, sourceIntentHint, hasInventoryRootFrame) {
|
||||
if (!hasInventoryRootFrame) {
|
||||
|
|
@ -701,12 +759,17 @@ function createAssistantTransitionPolicy(deps) {
|
|||
const hasBusinessOverviewCarryoverSourceHint = sourceDiscoveryPilotScopeHint === "business_overview_route_template_v1";
|
||||
const navigationSessionState = earlyNavigationSessionState;
|
||||
const navigationFocusObjectHint = navigationSessionState.focusObject;
|
||||
const sourceFiltersHint = (0, assistantContinuityPolicy_1.readAddressDebugFilters)(carryoverSourceDebug);
|
||||
const sourceHasPurchaseDateBridgeHint = Boolean(deps.toNonEmptyString(sourceFiltersHint?.purchase_date_bridge_selected));
|
||||
const hasNavigationInventoryItemFocusHint = Boolean(deps.toNonEmptyString(navigationFocusObjectHint?.label) &&
|
||||
deps.toNonEmptyString(navigationFocusObjectHint?.objectType) === "item" &&
|
||||
(sourceIntentHint === "inventory_on_hand_as_of_date" ||
|
||||
sourceIntentHint === "vat_liability_confirmed_for_tax_period" ||
|
||||
sourceIntentHint === "vat_payable_forecast" ||
|
||||
sourceIntentHint === "vat_payable_confirmed_as_of_date" ||
|
||||
sourceIntentHint === "inventory_supplier_stock_overlap_as_of_date" ||
|
||||
deps.isInventorySelectedObjectIntent(sourceIntentHint)));
|
||||
const inventoryPurchaseDateVatBridge = hasInventoryPurchaseDateVatBridgeSignal(userMessage, alternateMessage, sourceIntentHint, hasNavigationInventoryItemFocusHint);
|
||||
const inventoryPurchaseDateVatBridge = hasInventoryPurchaseDateVatBridgeSignal(userMessage, alternateMessage, sourceIntentHint, hasNavigationInventoryItemFocusHint, sourceHasPurchaseDateBridgeHint);
|
||||
const inventoryMarginRankingFollowup = hasInventoryMarginRankingFollowupSignal(userMessage, alternateMessage, sourceIntentHint);
|
||||
let inventoryShortFollowupPrimary = (deps.isInventorySelectedObjectIntent(sourceIntentHint) || hasNavigationInventoryItemFocusHint) &&
|
||||
deps.hasShortInventoryObjectFollowupSignal(userMessage);
|
||||
|
|
@ -1092,10 +1155,16 @@ function createAssistantTransitionPolicy(deps) {
|
|||
: findRecentInventoryPurchaseProvenanceItem(items, deps.toNonEmptyString(navigationFocusObjectLabel) ??
|
||||
(0, assistantContinuityPolicy_1.readAddressDebugItem)(carryoverSourceDebug, deps.toNonEmptyString) ??
|
||||
deps.toNonEmptyString(previousFilters.item)) ?? previousAddressItem;
|
||||
const purchaseBridgeWindow = extractPurchaseDateBridgeWindow(purchaseBridgeItem, addressNavigationState);
|
||||
const purchaseBridgeWindow = extractPurchaseDateBridgeWindow(purchaseBridgeItem, addressNavigationState, userMessage, alternateMessage);
|
||||
if (purchaseBridgeWindow) {
|
||||
previousFilters.period_from = purchaseBridgeWindow.period_from;
|
||||
previousFilters.period_to = purchaseBridgeWindow.period_to;
|
||||
previousFilters.as_of_date = purchaseBridgeWindow.as_of_date;
|
||||
previousFilters.purchase_date_bridge_selected = purchaseBridgeWindow.purchase_date_bridge_selected;
|
||||
previousFilters.purchase_date_bridge_first = purchaseBridgeWindow.purchase_date_bridge_first;
|
||||
previousFilters.purchase_date_bridge_last = purchaseBridgeWindow.purchase_date_bridge_last;
|
||||
previousFilters.purchase_date_bridge_basis = purchaseBridgeWindow.purchase_date_bridge_basis;
|
||||
previousFilters.purchase_date_bridge_has_multiple = purchaseBridgeWindow.purchase_date_bridge_has_multiple;
|
||||
}
|
||||
}
|
||||
previousFilters = (0, assistantContinuityPolicy_1.applyTemporalCarryoverFilters)(previousFilters, navigationDateScope, continuityTemporalScope, sourceIntentHint, deps.toNonEmptyString);
|
||||
|
|
|
|||
|
|
@ -2777,106 +2777,11 @@ export function buildAutoRunsRouter(services: AppServices, openaiClient = new Op
|
|||
|
||||
router.post("/api/autoruns/autogen/generate", async (req, res, next) => {
|
||||
try {
|
||||
const body = toRecord(req.body);
|
||||
if (!body) {
|
||||
throw new ApiError("INVALID_AUTOGEN_PAYLOAD", "JSON body is required", 400);
|
||||
}
|
||||
const mode = parseAutoGenMode(body.mode);
|
||||
const count = parseAutogenCount(body.count);
|
||||
const domain = parseAutogenDomain(body.domain);
|
||||
const persistCaseSet = toBooleanSafe(body.persist_to_eval_cases) ?? true;
|
||||
const generatedBy = parseAnnotationAuthor(body.generated_by);
|
||||
const context = toRecord(body.context);
|
||||
const llmConfig = parseAutogenLlmRuntimeConfig(body, context);
|
||||
const personalityPrompt = toStringSafe(context?.autogen_personality_prompt);
|
||||
|
||||
if (mode === "saved_user_sessions") {
|
||||
throw new ApiError(
|
||||
"AUTOGEN_MODE_NOT_SUPPORTED",
|
||||
"Use `/api/autoruns/autogen/save-assistant-session` to save user sessions.",
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
let questions: string[] = [];
|
||||
if (mode === "qwen_seed") {
|
||||
if (!llmConfig) {
|
||||
throw new ApiError(
|
||||
"AUTOGEN_LLM_CONFIG_REQUIRED",
|
||||
"Для режима qwen_seed нужен активный LLM-контур (provider/model/baseUrl) из настроек подключения.",
|
||||
400
|
||||
);
|
||||
}
|
||||
questions = await generateQwenSeedQuestionsLive({
|
||||
count,
|
||||
domain,
|
||||
personalityPrompt,
|
||||
llmConfig,
|
||||
client: openaiClient
|
||||
});
|
||||
} else {
|
||||
questions = generateCodexCreativeQuestions(count, domain);
|
||||
}
|
||||
questions = Array.from(new Set(questions.map((item) => sanitizeGeneratedQuestion(item)).filter((item) => item.length > 0))).slice(
|
||||
0,
|
||||
count
|
||||
throw new ApiError(
|
||||
"AUTOGEN_QUESTION_GENERATION_DISABLED",
|
||||
"Автогенерация вопросов отключена: используйте сохранение живых пользовательских сессий.",
|
||||
410
|
||||
);
|
||||
const generationId = generateAutogenId();
|
||||
|
||||
let savedCaseSetFile: string | null = null;
|
||||
if (persistCaseSet) {
|
||||
if (!fs.existsSync(EVAL_CASES_DIR)) {
|
||||
fs.mkdirSync(EVAL_CASES_DIR, { recursive: true });
|
||||
}
|
||||
const fileName = buildAutogenCaseSetFileName(mode, generationId);
|
||||
const filePath = path.resolve(EVAL_CASES_DIR, fileName);
|
||||
const payload = buildAutogenCaseSetPayload({
|
||||
generationId,
|
||||
mode,
|
||||
domain,
|
||||
questions
|
||||
});
|
||||
fs.writeFileSync(filePath, JSON.stringify(payload, null, 2), "utf-8");
|
||||
savedCaseSetFile = fileName;
|
||||
}
|
||||
|
||||
const record: AutoGenHistoryRecord = {
|
||||
generation_id: generationId,
|
||||
created_at: new Date().toISOString(),
|
||||
mode,
|
||||
title: null,
|
||||
count: questions.length,
|
||||
domain,
|
||||
questions,
|
||||
generated_by: generatedBy,
|
||||
saved_case_set_file: savedCaseSetFile,
|
||||
context: context
|
||||
? {
|
||||
llm_provider: toStringSafe(context.llm_provider),
|
||||
model: toStringSafe(context.model),
|
||||
assistant_prompt_version: toStringSafe(context.assistant_prompt_version),
|
||||
decomposition_prompt_version: toStringSafe(context.decomposition_prompt_version),
|
||||
prompt_fingerprint: toStringSafe(context.prompt_fingerprint)
|
||||
? repairAutogenMojibake(String(context.prompt_fingerprint))
|
||||
: null,
|
||||
autogen_personality_id: toStringSafe(context.autogen_personality_id),
|
||||
autogen_personality_prompt: toStringSafe(context.autogen_personality_prompt)
|
||||
? repairAutogenMojibake(String(context.autogen_personality_prompt))
|
||||
: null,
|
||||
source_session_id: null,
|
||||
saved_session_file: null,
|
||||
saved_case_set_kind: "single_turn_list"
|
||||
}
|
||||
: null
|
||||
};
|
||||
const history = readAutoGenHistory();
|
||||
history.unshift(record);
|
||||
writeAutoGenHistory(history.slice(0, 500));
|
||||
|
||||
ok(res, {
|
||||
ok: true,
|
||||
generation: record
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3944,6 +3944,18 @@ export class AddressQueryService {
|
|||
asOfDate: typeof filterSet.as_of_date === "string" ? filterSet.as_of_date : undefined,
|
||||
requestedResultMode,
|
||||
vatDirectSourceProbe: options.vatDirectSourceProbe ?? undefined,
|
||||
purchaseDateBridge:
|
||||
typeof filterSet.purchase_date_bridge_selected === "string" ||
|
||||
typeof filterSet.purchase_date_bridge_first === "string" ||
|
||||
typeof filterSet.purchase_date_bridge_last === "string"
|
||||
? {
|
||||
selectedPurchaseDate: filterSet.purchase_date_bridge_selected,
|
||||
firstPurchaseDate: filterSet.purchase_date_bridge_first,
|
||||
lastPurchaseDate: filterSet.purchase_date_bridge_last,
|
||||
basis: filterSet.purchase_date_bridge_basis,
|
||||
hasMultiplePurchaseDates: filterSet.purchase_date_bridge_has_multiple === true
|
||||
}
|
||||
: undefined,
|
||||
emphasizeNumbers: options.emphasizeNumbers ?? undefined,
|
||||
useRubCurrency: options.useRubCurrency ?? undefined
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1520,6 +1520,91 @@ function buildInventoryMovementQuery(
|
|||
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort));
|
||||
}
|
||||
|
||||
function buildStaticWhereClause(conditions: Array<string | null | undefined>, indent = " "): string {
|
||||
const cleaned = conditions.map((condition) => String(condition ?? "").trim()).filter((condition) => condition.length > 0);
|
||||
return cleaned.length > 0 ? `ГДЕ\n${indent}${cleaned.join(`\n${indent}И `)}` : "";
|
||||
}
|
||||
|
||||
function resolveInventoryAgingAsOfExpr(filters: AddressFilterSet): string {
|
||||
const now = new Date();
|
||||
return (
|
||||
(typeof filters.as_of_date === "string" && filters.as_of_date.trim().length > 0
|
||||
? toDateTimeExpr(filters.as_of_date, true)
|
||||
: null) ??
|
||||
(typeof filters.period_to === "string" && filters.period_to.trim().length > 0
|
||||
? toDateTimeExpr(filters.period_to, true)
|
||||
: null) ??
|
||||
(typeof filters.period_from === "string" && filters.period_from.trim().length > 0
|
||||
? toDateTimeExpr(filters.period_from, true)
|
||||
: null) ??
|
||||
`ДАТАВРЕМЯ(${now.getFullYear()}, ${now.getMonth() + 1}, ${now.getDate()}, 23, 59, 59)`
|
||||
);
|
||||
}
|
||||
|
||||
function buildInventoryAgingByPurchaseDateQuery(filters: AddressFilterSet, resolvedLimit: number): string {
|
||||
const asOfExpr = resolveInventoryAgingAsOfExpr(filters);
|
||||
const inventoryAccountPredicate = buildAccountPrefixPredicate("Остатки.Счет", ["41.01"]);
|
||||
const onHandScopeConditions = [
|
||||
"Остатки.КоличествоРазвернутыйОстатокДт > 0",
|
||||
inventoryAccountPredicate,
|
||||
buildOrganizationReferenceCondition(filters, ["Остатки.Организация"]),
|
||||
buildWarehouseReferenceCondition(filters, ["Остатки.Субконто3"]),
|
||||
buildInventoryItemReferenceCondition(filters, ["Остатки.Субконто1"])
|
||||
];
|
||||
const onHandWhereClause = buildStaticWhereClause(onHandScopeConditions);
|
||||
const currentStockItemSubquery = [
|
||||
"(ВЫБРАТЬ РАЗЛИЧНЫЕ",
|
||||
" Остатки.Субконто1",
|
||||
" ИЗ",
|
||||
` РегистрБухгалтерии.Хозрасчетный.Остатки(${asOfExpr}, , , ) КАК Остатки`,
|
||||
buildStaticWhereClause(onHandScopeConditions, " "),
|
||||
")"
|
||||
].join("\n");
|
||||
const purchaseWhereClause = buildStaticWhereClause([
|
||||
"Товары.Ссылка.Проведен = ИСТИНА",
|
||||
`Товары.Ссылка.Дата <= ${asOfExpr}`,
|
||||
buildOrganizationReferenceCondition(filters, ["Товары.Ссылка.Организация"]),
|
||||
buildInventoryItemReferenceCondition(filters, ["Товары.Номенклатура"]),
|
||||
`Товары.Номенклатура В ${currentStockItemSubquery}`
|
||||
]);
|
||||
|
||||
return `
|
||||
ВЫБРАТЬ ПЕРВЫЕ ${resolvedLimit}
|
||||
${asOfExpr} КАК Период,
|
||||
"Остатки на дату" КАК Регистратор,
|
||||
ПРЕДСТАВЛЕНИЕ(Остатки.Счет) КАК СчетДт,
|
||||
"" КАК СчетКт,
|
||||
Остатки.СуммаРазвернутыйОстатокДт КАК Сумма,
|
||||
ПРЕДСТАВЛЕНИЕ(Остатки.Субконто1) КАК Номенклатура,
|
||||
"" КАК Контрагент,
|
||||
"" КАК Договор,
|
||||
ПРЕДСТАВЛЕНИЕ(Остатки.Организация) КАК Организация,
|
||||
ПРЕДСТАВЛЕНИЕ(Остатки.Субконто3) КАК Склад,
|
||||
Остатки.КоличествоРазвернутыйОстатокДт КАК Количество
|
||||
ИЗ
|
||||
РегистрБухгалтерии.Хозрасчетный.Остатки(${asOfExpr}, , , ) КАК Остатки
|
||||
${onHandWhereClause}
|
||||
ОБЪЕДИНИТЬ ВСЕ
|
||||
ВЫБРАТЬ ПЕРВЫЕ ${resolvedLimit}
|
||||
Товары.Ссылка.Дата КАК Период,
|
||||
ПРЕДСТАВЛЕНИЕ(Товары.Ссылка) КАК Регистратор,
|
||||
"41.01" КАК СчетДт,
|
||||
"" КАК СчетКт,
|
||||
Товары.Сумма КАК Сумма,
|
||||
ПРЕДСТАВЛЕНИЕ(Товары.Номенклатура) КАК Номенклатура,
|
||||
ПРЕДСТАВЛЕНИЕ(Товары.Ссылка.Контрагент) КАК Контрагент,
|
||||
ПРЕДСТАВЛЕНИЕ(Товары.Ссылка.ДоговорКонтрагента) КАК Договор,
|
||||
ПРЕДСТАВЛЕНИЕ(Товары.Ссылка.Организация) КАК Организация,
|
||||
"" КАК Склад,
|
||||
Товары.Количество КАК Количество
|
||||
ИЗ
|
||||
Документ.ПоступлениеТоваровУслуг.Товары КАК Товары
|
||||
${purchaseWhereClause}
|
||||
УПОРЯДОЧИТЬ ПО
|
||||
Период ВОЗР
|
||||
`.trim();
|
||||
}
|
||||
|
||||
function buildWarehouseReferenceCondition(filters: AddressFilterSet, fieldPaths: string[]): string | null {
|
||||
const warehouse = typeof filters.warehouse === "string" ? filters.warehouse.trim() : "";
|
||||
if (!warehouse) {
|
||||
|
|
@ -2199,7 +2284,7 @@ export function buildAddressRecipePlan(
|
|||
: recipe.query_template === "inventory_purchase_to_sale_chain_profile"
|
||||
? buildInventoryPurchaseToSaleDocumentQuery(filters, resolvedLimit)
|
||||
: recipe.query_template === "inventory_aging_by_purchase_date_profile"
|
||||
? buildInventoryMovementQuery(filters, resolvedLimit, "dt")
|
||||
? buildInventoryAgingByPurchaseDateQuery(filters, resolvedLimit)
|
||||
: recipe.query_template === "inventory_quality_events_profile"
|
||||
? buildInventoryQualityEventsQuery(filters, resolvedLimit)
|
||||
: recipe.query_template === "contracts_by_counterparty_profile"
|
||||
|
|
|
|||
|
|
@ -743,12 +743,42 @@ function needsVatPurchaseDateAnchorDisclosure(userMessage: string | null | undef
|
|||
);
|
||||
}
|
||||
|
||||
function buildVatPurchaseDateAnchorDisclosureLine(
|
||||
function vatPurchaseDateBasisLabel(basis: string | null | undefined, hasMultiplePurchaseDates: boolean): string {
|
||||
if (!hasMultiplePurchaseDates) {
|
||||
return "подтвержденную дату закупки";
|
||||
}
|
||||
if (basis === "last_confirmed_purchase") {
|
||||
return "последнюю подтвержденную дату закупки";
|
||||
}
|
||||
return "первую подтвержденную дату закупки";
|
||||
}
|
||||
|
||||
function buildVatPurchaseDateAnchorDisclosureLines(
|
||||
options: ComposeFactualReplyOptions,
|
||||
periodWindowLabel: string | null
|
||||
): string | null {
|
||||
if (!periodWindowLabel || !needsVatPurchaseDateAnchorDisclosure(options.userMessage)) {
|
||||
return null;
|
||||
): string[] {
|
||||
const bridge = options.purchaseDateBridge ?? null;
|
||||
const selectedPurchaseDate = normalizeIsoDateOnly(bridge?.selectedPurchaseDate);
|
||||
const firstPurchaseDate = normalizeIsoDateOnly(bridge?.firstPurchaseDate);
|
||||
const lastPurchaseDate = normalizeIsoDateOnly(bridge?.lastPurchaseDate);
|
||||
const hasMultiplePurchaseDates = Boolean(
|
||||
bridge?.hasMultiplePurchaseDates || (firstPurchaseDate && lastPurchaseDate && firstPurchaseDate !== lastPurchaseDate)
|
||||
);
|
||||
if (!periodWindowLabel || (!selectedPurchaseDate && !needsVatPurchaseDateAnchorDisclosure(options.userMessage))) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (selectedPurchaseDate) {
|
||||
const basisLabel = vatPurchaseDateBasisLabel(bridge?.basis, hasMultiplePurchaseDates);
|
||||
const lines = [
|
||||
`- Якорь периода: для расчета я использую ${basisLabel} ${formatDateRu(selectedPurchaseDate)}; налоговый период ${periodWindowLabel}; сам расчет ниже взят из книг продаж/покупок за это окно.`
|
||||
];
|
||||
if (hasMultiplePurchaseDates && firstPurchaseDate && lastPurchaseDate) {
|
||||
lines.push(
|
||||
`- Важно: у позиции несколько подтвержденных дат закупки (${formatDateRu(firstPurchaseDate)}..${formatDateRu(lastPurchaseDate)}); это расчет по выбранному якорю, а не единственная возможная дата.`
|
||||
);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
const asOfDate = normalizeIsoDateOnly(options.asOfDate);
|
||||
|
|
@ -765,10 +795,14 @@ function buildVatPurchaseDateAnchorDisclosureLine(
|
|||
asOfTs >= fromTs &&
|
||||
asOfTs <= toTs
|
||||
) {
|
||||
return `- Якорь периода: дата покупки ${formatDateRu(asOfDate)} попадает в налоговый период ${periodWindowLabel}; поэтому расчет ниже взят из книг продаж/покупок за это окно.`;
|
||||
return [
|
||||
`- Якорь периода: дата покупки ${formatDateRu(asOfDate)} попадает в налоговый период ${periodWindowLabel}; поэтому расчет ниже взят из книг продаж/покупок за это окно.`
|
||||
];
|
||||
}
|
||||
|
||||
return `- Якорь периода: дата покупки из вопроса/контекста использована для выбора налогового периода ${periodWindowLabel}; сам расчет ниже взят из книг продаж/покупок за это окно.`;
|
||||
return [
|
||||
`- Якорь периода: дата покупки из вопроса/контекста использована для выбора налогового периода ${periodWindowLabel}; сам расчет ниже взят из книг продаж/покупок за это окно.`
|
||||
];
|
||||
}
|
||||
|
||||
function detectRankingLimit(userMessage: string | null | undefined, fallback = 20): number {
|
||||
|
|
@ -1292,14 +1326,85 @@ function hasInventoryAccountPrefix(value: string | null | undefined, prefix: str
|
|||
return normalized === prefix || normalized.startsWith(`${prefix}.`) || normalized.startsWith(prefix);
|
||||
}
|
||||
|
||||
function isInventoryOnHandSnapshotRow(row: ComposeStageRow): boolean {
|
||||
const registrator = String(row.registrator ?? "").trim().toLowerCase();
|
||||
return /(?:остатки\s+на\s+дату|stock\s*on\s*hand|balance\s+as\s+of)/iu.test(registrator);
|
||||
}
|
||||
|
||||
function isInventoryPurchaseMovement(row: ComposeStageRow): boolean {
|
||||
return hasInventoryAccountPrefix(row.account_dt, "41.01");
|
||||
return hasInventoryAccountPrefix(row.account_dt, "41.01") && !isInventoryOnHandSnapshotRow(row);
|
||||
}
|
||||
|
||||
function isInventorySaleMovement(row: ComposeStageRow): boolean {
|
||||
return hasInventoryAccountPrefix(row.account_kt, "41.01");
|
||||
}
|
||||
|
||||
interface InventoryOnHandAgingScope {
|
||||
itemKeys: Set<string>;
|
||||
itemOrgKeys: Set<string>;
|
||||
fullKeys: Set<string>;
|
||||
orgKeys: Set<string>;
|
||||
}
|
||||
|
||||
function inventoryAgingKeyPart(value: string | null | undefined): string {
|
||||
return normalizeEntityToken(value) ?? "";
|
||||
}
|
||||
|
||||
function buildInventoryOnHandAgingScope(rows: ComposeStageRow[]): InventoryOnHandAgingScope {
|
||||
const scope: InventoryOnHandAgingScope = {
|
||||
itemKeys: new Set(),
|
||||
itemOrgKeys: new Set(),
|
||||
fullKeys: new Set(),
|
||||
orgKeys: new Set()
|
||||
};
|
||||
for (const row of rows) {
|
||||
if (!isInventoryOnHandSnapshotRow(row)) {
|
||||
continue;
|
||||
}
|
||||
const quantity = extractInventoryQuantity(row);
|
||||
if (quantity === null || quantity <= 0) {
|
||||
continue;
|
||||
}
|
||||
const itemKey = inventoryAgingKeyPart(extractInventoryItemName(row));
|
||||
if (!itemKey) {
|
||||
continue;
|
||||
}
|
||||
const organizationKey = inventoryAgingKeyPart(extractInventoryOrganizationName(row));
|
||||
const warehouseKey = inventoryAgingKeyPart(extractInventoryWarehouseName(row));
|
||||
scope.itemKeys.add(itemKey);
|
||||
if (organizationKey) {
|
||||
scope.orgKeys.add(organizationKey);
|
||||
scope.itemOrgKeys.add(`${itemKey}|${organizationKey}`);
|
||||
}
|
||||
if (organizationKey || warehouseKey) {
|
||||
scope.fullKeys.add(`${itemKey}|${warehouseKey}|${organizationKey}`);
|
||||
}
|
||||
}
|
||||
return scope;
|
||||
}
|
||||
|
||||
function inventoryPurchaseMatchesOnHandAgingScope(row: ComposeStageRow, scope: InventoryOnHandAgingScope): boolean {
|
||||
if (scope.itemKeys.size === 0) {
|
||||
return true;
|
||||
}
|
||||
const itemKey = inventoryAgingKeyPart(extractInventoryItemName(row));
|
||||
if (!itemKey || !scope.itemKeys.has(itemKey)) {
|
||||
return false;
|
||||
}
|
||||
const organizationKey = inventoryAgingKeyPart(extractInventoryOrganizationName(row));
|
||||
const warehouseKey = inventoryAgingKeyPart(extractInventoryWarehouseName(row));
|
||||
if (scope.fullKeys.has(`${itemKey}|${warehouseKey}|${organizationKey}`)) {
|
||||
return true;
|
||||
}
|
||||
if (organizationKey && scope.itemOrgKeys.has(`${itemKey}|${organizationKey}`)) {
|
||||
return true;
|
||||
}
|
||||
if (organizationKey && scope.orgKeys.size > 0) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function looksLikeInventoryTraceDocumentToken(value: string): boolean {
|
||||
const normalized = String(value ?? "").trim();
|
||||
if (!normalized) {
|
||||
|
|
@ -1526,8 +1631,12 @@ function buildInventoryAgingByItemAggregate(
|
|||
}
|
||||
>();
|
||||
const asOfTimestamp = toUtcDayTimestamp(asOfDate);
|
||||
const onHandScope = buildInventoryOnHandAgingScope(rows);
|
||||
|
||||
for (const row of rows) {
|
||||
if (!isInventoryPurchaseMovement(row) || !inventoryPurchaseMatchesOnHandAgingScope(row, onHandScope)) {
|
||||
continue;
|
||||
}
|
||||
const item = extractInventoryItemName(row);
|
||||
if (!item) {
|
||||
continue;
|
||||
|
|
@ -1613,19 +1722,11 @@ function formatInventoryAgingRows(items: InventoryAgingByItemAggregate[], asOfDa
|
|||
const parts = [
|
||||
`${index + 1}. ${item.item}`,
|
||||
`первая закупка: ${inventoryTraceDateLabel(item.firstPurchasePeriod)}`,
|
||||
`последняя закупка: ${inventoryTraceDateLabel(item.lastPurchasePeriod)}`,
|
||||
`документов: ${formatNumberWithDots(item.documentCount)}`,
|
||||
`операций: ${formatNumberWithDots(item.operations)}`
|
||||
`последняя закупка: ${inventoryTraceDateLabel(item.lastPurchasePeriod)}`
|
||||
];
|
||||
if (item.ageDays !== null) {
|
||||
parts.push(`возраст следа на ${formatDateRu(asOfDate)}: ${formatNumberWithDots(item.ageDays)} дн.`);
|
||||
}
|
||||
if (item.warehouse) {
|
||||
parts.push(`склад: ${item.warehouse}`);
|
||||
}
|
||||
if (item.organization) {
|
||||
parts.push(`организация: ${item.organization}`);
|
||||
}
|
||||
if (item.counterparties.length > 0) {
|
||||
parts.push(`поставщики: ${item.counterparties.slice(0, 3).join("; ")}`);
|
||||
}
|
||||
|
|
@ -3934,16 +4035,30 @@ function composeFactualReplyBody(
|
|||
const formatConfirmedMoney = (value: number): string => (options.useRubCurrency ? formatMoneyRub(value) : formatMoney(value));
|
||||
const organizationLabel = normalizeOrganizationScopeValue(options.organizationHint);
|
||||
const organizationScopeLabel = organizationLabel ? ` по организации ${organizationLabel}` : "";
|
||||
const purchaseDateAnchorLine = buildVatPurchaseDateAnchorDisclosureLine(options, periodWindowLabel);
|
||||
const purchaseDateAnchorLines = buildVatPurchaseDateAnchorDisclosureLines(options, periodWindowLabel);
|
||||
const selectedPurchaseDate = normalizeIsoDateOnly(options.purchaseDateBridge?.selectedPurchaseDate);
|
||||
const hasMultiplePurchaseDates = Boolean(
|
||||
options.purchaseDateBridge?.hasMultiplePurchaseDates ||
|
||||
(normalizeIsoDateOnly(options.purchaseDateBridge?.firstPurchaseDate) &&
|
||||
normalizeIsoDateOnly(options.purchaseDateBridge?.lastPurchaseDate) &&
|
||||
normalizeIsoDateOnly(options.purchaseDateBridge?.firstPurchaseDate) !==
|
||||
normalizeIsoDateOnly(options.purchaseDateBridge?.lastPurchaseDate))
|
||||
);
|
||||
const purchaseBasisLabel = vatPurchaseDateBasisLabel(options.purchaseDateBridge?.basis, hasMultiplePurchaseDates);
|
||||
const directVatLine = selectedPurchaseDate
|
||||
? hasMultiplePurchaseDates
|
||||
? `Коротко: если брать ${purchaseBasisLabel} ${formatDateRu(selectedPurchaseDate)}, подтвержденный НДС к уплате за налоговый период${organizationScopeLabel} — ${formatConfirmedMoney(vatToPay)}.`
|
||||
: `Коротко: по дате покупки ${formatDateRu(selectedPurchaseDate)} подтвержденный НДС к уплате за налоговый период${organizationScopeLabel} — ${formatConfirmedMoney(vatToPay)}.`
|
||||
: `Коротко: подтвержденный НДС к уплате за налоговый период${organizationScopeLabel} — ${formatConfirmedMoney(vatToPay)}.`;
|
||||
|
||||
const lines = [
|
||||
`Коротко: подтвержденный НДС к уплате за налоговый период${organizationScopeLabel} — ${formatConfirmedMoney(vatToPay)}.`,
|
||||
directVatLine,
|
||||
"Расчет сделан по книгам продаж и покупок.",
|
||||
"",
|
||||
"Что вошло в расчет:",
|
||||
...(organizationLabel ? [`- Организация: ${organizationLabel}.`] : []),
|
||||
`- Налоговый период расчета: ${periodWindowLabel ?? "не задан (нужен явный период)"}.`,
|
||||
...(purchaseDateAnchorLine ? [purchaseDateAnchorLine] : []),
|
||||
...purchaseDateAnchorLines,
|
||||
`- НДС по книге продаж: ${formatConfirmedMoney(salesVat)}.`,
|
||||
`- НДС по книге покупок (вычеты): ${formatConfirmedMoney(purchaseVat)}.`,
|
||||
`- Нетто НДС (книга продаж - книга покупок): ${formatConfirmedMoney(netVat)}.`
|
||||
|
|
|
|||
|
|
@ -574,6 +574,24 @@ function hasRelativeYearHint(text: string): boolean {
|
|||
);
|
||||
}
|
||||
|
||||
function resolveMonthPeriodFromIsoDate(isoDate: string | null | undefined): { period_from: string; period_to: string; as_of_date: string } | null {
|
||||
const match = String(isoDate ?? "").match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const year = Number(match[1]);
|
||||
const month = Number(match[2]);
|
||||
if (!Number.isFinite(year) || !Number.isFinite(month) || month < 1 || month > 12) {
|
||||
return null;
|
||||
}
|
||||
const lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate();
|
||||
return {
|
||||
period_from: `${match[1]}-${match[2]}-01`,
|
||||
period_to: `${match[1]}-${match[2]}-${String(lastDay).padStart(2, "0")}`,
|
||||
as_of_date: `${match[1]}-${match[2]}-${String(lastDay).padStart(2, "0")}`
|
||||
};
|
||||
}
|
||||
|
||||
function resolveRelativeMonthPeriodFromInventoryRoot(
|
||||
userMessage: string,
|
||||
followupContext: AddressFollowupContext | null
|
||||
|
|
@ -801,6 +819,36 @@ export function hasInventoryPurchaseDateVatBridgeCue(text: string): boolean {
|
|||
);
|
||||
}
|
||||
|
||||
function hasInventoryPurchaseDateVatBridgeContinuationCue(text: string): boolean {
|
||||
const normalized = textWithRepairedVariant(String(text ?? ""))
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/ё/g, "е");
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
const mentionsPurchase = /(?:покупк|закупк|purchase)/iu.test(normalized);
|
||||
const mentionsFirstOrLast = /(?:перв|последн|earliest|oldest|latest|last)/iu.test(normalized);
|
||||
return mentionsPurchase && mentionsFirstOrLast;
|
||||
}
|
||||
|
||||
function purchaseDateBridgeBasisFromMessage(userMessage: string): string | null {
|
||||
const normalized = textWithRepairedVariant(String(userMessage ?? ""))
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/ё/g, "е");
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
if (/(?:последн|latest|last)/iu.test(normalized) && /(?:покупк|закупк|purchase)/iu.test(normalized)) {
|
||||
return "last_confirmed_purchase";
|
||||
}
|
||||
if (/(?:перв|earliest|oldest)/iu.test(normalized) && /(?:покупк|закупк|purchase)/iu.test(normalized)) {
|
||||
return "first_confirmed_purchase";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function hasInventoryMarginRankingFollowupCue(text: string): boolean {
|
||||
const normalized = textWithRepairedVariant(String(text ?? ""))
|
||||
.toLowerCase()
|
||||
|
|
@ -968,6 +1016,34 @@ function mergeFollowupFilters(
|
|||
): { filters: AddressFilterSet; reasons: string[] } {
|
||||
const merged: AddressFilterSet = { ...current };
|
||||
const reasons: string[] = [];
|
||||
const clearInventoryAgingOrganizationAliasItem = (organizationHint: string | null): void => {
|
||||
if (intent !== "inventory_aging_by_purchase_date") {
|
||||
return;
|
||||
}
|
||||
const explicitItemMention = /(?:^|[\s,.;:!?()\-\u2014])(?:товар(?:у|а|ом)?|позици(?:и|я|ю)|item|row|line)(?=$|[\s,.;:!?()\-\u2014])/iu.test(
|
||||
String(userMessage ?? "")
|
||||
);
|
||||
const agingItem = toNonEmptyString(merged.item);
|
||||
const agingOrganization = toNonEmptyString(merged.organization) ?? organizationHint;
|
||||
const normalizedAgingItem = normalizeOrganizationScopeSearchText(agingItem ?? "");
|
||||
const normalizedAgingOrganization = normalizeOrganizationScopeSearchText(agingOrganization ?? "");
|
||||
const itemLooksLikeOrganizationScope = Boolean(
|
||||
agingItem &&
|
||||
agingOrganization &&
|
||||
(organizationsLikelySameEntity(agingItem, agingOrganization) ||
|
||||
(normalizedAgingItem &&
|
||||
normalizedAgingOrganization &&
|
||||
(normalizedAgingOrganization.includes(normalizedAgingItem) ||
|
||||
normalizedAgingItem.includes(normalizedAgingOrganization))))
|
||||
);
|
||||
if (agingItem && itemLooksLikeOrganizationScope) {
|
||||
delete merged.item;
|
||||
reasons.push("item_cleared_as_organization_scope_alias_for_stock_aging");
|
||||
} else if (agingItem && !explicitItemMention) {
|
||||
delete merged.item;
|
||||
reasons.push("item_cleared_for_stock_slice_aging");
|
||||
}
|
||||
};
|
||||
if (!followupContext) {
|
||||
if (
|
||||
(intent === "list_open_contracts" ||
|
||||
|
|
@ -987,6 +1063,7 @@ function mergeFollowupFilters(
|
|||
);
|
||||
}
|
||||
}
|
||||
clearInventoryAgingOrganizationAliasItem(null);
|
||||
return { filters: merged, reasons };
|
||||
}
|
||||
|
||||
|
|
@ -1001,6 +1078,13 @@ function mergeFollowupFilters(
|
|||
const previousAsOfDate = toNonEmptyString(previous.as_of_date);
|
||||
const previousPeriodFrom = toNonEmptyString(previous.period_from);
|
||||
const previousPeriodTo = toNonEmptyString(previous.period_to);
|
||||
const previousPurchaseDateBridgeSelected = toNonEmptyString(previous.purchase_date_bridge_selected);
|
||||
const previousPurchaseDateBridgeFirst = toNonEmptyString(previous.purchase_date_bridge_first);
|
||||
const previousPurchaseDateBridgeLast = toNonEmptyString(previous.purchase_date_bridge_last);
|
||||
const previousPurchaseDateBridgeBasis = toNonEmptyString(previous.purchase_date_bridge_basis);
|
||||
const previousPurchaseDateBridgeHasMultiple =
|
||||
previous.purchase_date_bridge_has_multiple === true ||
|
||||
String(previous.purchase_date_bridge_has_multiple ?? "").trim().toLowerCase() === "true";
|
||||
const relativeMonthFromInventoryRoot = resolveRelativeMonthPeriodFromInventoryRoot(userMessage, followupContext);
|
||||
const relativeMonthFromFollowupYear = resolveRelativeMonthPeriodFromFollowupYear(userMessage, followupContext);
|
||||
const allTimeRequested = hasAllTimeHint(userMessage);
|
||||
|
|
@ -1020,6 +1104,57 @@ function mergeFollowupFilters(
|
|||
reasons.push("organization_from_followup_context");
|
||||
}
|
||||
|
||||
if (intent === "vat_liability_confirmed_for_tax_period" && previousPurchaseDateBridgeSelected) {
|
||||
const requestedBridgeBasis = purchaseDateBridgeBasisFromMessage(userMessage);
|
||||
const shouldCarryPurchaseDateBridge =
|
||||
!currentHasExplicitTemporalScope &&
|
||||
(Boolean(requestedBridgeBasis) ||
|
||||
hasInventoryPurchaseDateVatBridgeCue(userMessage) ||
|
||||
hasInventoryPurchaseDateVatBridgeContinuationCue(userMessage));
|
||||
if (!shouldCarryPurchaseDateBridge) {
|
||||
delete merged.purchase_date_bridge_selected;
|
||||
delete merged.purchase_date_bridge_first;
|
||||
delete merged.purchase_date_bridge_last;
|
||||
delete merged.purchase_date_bridge_basis;
|
||||
delete merged.purchase_date_bridge_has_multiple;
|
||||
reasons.push(
|
||||
currentHasExplicitTemporalScope
|
||||
? "purchase_date_bridge_suppressed_by_explicit_temporal_scope"
|
||||
: "purchase_date_bridge_not_reused_without_bridge_cue"
|
||||
);
|
||||
} else {
|
||||
const selectedBridgeDate =
|
||||
requestedBridgeBasis === "last_confirmed_purchase"
|
||||
? previousPurchaseDateBridgeLast ?? previousPurchaseDateBridgeSelected
|
||||
: requestedBridgeBasis === "first_confirmed_purchase"
|
||||
? previousPurchaseDateBridgeFirst ?? previousPurchaseDateBridgeSelected
|
||||
: previousPurchaseDateBridgeSelected;
|
||||
merged.purchase_date_bridge_selected = selectedBridgeDate;
|
||||
const selectedBridgeWindow = resolveMonthPeriodFromIsoDate(selectedBridgeDate);
|
||||
if (selectedBridgeWindow) {
|
||||
merged.period_from = selectedBridgeWindow.period_from;
|
||||
merged.period_to = selectedBridgeWindow.period_to;
|
||||
merged.as_of_date = selectedBridgeWindow.as_of_date;
|
||||
reasons.push("period_from_purchase_date_bridge_followup_context");
|
||||
reasons.push("period_to_purchase_date_bridge_followup_context");
|
||||
}
|
||||
if (previousPurchaseDateBridgeFirst) {
|
||||
merged.purchase_date_bridge_first = previousPurchaseDateBridgeFirst;
|
||||
}
|
||||
if (previousPurchaseDateBridgeLast) {
|
||||
merged.purchase_date_bridge_last = previousPurchaseDateBridgeLast;
|
||||
}
|
||||
const effectiveBridgeBasis = requestedBridgeBasis ?? previousPurchaseDateBridgeBasis;
|
||||
if (effectiveBridgeBasis) {
|
||||
merged.purchase_date_bridge_basis = effectiveBridgeBasis;
|
||||
}
|
||||
if (previousPurchaseDateBridgeHasMultiple) {
|
||||
merged.purchase_date_bridge_has_multiple = true;
|
||||
}
|
||||
reasons.push("purchase_date_bridge_from_followup_context");
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
intent === "inventory_on_hand_as_of_date" &&
|
||||
followupContext.previous_intent === "inventory_on_hand_as_of_date" &&
|
||||
|
|
@ -1386,13 +1521,7 @@ function mergeFollowupFilters(
|
|||
reasons.push("period_derived_from_inventory_root_frame_year");
|
||||
}
|
||||
if (intent === "inventory_aging_by_purchase_date") {
|
||||
const explicitItemMention = /(?:^|[\s,.;:!?()\-\u2014])(?:товар(?:у|а|ом)?|позици(?:и|я|ю)|item|row|line)(?=$|[\s,.;:!?()\-\u2014])/iu.test(
|
||||
String(userMessage ?? "")
|
||||
);
|
||||
if (toNonEmptyString(merged.item) && !explicitItemMention) {
|
||||
delete merged.item;
|
||||
reasons.push("item_cleared_for_stock_slice_aging");
|
||||
}
|
||||
clearInventoryAgingOrganizationAliasItem(previousOrganization);
|
||||
}
|
||||
if (
|
||||
!sameDateRequested &&
|
||||
|
|
@ -1646,7 +1775,13 @@ function deriveIntentWithFollowupContext(
|
|||
}
|
||||
|
||||
const normalizedMessage = String(userMessage ?? "");
|
||||
const hasFollowupSignal = hasAddressFollowupContextSignal(normalizedMessage);
|
||||
const genericFollowupSignal = hasAddressFollowupContextSignal(normalizedMessage);
|
||||
const previousFilters = followupContext.previous_filters ?? {};
|
||||
const purchaseBridgeContinuationSignal =
|
||||
followupContext.previous_intent === "vat_liability_confirmed_for_tax_period" &&
|
||||
Boolean(toNonEmptyString(previousFilters.purchase_date_bridge_selected)) &&
|
||||
hasInventoryPurchaseDateVatBridgeContinuationCue(normalizedMessage);
|
||||
const hasFollowupSignal = genericFollowupSignal || purchaseBridgeContinuationSignal;
|
||||
if (!hasFollowupSignal) {
|
||||
return detectedIntent;
|
||||
}
|
||||
|
|
@ -1655,7 +1790,6 @@ function deriveIntentWithFollowupContext(
|
|||
if (!sourceIntent && !fallbackIntent) {
|
||||
return detectedIntent;
|
||||
}
|
||||
const previousFilters = followupContext.previous_filters ?? {};
|
||||
const previousPeriodFrom = toNonEmptyString(previousFilters.period_from);
|
||||
const previousPeriodTo = toNonEmptyString(previousFilters.period_to);
|
||||
const previousContract = toNonEmptyString(previousFilters.contract);
|
||||
|
|
@ -1672,6 +1806,7 @@ function deriveIntentWithFollowupContext(
|
|||
isVatFollowup &&
|
||||
hasSamePeriodHint(normalizedMessage) &&
|
||||
Boolean(previousPeriodFrom || previousPeriodTo);
|
||||
const previousPurchaseDateBridgeSelected = toNonEmptyString(previousFilters.purchase_date_bridge_selected);
|
||||
const previousIsInventoryFamily = isInventoryIntent(sourceIntent ?? undefined);
|
||||
const rootIsInventoryFamily = isInventoryIntent(followupContext.root_intent ?? undefined);
|
||||
const inventoryLineageActive =
|
||||
|
|
@ -1700,8 +1835,13 @@ function deriveIntentWithFollowupContext(
|
|||
) || hasSelectedObjectInlineSnapshotMetadata(normalizedMessage);
|
||||
const staleInventoryLineageCanYieldToCounterparty =
|
||||
previousCounterpartyLaneActive && !hasExplicitInventoryItemReference;
|
||||
const vatPurchaseDateBridgeContinuation =
|
||||
sourceIntent === "vat_liability_confirmed_for_tax_period" &&
|
||||
Boolean(previousPurchaseDateBridgeSelected) &&
|
||||
hasInventoryPurchaseDateVatBridgeContinuationCue(normalizedMessage);
|
||||
const inventoryPurchaseDateVatBridge =
|
||||
inventorySelectedObjectFollowup && hasInventoryPurchaseDateVatBridgeCue(normalizedMessage);
|
||||
(inventorySelectedObjectFollowup && hasInventoryPurchaseDateVatBridgeCue(normalizedMessage)) ||
|
||||
vatPurchaseDateBridgeContinuation;
|
||||
const marginRankingLineageActive =
|
||||
sourceIntent === "inventory_margin_ranking_for_nomenclature" ||
|
||||
fallbackIntent === "inventory_margin_ranking_for_nomenclature" ||
|
||||
|
|
@ -1728,6 +1868,7 @@ function deriveIntentWithFollowupContext(
|
|||
inventoryPurchaseDateVatBridge &&
|
||||
(detectedIntent.intent === "unknown" ||
|
||||
detectedIntent.intent === sourceIntent ||
|
||||
detectedIntent.intent === "account_balance_snapshot" ||
|
||||
detectedIntent.intent === "vat_payable_confirmed_as_of_date" ||
|
||||
detectedIntent.intent === "vat_payable_forecast")
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -134,6 +134,17 @@ function inventoryPartyListOrUnknown(parties: string[]): string {
|
|||
return parties.length > 0 ? parties.slice(0, 4).join("; ") : "не выделен отдельным полем";
|
||||
}
|
||||
|
||||
function normalizeInventoryReplyEntityToken(value: string | null | undefined): string | null {
|
||||
const normalized = String(value ?? "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/ё/gu, "е")
|
||||
.replace(/[^a-zа-я0-9]+/giu, " ")
|
||||
.replace(/\s+/gu, " ")
|
||||
.trim();
|
||||
return normalized || null;
|
||||
}
|
||||
|
||||
function sumInventoryRowAmount(rows: ComposeStageRow[]): number {
|
||||
return rows.reduce((sum, row) => sum + (typeof row.amount === "number" && Number.isFinite(row.amount) ? row.amount : 0), 0);
|
||||
}
|
||||
|
|
@ -550,30 +561,50 @@ export function composeInventoryReply(
|
|||
|
||||
if (intent === "inventory_aging_by_purchase_date") {
|
||||
const asOfDate = deps.resolvePayablesAsOfDate(options);
|
||||
const purchaseRows = rows.filter((row) => deps.isInventoryPurchaseMovement(row));
|
||||
const agingItems = deps.buildInventoryAgingByItemAggregate(rows, asOfDate);
|
||||
const agingItemTokens = new Set(
|
||||
agingItems
|
||||
.map((item) => normalizeInventoryReplyEntityToken(item.item))
|
||||
.filter((item): item is string => Boolean(item))
|
||||
);
|
||||
const purchaseRows = rows.filter((row) => {
|
||||
if (!deps.isInventoryPurchaseMovement(row)) {
|
||||
return false;
|
||||
}
|
||||
if (agingItemTokens.size === 0) {
|
||||
return true;
|
||||
}
|
||||
const item = deps.summarizeInventoryTraceRows([row]).item;
|
||||
const itemToken = normalizeInventoryReplyEntityToken(item);
|
||||
return Boolean(itemToken && agingItemTokens.has(itemToken));
|
||||
});
|
||||
const summary = deps.summarizeInventoryTraceRows(purchaseRows);
|
||||
const agingItems = deps.buildInventoryAgingByItemAggregate(purchaseRows, asOfDate);
|
||||
const oldestPurchaseDate = agingItems[0]?.firstPurchasePeriod ?? summary.firstPeriod;
|
||||
const oldestPurchaseAgeDays = agingItems[0]?.ageDays ?? null;
|
||||
const organizationLabel = agingItems.find((item) => item.organization)?.organization ?? null;
|
||||
const oldestAnswerPreview = agingItems
|
||||
.slice(0, 3)
|
||||
.map((item) => `${item.item} (${deps.inventoryTraceDateLabel(item.firstPurchasePeriod)})`)
|
||||
.join("; ");
|
||||
const directAnswerLine =
|
||||
agingItems.length > 0
|
||||
? `К самым старым закупкам в текущем подтвержденном срезе относятся позиции с самой ранней первой закупкой: ${oldestAnswerPreview}.`
|
||||
: "По доступному закупочному следу позиции со старыми закупками не материализованы.";
|
||||
? `Среди фактических положительных остатков есть давно закупавшиеся позиции: ${oldestAnswerPreview}.`
|
||||
: "В фактическом положительном остатке не найдено позиций с подтвержденным старым закупочным следом.";
|
||||
const lines: string[] = [directAnswerLine];
|
||||
appendInventoryBulletSection(lines, "Сводка:", [
|
||||
const summaryLines = [
|
||||
`Дата среза: ${deps.formatDateRu(asOfDate)}.`,
|
||||
`Самая ранняя первая закупка среди позиций: ${deps.inventoryTraceDateLabel(oldestPurchaseDate)}.`,
|
||||
`Самая поздняя найденная закупка: ${deps.inventoryTraceDateLabel(summary.lastPeriod)}.`,
|
||||
`Позиций в выборке: ${deps.formatNumberWithDots(agingItems.length)}.`,
|
||||
`Закупочных документов: ${deps.formatNumberWithDots(summary.documents.length)}.`,
|
||||
`Закупочных операций: ${deps.formatNumberWithDots(purchaseRows.length)}.`
|
||||
]);
|
||||
];
|
||||
if (organizationLabel) {
|
||||
summaryLines.splice(1, 0, `Организация: ${organizationLabel}.`);
|
||||
}
|
||||
appendInventoryBulletSection(lines, "Сводка:", summaryLines);
|
||||
appendInventoryBulletSection(lines, "Ограничения:", [
|
||||
"Без партионного учета этот ответ показывает возраст закупочного следа по товарной позиции, а не возраст конкретного лота."
|
||||
"Берем только позиции с положительным остатком на дату среза; без партионного учета это возраст закупочного следа по номенклатуре, а не доказанный возраст конкретной партии."
|
||||
]);
|
||||
if (oldestPurchaseAgeDays !== null) {
|
||||
lines.push(`- Между самой ранней первой закупкой и датой среза прошло ${deps.formatNumberWithDots(oldestPurchaseAgeDays)} дн.`);
|
||||
|
|
@ -582,10 +613,10 @@ export function composeInventoryReply(
|
|||
lines.push(`- Поставщики, встречающиеся в наблюдаемом закупочном следе: ${summary.counterparties.slice(0, 4).join("; ")}.`);
|
||||
}
|
||||
if (agingItems.length > 0) {
|
||||
appendInventorySection(lines, "Позиции от самых старых закупок:", deps.formatInventoryAgingRows(agingItems, asOfDate, 12));
|
||||
appendInventorySection(lines, "Позиции от самых старых закупок:", deps.formatInventoryAgingRows(agingItems, asOfDate, 5));
|
||||
} else {
|
||||
appendInventorySection(lines, "Позиции от самых старых закупок:", [
|
||||
"- В доступных данных не найдено закупочных движений для выбранного среза."
|
||||
"- В доступных данных не найдено закупочных движений по позициям, которые есть в положительном остатке на дату среза."
|
||||
]);
|
||||
}
|
||||
return buildFactualSummaryReply(
|
||||
|
|
|
|||
|
|
@ -11,6 +11,13 @@ export interface ComposeFactualReplyOptions<TVatDirectSourceProbe = unknown> {
|
|||
asOfDate?: string;
|
||||
requestedResultMode?: AddressResultMode;
|
||||
vatDirectSourceProbe?: TVatDirectSourceProbe | null;
|
||||
purchaseDateBridge?: {
|
||||
selectedPurchaseDate?: string | null;
|
||||
firstPurchaseDate?: string | null;
|
||||
lastPurchaseDate?: string | null;
|
||||
basis?: string | null;
|
||||
hasMultiplePurchaseDates?: boolean;
|
||||
} | null;
|
||||
emphasizeNumbers?: boolean;
|
||||
useRubCurrency?: boolean;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -120,12 +120,25 @@ function cleanComparisonScopeCompanyLine(line: string, organization: string | nu
|
|||
return clean.trim();
|
||||
}
|
||||
|
||||
function hasComparisonScopeProofCue(value: unknown): boolean {
|
||||
const text = toNullableString(value)?.toLocaleLowerCase("ru-RU").replace(/\s+/gu, " ").trim();
|
||||
if (!text) {
|
||||
return false;
|
||||
}
|
||||
return /(?:\bсравни\b|собери\s+коротк\p{L}*\s+итог|коротк\p{L}*\s+сравн|что\s+.*подтверд\p{L}*.*(?:компан|контрагент|свк)|что\s+.*отдельно\s+по\s+(?:группа\s+свк|выбранн\p{L}*\s+контрагент)|какие\s+выводы\s+можно\s+делать\s+и\s+какие\s+нельзя)/iu.test(
|
||||
text
|
||||
);
|
||||
}
|
||||
|
||||
function buildComparisonScopeProofReply(input: {
|
||||
baseReply: string;
|
||||
debug: Record<string, unknown>;
|
||||
session: unknown;
|
||||
userMessage?: unknown;
|
||||
}): { reply: string; audit: Record<string, unknown> } | null {
|
||||
if (!hasComparisonScopeProofCue(input.userMessage)) {
|
||||
return null;
|
||||
}
|
||||
const entryPoint = toRecordObject(input.debug.assistant_mcp_discovery_entry_point_v1);
|
||||
const turnInput = toRecordObject(entryPoint?.turn_input);
|
||||
const turnMeaning = toRecordObject(turnInput?.turn_meaning_ref);
|
||||
|
|
|
|||
|
|
@ -150,7 +150,7 @@ function hasCompactCashflowFollowupSignal(text: string | null): boolean {
|
|||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
return /(?:\u0431\u0435\u0437\s+\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\p{L}*|\u0431\u0435\u0437\s+\u0440\u0430\u0437\u0431\u0438\u0432\p{L}*|\u0431\u0435\u0437\s+\u0440\u0430\u0437\u0440\u0435\u0437\p{L}*|\u0431\u0435\u0437\s+\u0434\u0435\u0442\u0430\u043b\p{L}*|\u043e\u0434\u043d\u043e\u0439\s+\u0441\u0442\u0440\u043e\u043a\p{L}*|\u0442\u043e\u043b\u044c\u043a\u043e\s+\u0438\u0442\u043e\u0433|\u043f\u0440\u0438\u0448\p{L}*[\s\S]{0,80}\u0443\u0448\p{L}*[\s\S]{0,80}\u043d\u0435\u0442\u0442\u043e|\u043f\u043e\u043b\u0443\u0447\p{L}*[\s\S]{0,80}\u0437\u0430\u043f\u043b\u0430\u0442\p{L}*[\s\S]{0,80}\u0431\u0435\u0437\s+\u0434\u0435\u0442\u0430\u043b)/iu.test(value);
|
||||
return /(?:\u0431\u0435\u0437\s+\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\p{L}*|\u0431\u0435\u0437\s+\u0440\u0430\u0437\u0431\u0438\u0432\p{L}*|\u0431\u0435\u0437\s+\u0440\u0430\u0437\u0440\u0435\u0437\p{L}*|\u0431\u0435\u0437\s+\u0434\u0435\u0442\u0430\u043b\p{L}*|\u043e\u0434\u043d\u043e\u0439\s+\u0441\u0442\u0440\u043e\u043a\p{L}*|\u0442\u043e\u043b\u044c\u043a\u043e\s+\u0438\u0442\u043e\u0433|\u043f\u0440\u0438\u0448\p{L}*[\s\S]{0,80}\u0443\u0448\p{L}*[\s\S]{0,80}\u043d\u0435\u0442\u0442\u043e|\u043f\u043e\u043b\u0443\u0447\p{L}*[\s\S]{0,80}\u0437\u0430\u043f\u043b\u0430\u0442\p{L}*[\s\S]{0,80}\u0431\u0435\u0437\s+\u0434\u0435\u0442\u0430\u043b|кто\s+больше\s+всего\s+зан[её]с|кому\s+больше\s+всего\s+ушл\p{L}*|банк\s+не\s+называй\s+обычн\p{L}*\s+клиент\p{L}*|главн\p{L}*\s+клиент|главн\p{L}*\s+поставщик)/iu.test(value);
|
||||
}
|
||||
|
||||
function dateScopeToFilterWindow(dateScope: string | null): Record<string, string> | null {
|
||||
|
|
@ -169,6 +169,37 @@ function dateScopeToFilterWindow(dateScope: string | null): Record<string, strin
|
|||
return null;
|
||||
}
|
||||
|
||||
function filterWindowFromExtractedFilters(filters: Record<string, unknown> | null): Record<string, string> | null {
|
||||
if (!filters) {
|
||||
return null;
|
||||
}
|
||||
const asOfDate = typeof filters.as_of_date === "string" ? filters.as_of_date.trim() : "";
|
||||
if (/^(?:19|20)\d{2}-\d{2}-\d{2}$/.test(asOfDate)) {
|
||||
return { as_of_date: asOfDate };
|
||||
}
|
||||
const periodFrom = typeof filters.period_from === "string" ? filters.period_from.trim() : "";
|
||||
const periodTo = typeof filters.period_to === "string" ? filters.period_to.trim() : "";
|
||||
if (/^(?:19|20)\d{2}-\d{2}-\d{2}$/.test(periodFrom) && /^(?:19|20)\d{2}-\d{2}-\d{2}$/.test(periodTo)) {
|
||||
return {
|
||||
period_from: periodFrom,
|
||||
period_to: periodTo
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasBoundedFilterWindow(filters: Record<string, unknown> | null): boolean {
|
||||
if (!filters) {
|
||||
return false;
|
||||
}
|
||||
if (typeof filters.as_of_date === "string" && /^(?:19|20)\d{2}-\d{2}-\d{2}$/.test(filters.as_of_date.trim())) {
|
||||
return true;
|
||||
}
|
||||
const periodFrom = typeof filters.period_from === "string" ? filters.period_from.trim() : "";
|
||||
const periodTo = typeof filters.period_to === "string" ? filters.period_to.trim() : "";
|
||||
return /^(?:19|20)\d{2}-\d{2}-\d{2}$/.test(periodFrom) && /^(?:19|20)\d{2}-\d{2}-\d{2}$/.test(periodTo);
|
||||
}
|
||||
|
||||
function looksLikeReliableOrganizationScope(value: string | null): boolean {
|
||||
const text = compactLower(value);
|
||||
if (!text) {
|
||||
|
|
@ -197,12 +228,17 @@ function inferBusinessOverviewDiscoveryContextFromSessionItems(
|
|||
continue;
|
||||
}
|
||||
const turnMeaningRef = toRecordObject(turnInput?.turn_meaning_ref);
|
||||
const filterWindow = dateScopeToFilterWindow(toNonEmptyString(turnMeaningRef?.explicit_date_scope));
|
||||
const debugFilters = toRecordObject(debug?.extracted_filters);
|
||||
const filterWindow =
|
||||
dateScopeToFilterWindow(toNonEmptyString(turnMeaningRef?.explicit_date_scope)) ??
|
||||
filterWindowFromExtractedFilters(debugFilters);
|
||||
if (!filterWindow) {
|
||||
continue;
|
||||
}
|
||||
const previousFilters: Record<string, unknown> = { ...filterWindow };
|
||||
const organization = toNonEmptyString(turnMeaningRef?.explicit_organization_scope);
|
||||
const organization =
|
||||
toNonEmptyString(turnMeaningRef?.explicit_organization_scope) ??
|
||||
toNonEmptyString(debugFilters?.organization);
|
||||
if (looksLikeReliableOrganizationScope(organization)) {
|
||||
previousFilters.organization = organization;
|
||||
}
|
||||
|
|
@ -229,11 +265,7 @@ function mergeBusinessOverviewDateContextForCompactCashflow(input: {
|
|||
return input.followupContext;
|
||||
}
|
||||
const existingFilters = toRecordObject(input.followupContext?.previous_filters);
|
||||
if (
|
||||
input.toNonEmptyString(existingFilters?.period_from) ||
|
||||
input.toNonEmptyString(existingFilters?.period_to) ||
|
||||
input.toNonEmptyString(existingFilters?.as_of_date)
|
||||
) {
|
||||
if (hasBoundedFilterWindow(existingFilters)) {
|
||||
return input.followupContext;
|
||||
}
|
||||
const inferred = inferBusinessOverviewDiscoveryContextFromSessionItems(input.sessionItems, input.toNonEmptyString);
|
||||
|
|
@ -244,12 +276,12 @@ function mergeBusinessOverviewDateContextForCompactCashflow(input: {
|
|||
...inferred,
|
||||
...(input.followupContext ?? {}),
|
||||
previous_filters: {
|
||||
...(toRecordObject(inferred.previous_filters) ?? {}),
|
||||
...(toRecordObject(input.followupContext?.previous_filters) ?? {})
|
||||
...(toRecordObject(input.followupContext?.previous_filters) ?? {}),
|
||||
...(toRecordObject(inferred.previous_filters) ?? {})
|
||||
},
|
||||
root_filters: {
|
||||
...(toRecordObject(inferred.root_filters) ?? {}),
|
||||
...(toRecordObject(input.followupContext?.root_filters) ?? {})
|
||||
...(toRecordObject(input.followupContext?.root_filters) ?? {}),
|
||||
...(toRecordObject(inferred.root_filters) ?? {})
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -281,6 +313,16 @@ function sameEntityHint(expected: string | null, actual: string | null): boolean
|
|||
return left === right || left.includes(right) || right.includes(left);
|
||||
}
|
||||
|
||||
function hasComparisonScopeProofCue(text: string | null): boolean {
|
||||
const value = compactLower(text);
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
return /(?:\bсравни\b|собери\s+коротк\p{L}*\s+итог|коротк\p{L}*\s+сравн|что\s+.*подтверд\p{L}*.*(?:компан|контрагент|свк)|что\s+.*отдельно\s+по\s+(?:группа\s+свк|выбранн\p{L}*\s+контрагент)|какие\s+выводы\s+можно\s+делать\s+и\s+какие\s+нельзя)/iu.test(
|
||||
value
|
||||
);
|
||||
}
|
||||
|
||||
function isBusinessOverviewDiscoveryFollowup(
|
||||
followupContext: Record<string, unknown> | null,
|
||||
toNonEmptyString: BuildAssistantAddressOrchestrationRuntimeInput["toNonEmptyString"]
|
||||
|
|
@ -489,6 +531,7 @@ function mergeBusinessOverviewProofBundlesFromNavigationState(input: {
|
|||
followupContext: Record<string, unknown> | null;
|
||||
sessionAddressNavigationState: unknown;
|
||||
predecomposeContract: Record<string, unknown> | null;
|
||||
userMessage: string;
|
||||
toNonEmptyString: BuildAssistantAddressOrchestrationRuntimeInput["toNonEmptyString"];
|
||||
}): Record<string, unknown> | null {
|
||||
const currentValueBundle = toRecordObject(input.followupContext?.previous_discovery_bidirectional_value_flow);
|
||||
|
|
@ -508,6 +551,9 @@ function mergeBusinessOverviewProofBundlesFromNavigationState(input: {
|
|||
const entities = toRecordObject(input.predecomposeContract?.entities);
|
||||
const hasCurrentOrganizationSelection = Boolean(input.toNonEmptyString(entities?.organization));
|
||||
const businessOverviewFollowup = isBusinessOverviewDiscoveryFollowup(input.followupContext, input.toNonEmptyString);
|
||||
if (!hasComparisonScopeProofCue(input.userMessage)) {
|
||||
return input.followupContext;
|
||||
}
|
||||
if (!businessOverviewFollowup && !hasCurrentOrganizationSelection) {
|
||||
return input.followupContext;
|
||||
}
|
||||
|
|
@ -550,9 +596,13 @@ function mergeBusinessOverviewProofBundlesFromNavigationState(input: {
|
|||
|
||||
function mergeBusinessOverviewProofBundlesFromSessionItems(input: {
|
||||
followupContext: Record<string, unknown> | null;
|
||||
userMessage: string;
|
||||
sessionItems: unknown[];
|
||||
toNonEmptyString: BuildAssistantAddressOrchestrationRuntimeInput["toNonEmptyString"];
|
||||
}): Record<string, unknown> | null {
|
||||
if (!hasComparisonScopeProofCue(input.userMessage)) {
|
||||
return input.followupContext;
|
||||
}
|
||||
if (!isBusinessOverviewDiscoveryFollowup(input.followupContext, input.toNonEmptyString)) {
|
||||
return input.followupContext;
|
||||
}
|
||||
|
|
@ -853,6 +903,7 @@ export async function buildAssistantAddressOrchestrationRuntime(
|
|||
});
|
||||
const discoveryFollowupContextWithProofBundles = mergeBusinessOverviewProofBundlesFromSessionItems({
|
||||
followupContext: discoveryFollowupContext,
|
||||
userMessage: input.userMessage,
|
||||
sessionItems: input.sessionItems,
|
||||
toNonEmptyString: input.toNonEmptyString
|
||||
});
|
||||
|
|
@ -860,6 +911,7 @@ export async function buildAssistantAddressOrchestrationRuntime(
|
|||
followupContext: discoveryFollowupContextWithProofBundles,
|
||||
sessionAddressNavigationState: input.sessionAddressNavigationState,
|
||||
predecomposeContract,
|
||||
userMessage: input.userMessage,
|
||||
toNonEmptyString: input.toNonEmptyString
|
||||
});
|
||||
const dialogContinuationContract = input.buildAddressDialogContinuationContractV2(
|
||||
|
|
|
|||
|
|
@ -188,6 +188,21 @@ function normalizeAddressRuntimeMetaForDeep(
|
|||
};
|
||||
}
|
||||
|
||||
function stripRoutineShortAnswerPrefix(value: string): string {
|
||||
return String(value ?? "")
|
||||
.split(/\r?\n/g)
|
||||
.map((line) =>
|
||||
line
|
||||
.replace(
|
||||
/^((?:\s|\uFEFF)*(?:[-*]\s*)?)(?:\u041a\u043e\u0440\u043e\u0442\u043a\u043e:\s*)+/u,
|
||||
"$1"
|
||||
)
|
||||
.replace(/(:\s*)\u041a\u043e\u0440\u043e\u0442\u043a\u043e:\s*/u, "$1")
|
||||
)
|
||||
.join("\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function runAssistantDeepTurnResponseRuntime(
|
||||
input: RunAssistantDeepTurnResponseRuntimeInput
|
||||
): RunAssistantDeepTurnResponseRuntimeOutput {
|
||||
|
|
@ -243,9 +258,11 @@ export function runAssistantDeepTurnResponseRuntime(
|
|||
currentReplySource: "deep_analysis",
|
||||
addressRuntimeMeta: addressRuntimeMetaForDeep as unknown as Record<string, unknown> | null
|
||||
});
|
||||
const assistantReply = mcpDiscoveryResponsePolicy.applied
|
||||
? mcpDiscoveryResponsePolicy.reply_text
|
||||
: packagingRuntime.safeAssistantReply;
|
||||
const assistantReply = stripRoutineShortAnswerPrefix(
|
||||
mcpDiscoveryResponsePolicy.applied
|
||||
? input.sanitizeReply(mcpDiscoveryResponsePolicy.reply_text, packagingRuntime.safeAssistantReply)
|
||||
: packagingRuntime.safeAssistantReply
|
||||
);
|
||||
const replyType = mcpDiscoveryResponsePolicy.applied ? "partial_coverage" : input.composition.reply_type;
|
||||
const debug = {
|
||||
...packagingRuntime.debug,
|
||||
|
|
|
|||
|
|
@ -87,6 +87,21 @@ function hasInternalMechanics(value: string): boolean {
|
|||
);
|
||||
}
|
||||
|
||||
function stripRoutineShortAnswerPrefix(value: string): string {
|
||||
return String(value ?? "")
|
||||
.split(/\r?\n/g)
|
||||
.map((line) =>
|
||||
line
|
||||
.replace(
|
||||
/^((?:\s|\uFEFF)*(?:[-*]\s*)?)(?:\u041a\u043e\u0440\u043e\u0442\u043a\u043e:\s*)+/u,
|
||||
"$1"
|
||||
)
|
||||
.replace(/(:\s*)\u041a\u043e\u0440\u043e\u0442\u043a\u043e:\s*/u, "$1")
|
||||
)
|
||||
.join("\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function isMcpDiscoveryEntryPointContract(value: unknown): value is AssistantMcpDiscoveryRuntimeEntryPointContract {
|
||||
const record = toRecordObject(value);
|
||||
return (
|
||||
|
|
@ -1052,12 +1067,13 @@ export function applyAssistantMcpDiscoveryResponsePolicy(
|
|||
}
|
||||
|
||||
pushReason(reasonCodes, "mcp_discovery_response_policy_candidate_applied");
|
||||
const replyText = stripRoutineShortAnswerPrefix(String(candidate.reply_text));
|
||||
return {
|
||||
schema_version: ASSISTANT_MCP_DISCOVERY_RESPONSE_POLICY_SCHEMA_VERSION,
|
||||
policy_owner: "assistantMcpDiscoveryResponsePolicy",
|
||||
decision: "apply_candidate",
|
||||
applied: true,
|
||||
reply_text: String(candidate.reply_text),
|
||||
reply_text: replyText || String(candidate.reply_text),
|
||||
reply_source: "mcp_discovery_response_candidate_guarded",
|
||||
candidate,
|
||||
reason_codes: reasonCodes
|
||||
|
|
|
|||
|
|
@ -225,6 +225,9 @@ function isGarbageSemanticAnchorCandidate(value: string | null): boolean {
|
|||
/^(?:и\s+)?кто\s+(?:главн\p{L}*|основн\p{L}*|крупн\p{L}*)\s+(?:клиент|покупател|поставщик|контрагент)(?:\s+в)?$/iu.test(
|
||||
text
|
||||
) ||
|
||||
/^(?:и\s+)?(?:главн\p{L}*|основн\p{L}*|крупн\p{L}*)\s+(?:клиент|покупател|поставщик|контрагент)(?:\s+в)?$/iu.test(
|
||||
text
|
||||
) ||
|
||||
/^(?:или\s+)?(?:обычн\p{L}*\s+)?(?:клиент|поставщик|покупател\p{L}*|заказчик|контрагент)(?:\s+или\s+(?:клиент|поставщик|покупател\p{L}*|заказчик|контрагент))?$/iu.test(
|
||||
text
|
||||
) ||
|
||||
|
|
@ -2458,6 +2461,8 @@ export function buildAssistantMcpDiscoveryTurnInput(
|
|||
? null
|
||||
: explicitCurrentCounterpartyOverridesFollowupEntity
|
||||
? null
|
||||
: businessOverviewSuppressesFollowupCounterparty
|
||||
? rawMetadataScopeHint
|
||||
: rawMetadataScopeHint ??
|
||||
followupSeed.metadataScopeHint ??
|
||||
followupSeed.discoveryEntity ??
|
||||
|
|
@ -2685,7 +2690,7 @@ export function buildAssistantMcpDiscoveryTurnInput(
|
|||
normalizedPredecomposeDateScope &&
|
||||
normalizedPredecomposeDateScope.startsWith(`${rawDateScope}-`)
|
||||
);
|
||||
const followupAllTimeScopeApplied = normalizedFollowupDateScope === "all_time_scope";
|
||||
const followupAllTimeScopeApplied = normalizedFollowupDateScope === "all_time_scope" && !currentTurnCarriesExplicitPeriod;
|
||||
const explicitDateScope =
|
||||
rawAllTimeScopeSignal || followupAllTimeScopeApplied
|
||||
? null
|
||||
|
|
|
|||
|
|
@ -1982,7 +1982,11 @@ function repairAddressMojibake(value) {
|
|||
}
|
||||
function sanitizeOutgoingAssistantText(value, fallback = "Не смог сформировать читаемый ответ. Уточните запрос.") {
|
||||
const repaired = repairAddressMojibake(String(value ?? ""));
|
||||
const sanitized = String((0, answerComposer_1.sanitizeAssistantReplyForUserFacing)(repaired) ?? "").trim();
|
||||
const sanitized = String((0, answerComposer_1.sanitizeAssistantReplyForUserFacing)(repaired) ?? "")
|
||||
.split(/\r?\n/g)
|
||||
.map((line) => line.replace(/^Коротко:\s*/u, "").replace(/(:\s*)Коротко:\s*/u, "$1"))
|
||||
.join("\n")
|
||||
.trim();
|
||||
if (sanitized) {
|
||||
return sanitized;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -301,7 +301,7 @@ export function createAssistantTransitionPolicy(deps) {
|
|||
return `${match[3]}-${match[2]}-${match[1]}`;
|
||||
}
|
||||
|
||||
function computeMonthWindowFromIso(isoDate) {
|
||||
function computeMonthWindowFromIso(isoDate, bridgeFacts = {}) {
|
||||
const match = String(isoDate ?? "").match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||
if (!match) {
|
||||
return null;
|
||||
|
|
@ -318,11 +318,17 @@ export function createAssistantTransitionPolicy(deps) {
|
|||
purchase_date: isoDate,
|
||||
period_from: `${year}-${mm}-01`,
|
||||
period_to: `${year}-${mm}-${dd}`,
|
||||
as_of_date: `${year}-${mm}-${dd}`
|
||||
as_of_date: `${year}-${mm}-${dd}`,
|
||||
purchase_date_bridge_selected: isoDate,
|
||||
...bridgeFacts
|
||||
};
|
||||
}
|
||||
|
||||
function extractEarliestDmyDateFromEntityRefs(entityRefs) {
|
||||
function uniqueSortedIsoDates(dates) {
|
||||
return Array.from(new Set(dates.filter(Boolean))).sort();
|
||||
}
|
||||
|
||||
function extractDmyDatesFromEntityRefs(entityRefs) {
|
||||
const dates = [];
|
||||
for (const entityRef of Array.isArray(entityRefs) ? entityRefs : []) {
|
||||
const value = deps.toNonEmptyString(entityRef?.value);
|
||||
|
|
@ -340,40 +346,96 @@ export function createAssistantTransitionPolicy(deps) {
|
|||
}
|
||||
}
|
||||
}
|
||||
if (dates.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return dates.sort()[0] ?? null;
|
||||
return uniqueSortedIsoDates(dates);
|
||||
}
|
||||
|
||||
function extractPurchaseDateBridgeWindow(previousAddressItem, addressNavigationState) {
|
||||
function detectPurchaseDateBridgeBasis(userMessage, alternateMessage) {
|
||||
const text = [userMessage, alternateMessage]
|
||||
.map((item) => deps.compactWhitespace(deps.repairAddressMojibake(String(item ?? "")).toLowerCase()).replace(/ё/g, "е"))
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
if (/(?:последн|latest|last|сам[а-я\s-]*поздн)/iu.test(text)) {
|
||||
return "last_confirmed_purchase";
|
||||
}
|
||||
if (/(?:перв|earliest|oldest|ранн|сам[а-я\s-]*стар)/iu.test(text)) {
|
||||
return "first_confirmed_purchase";
|
||||
}
|
||||
return "first_confirmed_purchase_default";
|
||||
}
|
||||
|
||||
function extractPurchaseDateBridgeFacts(previousAddressItem, addressNavigationState, userMessage, alternateMessage) {
|
||||
const previousFilters = readAddressDebugFilters(previousAddressItem?.debug ?? null);
|
||||
let firstIsoDate = deps.toNonEmptyString(previousFilters?.purchase_date_bridge_first);
|
||||
let lastIsoDate = deps.toNonEmptyString(previousFilters?.purchase_date_bridge_last);
|
||||
const selectedIsoDateFromFilters = deps.toNonEmptyString(previousFilters?.purchase_date_bridge_selected);
|
||||
if (!firstIsoDate && selectedIsoDateFromFilters) {
|
||||
firstIsoDate = selectedIsoDateFromFilters;
|
||||
}
|
||||
if (!lastIsoDate && selectedIsoDateFromFilters) {
|
||||
lastIsoDate = selectedIsoDateFromFilters;
|
||||
}
|
||||
const replyText = deps.toNonEmptyString(previousAddressItem?.text);
|
||||
if (replyText) {
|
||||
if (replyText && (!firstIsoDate || !lastIsoDate)) {
|
||||
const repairedText = deps.repairAddressMojibake(replyText);
|
||||
const explicitFirstDateMatch = repairedText.match(/первая\s+найденная\s+дата\s+закупки:\s*(\d{2}\.\d{2}\.\d{4})/iu);
|
||||
const explicitFirstDateIso = explicitFirstDateMatch ? parseDmyDateToIso(explicitFirstDateMatch[1]) : null;
|
||||
if (explicitFirstDateIso) {
|
||||
return computeMonthWindowFromIso(explicitFirstDateIso);
|
||||
}
|
||||
const explicitFirstDateMatch = repairedText.match(
|
||||
/первая\s+(?:найденная|подтвержденная)\s+дата\s+закупки:\s*(\d{2}\.\d{2}\.\d{4})/iu
|
||||
);
|
||||
const explicitLastDateMatch = repairedText.match(
|
||||
/последняя\s+(?:найденная|подтвержденная)\s+дата\s+закупки:\s*(\d{2}\.\d{2}\.\d{4})/iu
|
||||
);
|
||||
firstIsoDate = explicitFirstDateMatch ? parseDmyDateToIso(explicitFirstDateMatch[1]) : null;
|
||||
lastIsoDate = explicitLastDateMatch ? parseDmyDateToIso(explicitLastDateMatch[1]) : null;
|
||||
}
|
||||
|
||||
const navigationSessionState = resolveNavigationSessionContextState(
|
||||
addressNavigationState,
|
||||
deps.toNonEmptyString,
|
||||
deps.normalizeOrganizationScopeValue
|
||||
);
|
||||
const focusObject = navigationSessionState.focusObject;
|
||||
const preferredResultSetId =
|
||||
deps.toNonEmptyString(focusObject?.provenanceResultSetId) ?? navigationSessionState.activeResultSetId;
|
||||
const resultSets = Array.isArray(addressNavigationState?.result_sets) ? addressNavigationState.result_sets : [];
|
||||
const preferredResultSet =
|
||||
(preferredResultSetId
|
||||
? resultSets.find((item) => deps.toNonEmptyString(item?.result_set_id) === preferredResultSetId) ?? null
|
||||
: null) ??
|
||||
resultSets.find((item) => deps.toNonEmptyString(item?.intent) === "inventory_purchase_provenance_for_item") ??
|
||||
null;
|
||||
const earliestIsoDate = extractEarliestDmyDateFromEntityRefs(preferredResultSet?.entity_refs);
|
||||
return earliestIsoDate ? computeMonthWindowFromIso(earliestIsoDate) : null;
|
||||
if (!firstIsoDate || !lastIsoDate) {
|
||||
const navigationSessionState = resolveNavigationSessionContextState(
|
||||
addressNavigationState,
|
||||
deps.toNonEmptyString,
|
||||
deps.normalizeOrganizationScopeValue
|
||||
);
|
||||
const focusObject = navigationSessionState.focusObject;
|
||||
const preferredResultSetId =
|
||||
deps.toNonEmptyString(focusObject?.provenanceResultSetId) ?? navigationSessionState.activeResultSetId;
|
||||
const resultSets = Array.isArray(addressNavigationState?.result_sets) ? addressNavigationState.result_sets : [];
|
||||
const preferredResultSet =
|
||||
(preferredResultSetId
|
||||
? resultSets.find((item) => deps.toNonEmptyString(item?.result_set_id) === preferredResultSetId) ?? null
|
||||
: null) ??
|
||||
resultSets.find((item) => deps.toNonEmptyString(item?.intent) === "inventory_purchase_provenance_for_item") ??
|
||||
null;
|
||||
const datesFromResultSet = extractDmyDatesFromEntityRefs(preferredResultSet?.entity_refs);
|
||||
firstIsoDate = firstIsoDate ?? datesFromResultSet[0] ?? null;
|
||||
lastIsoDate = lastIsoDate ?? datesFromResultSet[datesFromResultSet.length - 1] ?? null;
|
||||
}
|
||||
|
||||
if (!firstIsoDate && !lastIsoDate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedFirst = firstIsoDate ?? lastIsoDate;
|
||||
const normalizedLast = lastIsoDate ?? firstIsoDate;
|
||||
const basis = detectPurchaseDateBridgeBasis(userMessage, alternateMessage);
|
||||
const selectedIsoDate = basis === "last_confirmed_purchase" ? normalizedLast : normalizedFirst;
|
||||
return {
|
||||
selectedIsoDate,
|
||||
firstIsoDate: normalizedFirst,
|
||||
lastIsoDate: normalizedLast,
|
||||
basis,
|
||||
hasMultiplePurchaseDates: Boolean(normalizedFirst && normalizedLast && normalizedFirst !== normalizedLast)
|
||||
};
|
||||
}
|
||||
|
||||
function extractPurchaseDateBridgeWindow(previousAddressItem, addressNavigationState, userMessage, alternateMessage) {
|
||||
const facts = extractPurchaseDateBridgeFacts(previousAddressItem, addressNavigationState, userMessage, alternateMessage);
|
||||
if (!facts?.selectedIsoDate) {
|
||||
return null;
|
||||
}
|
||||
return computeMonthWindowFromIso(facts.selectedIsoDate, {
|
||||
purchase_date_bridge_first: facts.firstIsoDate,
|
||||
purchase_date_bridge_last: facts.lastIsoDate,
|
||||
purchase_date_bridge_basis: facts.basis,
|
||||
purchase_date_bridge_has_multiple: facts.hasMultiplePurchaseDates
|
||||
});
|
||||
}
|
||||
|
||||
function isUsableFollowupSourceDebug(debug) {
|
||||
|
|
@ -605,11 +667,20 @@ export function createAssistantTransitionPolicy(deps) {
|
|||
return null;
|
||||
}
|
||||
|
||||
function hasInventoryPurchaseDateVatBridgeSignal(userMessage, alternateMessage, sourceIntentHint, hasInventoryItemFocusHint) {
|
||||
function hasInventoryPurchaseDateVatBridgeSignal(
|
||||
userMessage,
|
||||
alternateMessage,
|
||||
sourceIntentHint,
|
||||
hasInventoryItemFocusHint,
|
||||
sourceHasPurchaseDateBridgeHint = false
|
||||
) {
|
||||
const vatPurchaseBridgeContinuation =
|
||||
sourceIntentHint === "vat_liability_confirmed_for_tax_period" && sourceHasPurchaseDateBridgeHint;
|
||||
if (
|
||||
sourceIntentHint !== "inventory_purchase_provenance_for_item" &&
|
||||
!hasInventoryItemFocusHint &&
|
||||
!deps.isInventorySelectedObjectIntent(sourceIntentHint)
|
||||
!deps.isInventorySelectedObjectIntent(sourceIntentHint) &&
|
||||
!vatPurchaseBridgeContinuation
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
|
@ -621,10 +692,13 @@ export function createAssistantTransitionPolicy(deps) {
|
|||
}
|
||||
return samples.some(
|
||||
(sample) =>
|
||||
/(?:ндс|vat)/iu.test(sample) &&
|
||||
/(?:на\s+дат[ауеы]\s+покупк|на\s+дат[ауеы]\s+закупк|по\s+дат[еу]\s+покупк|по\s+дат[еу]\s+закупк|дата\s+покупк|дата\s+закупк|purchase\s+date)/iu.test(
|
||||
sample
|
||||
)
|
||||
((/(?:ндс|vat)/iu.test(sample) &&
|
||||
/(?:на\s+дат[ауеы]\s+покупк|на\s+дат[ауеы]\s+закупк|по\s+дат[еу]\s+покупк|по\s+дат[еу]\s+закупк|дата\s+покупк|дата\s+закупк|purchase\s+date)/iu.test(
|
||||
sample
|
||||
)) ||
|
||||
((vatPurchaseBridgeContinuation || sourceIntentHint === "vat_liability_confirmed_for_tax_period") &&
|
||||
/(?:перв|последн|earliest|oldest|latest|last)/iu.test(sample) &&
|
||||
/(?:покупк|закупк|purchase)/iu.test(sample)))
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -932,10 +1006,17 @@ export function createAssistantTransitionPolicy(deps) {
|
|||
sourceDiscoveryPilotScopeHint === "business_overview_route_template_v1";
|
||||
const navigationSessionState = earlyNavigationSessionState;
|
||||
const navigationFocusObjectHint = navigationSessionState.focusObject;
|
||||
const sourceFiltersHint = readAddressDebugFilters(carryoverSourceDebug);
|
||||
const sourceHasPurchaseDateBridgeHint = Boolean(
|
||||
deps.toNonEmptyString(sourceFiltersHint?.purchase_date_bridge_selected)
|
||||
);
|
||||
const hasNavigationInventoryItemFocusHint = Boolean(
|
||||
deps.toNonEmptyString(navigationFocusObjectHint?.label) &&
|
||||
deps.toNonEmptyString(navigationFocusObjectHint?.objectType) === "item" &&
|
||||
(sourceIntentHint === "inventory_on_hand_as_of_date" ||
|
||||
sourceIntentHint === "vat_liability_confirmed_for_tax_period" ||
|
||||
sourceIntentHint === "vat_payable_forecast" ||
|
||||
sourceIntentHint === "vat_payable_confirmed_as_of_date" ||
|
||||
sourceIntentHint === "inventory_supplier_stock_overlap_as_of_date" ||
|
||||
deps.isInventorySelectedObjectIntent(sourceIntentHint))
|
||||
);
|
||||
|
|
@ -943,7 +1024,8 @@ export function createAssistantTransitionPolicy(deps) {
|
|||
userMessage,
|
||||
alternateMessage,
|
||||
sourceIntentHint,
|
||||
hasNavigationInventoryItemFocusHint
|
||||
hasNavigationInventoryItemFocusHint,
|
||||
sourceHasPurchaseDateBridgeHint
|
||||
);
|
||||
const inventoryMarginRankingFollowup = hasInventoryMarginRankingFollowupSignal(
|
||||
userMessage,
|
||||
|
|
@ -1457,10 +1539,21 @@ export function createAssistantTransitionPolicy(deps) {
|
|||
readAddressDebugItem(carryoverSourceDebug, deps.toNonEmptyString) ??
|
||||
deps.toNonEmptyString(previousFilters.item)
|
||||
) ?? previousAddressItem;
|
||||
const purchaseBridgeWindow = extractPurchaseDateBridgeWindow(purchaseBridgeItem, addressNavigationState);
|
||||
const purchaseBridgeWindow = extractPurchaseDateBridgeWindow(
|
||||
purchaseBridgeItem,
|
||||
addressNavigationState,
|
||||
userMessage,
|
||||
alternateMessage
|
||||
);
|
||||
if (purchaseBridgeWindow) {
|
||||
previousFilters.period_from = purchaseBridgeWindow.period_from;
|
||||
previousFilters.period_to = purchaseBridgeWindow.period_to;
|
||||
previousFilters.as_of_date = purchaseBridgeWindow.as_of_date;
|
||||
previousFilters.purchase_date_bridge_selected = purchaseBridgeWindow.purchase_date_bridge_selected;
|
||||
previousFilters.purchase_date_bridge_first = purchaseBridgeWindow.purchase_date_bridge_first;
|
||||
previousFilters.purchase_date_bridge_last = purchaseBridgeWindow.purchase_date_bridge_last;
|
||||
previousFilters.purchase_date_bridge_basis = purchaseBridgeWindow.purchase_date_bridge_basis;
|
||||
previousFilters.purchase_date_bridge_has_multiple = purchaseBridgeWindow.purchase_date_bridge_has_multiple;
|
||||
}
|
||||
}
|
||||
previousFilters = applyTemporalCarryoverFilters(
|
||||
|
|
|
|||
|
|
@ -163,6 +163,11 @@ export interface AddressFilterSet {
|
|||
warehouse?: string;
|
||||
document_type?: string;
|
||||
document_ref?: string;
|
||||
purchase_date_bridge_selected?: string;
|
||||
purchase_date_bridge_first?: string;
|
||||
purchase_date_bridge_last?: string;
|
||||
purchase_date_bridge_basis?: string;
|
||||
purchase_date_bridge_has_multiple?: boolean;
|
||||
status?: string;
|
||||
limit?: number;
|
||||
sort?: "period_desc" | "period_asc";
|
||||
|
|
|
|||
|
|
@ -244,7 +244,12 @@ describe("address follow-up temporal regressions", () => {
|
|||
organization: 'ООО "Альтернатива Плюс"',
|
||||
period_from: "2015-02-01",
|
||||
period_to: "2015-02-28",
|
||||
as_of_date: "2016-03-31"
|
||||
as_of_date: "2015-02-28",
|
||||
purchase_date_bridge_selected: "2015-02-05",
|
||||
purchase_date_bridge_first: "2015-02-05",
|
||||
purchase_date_bridge_last: "2015-07-22",
|
||||
purchase_date_bridge_basis: "first_confirmed_purchase_default",
|
||||
purchase_date_bridge_has_multiple: true
|
||||
},
|
||||
previous_anchor_type: "item",
|
||||
previous_anchor_value: "Рабочая станция универсального специалиста"
|
||||
|
|
@ -254,7 +259,98 @@ describe("address follow-up temporal regressions", () => {
|
|||
expect(result?.intent.intent).toBe("vat_liability_confirmed_for_tax_period");
|
||||
expect(result?.filters.extracted_filters.period_from).toBe("2015-02-01");
|
||||
expect(result?.filters.extracted_filters.period_to).toBe("2015-02-28");
|
||||
expect(result?.baseReasons).toContain("period_from_followup_context");
|
||||
expect(result?.filters.extracted_filters.as_of_date).toBe("2015-02-28");
|
||||
expect(result?.filters.extracted_filters.purchase_date_bridge_selected).toBe("2015-02-05");
|
||||
expect(result?.filters.extracted_filters.purchase_date_bridge_has_multiple).toBe(true);
|
||||
expect(result?.baseReasons).toContain("period_from_purchase_date_bridge_followup_context");
|
||||
expect(result?.baseReasons).toContain("purchase_date_bridge_from_followup_context");
|
||||
});
|
||||
|
||||
it("does not reuse purchase-date VAT bridge when the next VAT question has an explicit period", () => {
|
||||
const result = runAddressDecomposeStage("а какой ндс мы должны сгрузить на март 2020?", {
|
||||
previous_intent: "vat_liability_confirmed_for_tax_period",
|
||||
target_intent: "vat_liability_confirmed_for_tax_period",
|
||||
previous_filters: {
|
||||
item: "Рабочая станция универсального специалиста",
|
||||
organization: 'ООО "Альтернатива Плюс"',
|
||||
period_from: "2015-02-01",
|
||||
period_to: "2015-02-28",
|
||||
as_of_date: "2015-02-28",
|
||||
purchase_date_bridge_selected: "2015-02-05",
|
||||
purchase_date_bridge_first: "2015-02-05",
|
||||
purchase_date_bridge_last: "2015-07-22",
|
||||
purchase_date_bridge_basis: "first_confirmed_purchase_default",
|
||||
purchase_date_bridge_has_multiple: true
|
||||
},
|
||||
previous_anchor_type: "organization",
|
||||
previous_anchor_value: 'ООО "Альтернатива Плюс"'
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.intent.intent).toBe("vat_liability_confirmed_for_tax_period");
|
||||
expect(result?.filters.extracted_filters.period_from).toBe("2020-03-01");
|
||||
expect(result?.filters.extracted_filters.period_to).toBe("2020-03-31");
|
||||
expect(result?.filters.extracted_filters.as_of_date).toBeUndefined();
|
||||
expect(result?.filters.extracted_filters.purchase_date_bridge_selected).toBeUndefined();
|
||||
expect(result?.baseReasons).toContain("purchase_date_bridge_suppressed_by_explicit_temporal_scope");
|
||||
expect(result?.baseReasons).not.toContain("period_from_purchase_date_bridge_followup_context");
|
||||
});
|
||||
|
||||
it("keeps first confirmed purchase VAT recalculation in the VAT lane without repeating VAT wording", () => {
|
||||
const result = runAddressDecomposeStage("если у нее несколько закупок, посчитай по первой подтвержденной покупке", {
|
||||
previous_intent: "vat_liability_confirmed_for_tax_period",
|
||||
target_intent: "vat_liability_confirmed_for_tax_period",
|
||||
previous_filters: {
|
||||
organization: 'ООО "Альтернатива Плюс"',
|
||||
period_from: "2015-02-01",
|
||||
period_to: "2015-02-28",
|
||||
as_of_date: "2015-02-28",
|
||||
purchase_date_bridge_selected: "2015-02-05",
|
||||
purchase_date_bridge_first: "2015-02-05",
|
||||
purchase_date_bridge_last: "2015-07-22",
|
||||
purchase_date_bridge_basis: "first_confirmed_purchase_default",
|
||||
purchase_date_bridge_has_multiple: true
|
||||
},
|
||||
previous_anchor_type: "organization",
|
||||
previous_anchor_value: 'ООО "Альтернатива Плюс"'
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.intent.intent).toBe("vat_liability_confirmed_for_tax_period");
|
||||
expect(result?.filters.extracted_filters.purchase_date_bridge_selected).toBe("2015-02-05");
|
||||
expect(result?.filters.extracted_filters.purchase_date_bridge_basis).toBe("first_confirmed_purchase");
|
||||
expect(result?.filters.extracted_filters.period_from).toBe("2015-02-01");
|
||||
expect(result?.filters.extracted_filters.period_to).toBe("2015-02-28");
|
||||
expect(result?.baseReasons).toContain("intent_adjusted_to_inventory_purchase_date_vat_bridge");
|
||||
expect(result?.baseReasons).toContain("purchase_date_bridge_from_followup_context");
|
||||
});
|
||||
|
||||
it("keeps last confirmed purchase VAT recalculation and moves the tax period to the last purchase month", () => {
|
||||
const result = runAddressDecomposeStage("а теперь по последней подтвержденной покупке", {
|
||||
previous_intent: "vat_liability_confirmed_for_tax_period",
|
||||
target_intent: "vat_liability_confirmed_for_tax_period",
|
||||
previous_filters: {
|
||||
organization: 'ООО "Альтернатива Плюс"',
|
||||
period_from: "2015-02-01",
|
||||
period_to: "2015-02-28",
|
||||
as_of_date: "2015-02-28",
|
||||
purchase_date_bridge_selected: "2015-02-05",
|
||||
purchase_date_bridge_first: "2015-02-05",
|
||||
purchase_date_bridge_last: "2015-07-22",
|
||||
purchase_date_bridge_basis: "first_confirmed_purchase",
|
||||
purchase_date_bridge_has_multiple: true
|
||||
},
|
||||
previous_anchor_type: "organization",
|
||||
previous_anchor_value: 'ООО "Альтернатива Плюс"'
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.intent.intent).toBe("vat_liability_confirmed_for_tax_period");
|
||||
expect(result?.filters.extracted_filters.purchase_date_bridge_selected).toBe("2015-07-22");
|
||||
expect(result?.filters.extracted_filters.purchase_date_bridge_basis).toBe("last_confirmed_purchase");
|
||||
expect(result?.filters.extracted_filters.period_from).toBe("2015-07-01");
|
||||
expect(result?.filters.extracted_filters.period_to).toBe("2015-07-31");
|
||||
expect(result?.filters.extracted_filters.as_of_date).toBe("2015-07-31");
|
||||
});
|
||||
it("clears counterparty noise from bare organization clarification selections that resume inventory", () => {
|
||||
const result = runAddressDecomposeStage("АЛЬТЕРНАТИВА", {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,44 @@ import { runAddressDecomposeStage } from "../src/services/address_runtime/decomp
|
|||
import { composeFactualReply } from "../src/services/address_runtime/composeStage";
|
||||
|
||||
describe("inventory aging follow-up", () => {
|
||||
it("clears organization alias item on root old-stock questions with LLM organization semantics", () => {
|
||||
const result = runAddressDecomposeStage(
|
||||
'Есть ли у ООО "Альтернатива Плюс" остатки товара, которые закупались очень давно',
|
||||
null,
|
||||
{
|
||||
scope_target_kind: "organization",
|
||||
scope_target_text: 'ООО "Альтернатива Плюс"',
|
||||
date_scope_kind: "implicit_current",
|
||||
self_scope_detected: false,
|
||||
selected_object_scope_detected: false
|
||||
}
|
||||
);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.intent.intent).toBe("inventory_aging_by_purchase_date");
|
||||
expect(result?.filters.extracted_filters.organization).toBe('ООО "Альтернатива Плюс"');
|
||||
expect(result?.filters.extracted_filters.item).toBeUndefined();
|
||||
expect(result?.baseReasons).toContain("item_cleared_as_organization_scope_alias_for_stock_aging");
|
||||
});
|
||||
|
||||
it("does not mistake an explicit organization name for an inventory item in old-stock questions", () => {
|
||||
const result = runAddressDecomposeStage('Есть ли у ООО "Альтернатива Плюс" остатки товара, которые закупались очень давно', {
|
||||
previous_intent: "inventory_aging_by_purchase_date",
|
||||
previous_filters: {
|
||||
organization: 'ООО "Альтернатива Плюс"',
|
||||
as_of_date: "2026-05-25"
|
||||
},
|
||||
previous_anchor_type: "organization",
|
||||
previous_anchor_value: 'ООО "Альтернатива Плюс"'
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.intent.intent).toBe("inventory_aging_by_purchase_date");
|
||||
expect(result?.filters.extracted_filters.organization).toBe('ООО "Альтернатива Плюс"');
|
||||
expect(result?.filters.extracted_filters.item).toBeUndefined();
|
||||
expect(result?.baseReasons).toContain("item_cleared_as_organization_scope_alias_for_stock_aging");
|
||||
});
|
||||
|
||||
it("keeps carried date window for 'на эту дату' aging follow-up", () => {
|
||||
const result = runAddressDecomposeStage("Какие остатки по товарам на эту дату относятся к старым закупкам", {
|
||||
previous_intent: "inventory_purchase_provenance_for_item",
|
||||
|
|
|
|||
|
|
@ -2182,6 +2182,49 @@ describe("address compose stage utf8 headers", () => {
|
|||
expect(reply.text).toContain("книг продаж/покупок");
|
||||
});
|
||||
|
||||
it("discloses ambiguous inventory purchase-date VAT bridge instead of hiding the selected date", () => {
|
||||
const reply = composeFactualReply(
|
||||
"vat_liability_confirmed_for_tax_period",
|
||||
[
|
||||
{
|
||||
period: "2015-02-28T23:59:59Z",
|
||||
registrator: "VAT_BOOK_SALES",
|
||||
account_dt: "68.02",
|
||||
account_kt: "",
|
||||
amount: 3500000,
|
||||
analytics: []
|
||||
},
|
||||
{
|
||||
period: "2015-02-28T23:59:59Z",
|
||||
registrator: "VAT_BOOK_PURCHASES",
|
||||
account_dt: "19",
|
||||
account_kt: "",
|
||||
amount: 868612,
|
||||
analytics: []
|
||||
}
|
||||
],
|
||||
{
|
||||
userMessage: "ндс можешь прикинуть на дату покупки рабочей станции?",
|
||||
periodFrom: "2015-02-01",
|
||||
periodTo: "2015-02-28",
|
||||
asOfDate: "2015-02-28",
|
||||
organizationHint: "ООО Альтернатива Плюс",
|
||||
purchaseDateBridge: {
|
||||
selectedPurchaseDate: "2015-02-05",
|
||||
firstPurchaseDate: "2015-02-05",
|
||||
lastPurchaseDate: "2015-07-22",
|
||||
basis: "first_confirmed_purchase_default",
|
||||
hasMultiplePurchaseDates: true
|
||||
},
|
||||
useRubCurrency: true
|
||||
}
|
||||
);
|
||||
|
||||
expect(reply.text).toContain("если брать первую подтвержденную дату закупки 05.02.2015");
|
||||
expect(reply.text).toContain("у позиции несколько подтвержденных дат закупки");
|
||||
expect(reply.text).toContain("05.02.2015..22.07.2015");
|
||||
});
|
||||
|
||||
it("formats VAT forecast amounts in rubles and emphasizes numbers when requested", () => {
|
||||
const reply = composeFactualReply(
|
||||
"vat_payable_forecast",
|
||||
|
|
@ -5741,6 +5784,24 @@ it("routes old purchase residue questions to aging-by-purchase-date", () => {
|
|||
expect(plan.query).toContain("ПОДСТРОКА(ЕСТЬNULL(Остатки.Счет.Код, \"\"), 1, 5) = \"41.01\"");
|
||||
});
|
||||
|
||||
it("builds old-stock aging query from positive stock snapshot plus purchase history", () => {
|
||||
const selected = selectAddressRecipe("inventory_aging_by_purchase_date", {
|
||||
organization: 'ООО "Альтернатива Плюс"',
|
||||
as_of_date: "2026-04-18"
|
||||
});
|
||||
expect(selected.selected_recipe?.recipe_id).toBe("address_inventory_aging_by_purchase_date_v1");
|
||||
const plan = buildAddressRecipePlan(selected.selected_recipe!, {
|
||||
organization: 'ООО "Альтернатива Плюс"',
|
||||
as_of_date: "2026-04-18"
|
||||
});
|
||||
|
||||
expect(plan.query).toContain("РегистрБухгалтерии.Хозрасчетный.Остатки(");
|
||||
expect(plan.query).toContain("Документ.ПоступлениеТоваровУслуг.Товары");
|
||||
expect(plan.query).toContain("Остатки.КоличествоРазвернутыйОстатокДт > 0");
|
||||
expect(plan.query).toContain("Товары.Номенклатура В");
|
||||
expect(plan.query).toContain("Товары.Ссылка.Дата <= ДАТАВРЕМЯ(2026, 4, 18, 23, 59, 59)");
|
||||
});
|
||||
|
||||
it("renders confirmed inventory-on-hand snapshot from normalized rows", () => {
|
||||
const reply = composeFactualReply(
|
||||
"inventory_on_hand_as_of_date",
|
||||
|
|
@ -5776,6 +5837,59 @@ it("routes old purchase residue questions to aging-by-purchase-date", () => {
|
|||
expect(reply.semantics?.balance_confirmed).toBe(true);
|
||||
});
|
||||
|
||||
it("renders old-stock aging only for items that are still in positive stock", () => {
|
||||
const reply = composeFactualReply(
|
||||
"inventory_aging_by_purchase_date",
|
||||
[
|
||||
{
|
||||
period: "2015-02-05T00:00:00Z",
|
||||
registrator: "Поступление товаров и услуг 00000000001 от 05.02.2015",
|
||||
account_dt: "41.01",
|
||||
account_kt: "",
|
||||
amount: 1000,
|
||||
analytics: ["Старый проданный товар", 'ООО "Альтернатива Плюс"', "Поставщик А"],
|
||||
item: "Старый проданный товар",
|
||||
organization: 'ООО "Альтернатива Плюс"',
|
||||
counterparty: "Поставщик А",
|
||||
quantity: 1
|
||||
},
|
||||
{
|
||||
period: "2017-03-10T00:00:00Z",
|
||||
registrator: "Поступление товаров и услуг 00000000002 от 10.03.2017",
|
||||
account_dt: "41.01",
|
||||
account_kt: "",
|
||||
amount: 2500,
|
||||
analytics: ["Товар в остатке", 'ООО "Альтернатива Плюс"', "Поставщик Б"],
|
||||
item: "Товар в остатке",
|
||||
organization: 'ООО "Альтернатива Плюс"',
|
||||
counterparty: "Поставщик Б",
|
||||
quantity: 2
|
||||
},
|
||||
{
|
||||
period: "2026-04-18T23:59:59Z",
|
||||
registrator: "Остатки на дату",
|
||||
account_dt: "41.01",
|
||||
account_kt: "",
|
||||
amount: 2500,
|
||||
analytics: ["Товар в остатке", "Основной склад", 'ООО "Альтернатива Плюс"'],
|
||||
item: "Товар в остатке",
|
||||
warehouse: "Основной склад",
|
||||
organization: 'ООО "Альтернатива Плюс"',
|
||||
quantity: 2
|
||||
}
|
||||
],
|
||||
{
|
||||
asOfDate: "2026-04-18",
|
||||
useRubCurrency: true
|
||||
}
|
||||
);
|
||||
|
||||
expect(reply.responseType).toBe("FACTUAL_SUMMARY");
|
||||
expect(reply.text.split("\n")[0]).toContain("Товар в остатке");
|
||||
expect(reply.text).toContain("положительным остатком");
|
||||
expect(reply.text).not.toContain("Старый проданный товар");
|
||||
});
|
||||
|
||||
it("sorts inventory-on-hand positions by amount before rendering the top list", () => {
|
||||
const reply = composeFactualReply(
|
||||
"inventory_on_hand_as_of_date",
|
||||
|
|
|
|||
|
|
@ -270,7 +270,7 @@ describe("address reply builders regressions", () => {
|
|||
);
|
||||
|
||||
const firstLine = result?.text.split("\n")[0] ?? "";
|
||||
expect(firstLine).toContain("К самым старым закупкам");
|
||||
expect(firstLine).toContain("фактических положительных остатков");
|
||||
expect(firstLine).not.toContain("2026-04-18");
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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("Коротко:")
|
||||
})
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -48,6 +48,37 @@ describe("assistant MCP discovery response policy", () => {
|
|||
expect(result.reason_codes).toContain("mcp_discovery_response_policy_candidate_applied");
|
||||
});
|
||||
|
||||
it("removes routine short-answer prefix from applied guarded candidates", () => {
|
||||
const result = applyAssistantMcpDiscoveryResponsePolicy({
|
||||
currentReply: "route is not wired",
|
||||
currentReplySource: "deterministic_unsupported_current_turn_boundary",
|
||||
modeDecisionReason: "unsupported_current_turn_meaning_boundary",
|
||||
addressRuntimeMeta: {
|
||||
mcpDiscoveryRuntimeEntryPoint: entryPoint({
|
||||
bridge: {
|
||||
bridge_status: "answer_draft_ready",
|
||||
user_facing_response_allowed: true,
|
||||
business_fact_answer_allowed: true,
|
||||
requires_user_clarification: false,
|
||||
answer_draft: {
|
||||
answer_mode: "confirmed_with_bounded_inference",
|
||||
headline: "Коротко: Confirmed scoped answer.",
|
||||
confirmed_lines: ["Коротко: Confirmed fact"],
|
||||
inference_lines: [],
|
||||
unknown_lines: [],
|
||||
limitation_lines: [],
|
||||
next_step_line: null
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
expect(result.applied).toBe(true);
|
||||
expect(result.reply_text).toContain("Confirmed scoped answer.");
|
||||
expect(result.reply_text).not.toContain("Коротко:");
|
||||
});
|
||||
|
||||
it("keeps the current reply when the turn is not an unsupported boundary", () => {
|
||||
const result = applyAssistantMcpDiscoveryResponsePolicy({
|
||||
currentReply: "regular chat",
|
||||
|
|
|
|||
|
|
@ -214,6 +214,152 @@ describe("assistantTransitionPolicy", () => {
|
|||
expect(contract.decision).toBe("continue_previous");
|
||||
});
|
||||
|
||||
it("carries explicit purchase-date VAT bridge facts without silently losing ambiguity", () => {
|
||||
const policy = buildPolicy({
|
||||
findLastAddressAssistantItem: () => ({
|
||||
role: "assistant",
|
||||
text: [
|
||||
"По позиции Рабочая станция до 31.03.2016 однозначный поставщик не подтвержден.",
|
||||
"- Первая найденная дата закупки: 05.02.2015.",
|
||||
"- Последняя найденная дата закупки: 22.07.2015."
|
||||
].join("\n"),
|
||||
debug: {
|
||||
detected_intent: "inventory_purchase_provenance_for_item",
|
||||
extracted_filters: {
|
||||
item: "Рабочая станция",
|
||||
organization: 'ООО "Альтернатива Плюс"',
|
||||
as_of_date: "2016-03-31"
|
||||
},
|
||||
anchor_type: "item",
|
||||
anchor_value_resolved: "Рабочая станция"
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
const carryover = policy.resolveAddressFollowupCarryoverContext(
|
||||
"ндс можешь прикинуть на дату покупки рабочей станции?",
|
||||
[],
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
|
||||
expect(carryover?.followupContext?.target_intent).toBe("vat_liability_confirmed_for_tax_period");
|
||||
expect(carryover?.followupContext?.previous_filters).toMatchObject({
|
||||
period_from: "2015-02-01",
|
||||
period_to: "2015-02-28",
|
||||
as_of_date: "2015-02-28",
|
||||
purchase_date_bridge_selected: "2015-02-05",
|
||||
purchase_date_bridge_first: "2015-02-05",
|
||||
purchase_date_bridge_last: "2015-07-22",
|
||||
purchase_date_bridge_basis: "first_confirmed_purchase_default",
|
||||
purchase_date_bridge_has_multiple: true
|
||||
});
|
||||
});
|
||||
|
||||
it("can retarget a follow-up VAT recalculation to the last confirmed purchase date", () => {
|
||||
const items = [
|
||||
{
|
||||
role: "assistant",
|
||||
text: [
|
||||
"По позиции Рабочая станция до 31.03.2016 однозначный поставщик не подтвержден.",
|
||||
"- Первая найденная дата закупки: 05.02.2015.",
|
||||
"- Последняя найденная дата закупки: 22.07.2015."
|
||||
].join("\n"),
|
||||
debug: {
|
||||
detected_intent: "inventory_purchase_provenance_for_item",
|
||||
extracted_filters: {
|
||||
item: "Рабочая станция",
|
||||
organization: 'ООО "Альтернатива Плюс"'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
text: "Коротко: если брать первую подтвержденную дату закупки 05.02.2015, НДС к уплате 100.",
|
||||
debug: {
|
||||
detected_intent: "vat_liability_confirmed_for_tax_period",
|
||||
extracted_filters: {
|
||||
item: "Рабочая станция",
|
||||
organization: 'ООО "Альтернатива Плюс"',
|
||||
purchase_date_bridge_selected: "2015-02-05"
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
const policy = buildPolicy({
|
||||
findLastAddressAssistantItem: (sourceItems: unknown[]) => {
|
||||
const list = sourceItems as Record<string, unknown>[];
|
||||
return list[list.length - 1];
|
||||
}
|
||||
});
|
||||
|
||||
const carryover = policy.resolveAddressFollowupCarryoverContext(
|
||||
"а теперь по последней подтвержденной покупке",
|
||||
items,
|
||||
null,
|
||||
null,
|
||||
{
|
||||
session_context: {
|
||||
active_focus_object: {
|
||||
object_type: "item",
|
||||
label: "Рабочая станция"
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
expect(carryover?.followupContext?.target_intent).toBe("vat_liability_confirmed_for_tax_period");
|
||||
expect(carryover?.followupContext?.previous_filters).toMatchObject({
|
||||
period_from: "2015-07-01",
|
||||
period_to: "2015-07-31",
|
||||
as_of_date: "2015-07-31",
|
||||
purchase_date_bridge_selected: "2015-07-22",
|
||||
purchase_date_bridge_basis: "last_confirmed_purchase"
|
||||
});
|
||||
});
|
||||
|
||||
it("continues purchase-date VAT recalculation from prior VAT bridge filters without relying on active item focus", () => {
|
||||
const policy = buildPolicy({
|
||||
findLastAddressAssistantItem: () => ({
|
||||
role: "assistant",
|
||||
text: "Коротко: если брать первую подтвержденную дату закупки 05.02.2015, НДС к уплате 100.",
|
||||
debug: {
|
||||
detected_intent: "vat_liability_confirmed_for_tax_period",
|
||||
extracted_filters: {
|
||||
organization: 'ООО "Альтернатива Плюс"',
|
||||
period_from: "2015-02-01",
|
||||
period_to: "2015-02-28",
|
||||
as_of_date: "2015-02-28",
|
||||
purchase_date_bridge_selected: "2015-02-05",
|
||||
purchase_date_bridge_first: "2015-02-05",
|
||||
purchase_date_bridge_last: "2015-07-22",
|
||||
purchase_date_bridge_basis: "first_confirmed_purchase_default",
|
||||
purchase_date_bridge_has_multiple: true
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
const carryover = policy.resolveAddressFollowupCarryoverContext(
|
||||
"если у нее несколько закупок, посчитай по первой подтвержденной покупке",
|
||||
[],
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
|
||||
expect(carryover?.followupContext?.target_intent).toBe("vat_liability_confirmed_for_tax_period");
|
||||
expect(carryover?.followupContext?.previous_filters).toMatchObject({
|
||||
period_from: "2015-02-01",
|
||||
period_to: "2015-02-28",
|
||||
as_of_date: "2015-02-28",
|
||||
purchase_date_bridge_selected: "2015-02-05",
|
||||
purchase_date_bridge_basis: "first_confirmed_purchase",
|
||||
purchase_date_bridge_has_multiple: true
|
||||
});
|
||||
});
|
||||
|
||||
it("retargets displayed counterparty follow-up through shared referenced-entity carryover", () => {
|
||||
const policy = buildPolicy({
|
||||
findLastAddressAssistantItem: () => ({
|
||||
|
|
|
|||
Loading…
Reference in New Issue