ЮИ - Перенести настройки ассистента в управление автопрогонами и убрать верхний режим Ассистент

This commit is contained in:
dctouch 2026-04-16 20:18:23 +03:00
parent f1333c457e
commit f3255cb3b8
37 changed files with 2987 additions and 166 deletions

View File

@ -0,0 +1,366 @@
{
"schema_version": "domain_truth_harness_spec_v1",
"scenario_id": "address_truth_harness_test2",
"domain": "address_mixed_followup",
"title": "Exact replay and truth review for test2 mixed follow-up chain",
"description": "Strict sequential replay of the exact user wording from test2.txt with human-answer and technical-debug review.",
"source_export": "C:\\Users\\DCTOUCH\\Desktop\\test2.txt",
"bindings": {},
"steps": [
{
"step_id": "step_01_chat_opening",
"title": "Casual opener",
"question": "йо чо как",
"criticality": "info",
"allowed_reply_types": [
"factual_with_explanation",
"factual"
],
"required_answer_patterns_any": [
"(?i)1с",
"(?i)помогаю",
"(?i)готов"
],
"notes": "Информационный шаг: важно не ломать живой режим, но это не главный бизнес-блокер."
},
{
"step_id": "step_02_receivables_march_2020",
"title": "Receivables at March 2020",
"question": "кто нам должен на март 2020",
"allowed_reply_types": [
"factual"
],
"expected_intents": [
"receivables_confirmed_as_of_date"
],
"expected_capability": "confirmed_receivables_as_of_date",
"expected_result_mode": "confirmed_balance",
"required_filters": {
"as_of_date": "2020-03-31",
"period_from": "2020-03-01",
"period_to": "2020-03-31"
},
"required_direct_answer_patterns_any": [
"31\\.03\\.2020",
"(?i)дебиторск"
],
"notes": "Базовый корневой финансовый вопрос должен отработать точно и задать март 2020 как carryover-якорь."
},
{
"step_id": "step_03_inventory_same_date",
"title": "Inventory on the same date",
"question": "остатки по складу на эту же дату",
"allowed_reply_types": [
"factual"
],
"expected_intents": [
"inventory_on_hand_as_of_date"
],
"expected_capability": "confirmed_inventory_on_hand_as_of_date",
"expected_result_mode": "confirmed_balance",
"required_filters": {
"as_of_date": "{{step_02_receivables_march_2020.filters.as_of_date}}",
"period_from": "{{step_02_receivables_march_2020.filters.period_from}}",
"period_to": "{{step_02_receivables_march_2020.filters.period_to}}"
},
"required_direct_answer_patterns_any": [
"31\\.03\\.2020",
"(?i)на складе"
],
"notes": "Смена контура receivables -> inventory должна сохранить ту же дату, без дополнительного ручного уточнения."
},
{
"step_id": "step_04_selected_item_seller",
"title": "Selected item purchase provenance",
"question": "По выбранному объекту \"Четки Пост (84*117)\": кто продавец",
"allowed_reply_types": [
"factual"
],
"expected_intents": [
"inventory_purchase_provenance_for_item"
],
"required_state_objects": [
"focus_object"
],
"required_carryover_invariants": [
"focus_object"
],
"forbidden_capabilities": [
"confirmed_inventory_on_hand_as_of_date"
],
"forbidden_recipes": [
"address_inventory_on_hand_as_of_date_v1"
],
"forbidden_filter_keys": [
"as_of_date",
"period_from",
"period_to"
],
"forbidden_direct_answer_patterns": [
"^На 31\\.03\\.2020 на складе",
"(?i)^сейчас не дам прямой адресный ответ"
],
"notes": "Выбранная позиция не должна реплеить складской срез; нужен именно ответ про поставщика/продавца."
},
{
"step_id": "step_05_selected_item_sale_trace",
"title": "Selected item buyer",
"question": "По выбранному объекту \"Четки Пост (84*117)\": кому мы продали эту хуйню",
"allowed_reply_types": [
"factual",
"partial_coverage"
],
"allowed_limited_reason_categories": [
"empty_match"
],
"expected_intents": [
"inventory_sale_trace_for_item"
],
"required_state_objects": [
"focus_object"
],
"required_carryover_invariants": [
"focus_object"
],
"forbidden_filter_keys": [
"as_of_date",
"period_from",
"period_to"
],
"forbidden_direct_answer_patterns": [
"(?i)^по текущим условиям в доступном срезе данных совпадений не нашлось",
"(?i)^сейчас не дам прямой адресный ответ"
],
"notes": "След продажи по выбранной позиции не должен быть зажат складским snapshot-окном. Если по live данным подтвержденных продаж нет, честный partial_coverage с empty_match допустим, но только при правильном sale-trace routing и сохраненном focus_object."
},
{
"step_id": "step_06_selected_item_purchase_followup",
"title": "Selected item purchase follow-up",
"question": "а купили у кого известно?",
"allowed_reply_types": [
"factual"
],
"expected_intents": [
"inventory_purchase_provenance_for_item"
],
"required_state_objects": [
"focus_object"
],
"required_carryover_invariants": [
"focus_object"
],
"forbidden_filter_keys": [
"as_of_date",
"period_from",
"period_to"
],
"forbidden_direct_answer_patterns": [
"(?i)^для такого формата запроса нужен более широкий аналитический контур",
"(?i)^сейчас не дам прямой адресный ответ"
],
"notes": "Короткий follow-up после выбранной позиции должен остаться в том же item-contour, а не свалиться в unknown."
},
{
"step_id": "step_07_inventory_july_2019",
"title": "Inventory at July 2019",
"question": "остатки на июль 2019",
"allowed_reply_types": [
"factual"
],
"expected_intents": [
"inventory_on_hand_as_of_date"
],
"expected_capability": "confirmed_inventory_on_hand_as_of_date",
"expected_result_mode": "confirmed_balance",
"required_filters": {
"as_of_date": "2019-07-31",
"period_from": "2019-07-01",
"period_to": "2019-07-31"
},
"required_direct_answer_patterns_any": [
"31\\.07\\.2019",
"(?i)на складе"
],
"forbidden_direct_answer_patterns": [
"(?i)^сейчас не дам прямой адресный ответ"
],
"notes": "После провала provenance-среза система все равно должна уметь коротко вернуть root inventory frame по явному месяцу."
},
{
"step_id": "step_08_inventory_september_2019",
"title": "Inventory at September 2019",
"question": "сентябрь 2019",
"allowed_reply_types": [
"factual"
],
"expected_intents": [
"inventory_on_hand_as_of_date"
],
"expected_capability": "confirmed_inventory_on_hand_as_of_date",
"expected_result_mode": "confirmed_balance",
"required_filters": {
"as_of_date": "2019-09-30",
"period_from": "2019-09-01",
"period_to": "2019-09-30"
},
"required_direct_answer_patterns_any": [
"30\\.09\\.2019",
"(?i)на складе"
],
"forbidden_direct_answer_patterns": [
"(?i)^сейчас не дам прямой адресный ответ"
],
"notes": "Короткое bare-month follow-up должно удерживать складской корень без дополнительной расшифровки."
},
{
"step_id": "step_09_inventory_march_2020",
"title": "Inventory at March 2020",
"question": "март 2020",
"allowed_reply_types": [
"factual"
],
"expected_intents": [
"inventory_on_hand_as_of_date"
],
"expected_capability": "confirmed_inventory_on_hand_as_of_date",
"expected_result_mode": "confirmed_balance",
"required_filters": {
"as_of_date": "2020-03-31",
"period_from": "2020-03-01",
"period_to": "2020-03-31"
},
"required_direct_answer_patterns_any": [
"31\\.03\\.2020",
"(?i)на складе"
],
"forbidden_direct_answer_patterns": [
"(?i)^сейчас не дам прямой адресный ответ"
],
"notes": "Возврат на март 2020 должен снова дать точный складской срез, а не unknown/partial."
},
{
"step_id": "step_10_inventory_same_date_negative_wording",
"title": "Inventory same date with noisy wording",
"question": "остатков на складе нет на эту дату?",
"allowed_reply_types": [
"factual"
],
"expected_intents": [
"inventory_on_hand_as_of_date"
],
"expected_capability": "confirmed_inventory_on_hand_as_of_date",
"expected_result_mode": "confirmed_balance",
"required_filters": {
"as_of_date": "{{step_09_inventory_march_2020.filters.as_of_date}}",
"period_from": "{{step_09_inventory_march_2020.filters.period_from}}",
"period_to": "{{step_09_inventory_march_2020.filters.period_to}}"
},
"forbidden_filter_values": {
"warehouse": [
"нет на эту дату"
]
},
"required_direct_answer_patterns_any": [
"31\\.03\\.2020",
"(?i)на складе"
],
"forbidden_direct_answer_patterns": [
"(?i)^сейчас не дам прямой адресный ответ"
],
"notes": "Разговорная частица `нет на эту дату` не должна становиться warehouse-anchor."
},
{
"step_id": "step_11_vat_same_date",
"title": "VAT on the same date",
"question": "ндс какой надо заплатить на эту же дату",
"allowed_reply_types": [
"factual"
],
"expected_intents": [
"vat_liability_confirmed_for_tax_period"
],
"expected_capability": "confirmed_vat_liability_for_tax_period",
"required_filters": {
"period_from": "{{step_09_inventory_march_2020.filters.period_from}}",
"period_to": "{{step_09_inventory_march_2020.filters.period_to}}"
},
"required_direct_answer_patterns_any": [
"(?i)ндс"
],
"notes": "Pivot inventory -> VAT по `на эту же дату` должен привязаться к марту 2020, а не к мусорному складскому anchor."
},
{
"step_id": "step_12_vat_may_2016",
"title": "VAT at May 2016",
"question": "а на май 2016",
"allowed_reply_types": [
"factual"
],
"expected_intents": [
"vat_liability_confirmed_for_tax_period"
],
"expected_capability": "confirmed_vat_liability_for_tax_period",
"forbidden_filter_values": {
"period_from": [
"{{step_11_vat_same_date.filters.period_from}}"
],
"period_to": [
"{{step_11_vat_same_date.filters.period_to}}"
]
},
"required_answer_patterns_any": [
"2016",
"(?i)налоговый период"
],
"required_direct_answer_patterns_any": [
"(?i)ндс"
],
"notes": "Короткий temporal follow-up внутри VAT-frame должен уйти с марта 2020 на май 2016."
},
{
"step_id": "step_13_receivables_same_date_after_vat_2016",
"title": "Receivables on the carried 2016 date",
"question": "кто нам должен денег на эту дату",
"allowed_reply_types": [
"factual"
],
"expected_intents": [
"receivables_confirmed_as_of_date"
],
"expected_capability": "confirmed_receivables_as_of_date",
"required_filter_within_previous_step_period": {
"as_of_date": "step_12_vat_may_2016"
},
"required_answer_patterns_any": [
"2016"
],
"required_direct_answer_patterns_any": [
"(?i)дебиторск"
],
"forbidden_direct_answer_patterns": [
"31\\.03\\.2020"
],
"notes": "Фраза `на эту дату` после VAT 2016 не должна откатываться обратно на март 2020."
},
{
"step_id": "step_14_receivables_today",
"title": "Receivables today",
"question": "а на сегодня",
"allowed_reply_types": [
"factual"
],
"expected_intents": [
"receivables_confirmed_as_of_date"
],
"expected_capability": "confirmed_receivables_as_of_date",
"required_filters": {
"as_of_date": "{{runtime.today_iso}}"
},
"required_direct_answer_patterns_any": [
"{{runtime.today_dot_regex}}",
"(?i)дебиторск"
],
"notes": "Последний шаг нужен как sanity-check: `на сегодня` должен честно пересчитать уже на текущую дату прогона."
}
]
}

View File

@ -1051,6 +1051,12 @@ function isTemporalWarehousePhrase(candidate) {
.toLowerCase()
.replace(/ё/g, "е")
.trim();
if (/^(?:на\s+)?(?:эту|ту|текущ(?:ую|ая|ий|ее|ей)|сегодняшн(?:юю|ая|ий|ее|ей))(?:\s+же)?\s+дат(?:у|а|е|ой)$/iu.test(normalized)) {
return true;
}
if (/^(?:нет|не\s+было|не\s+было\s+ли)(?:\s+на\s+(?:эту|ту|такую)\s+дат(?:у|е|ой))?$/iu.test(normalized)) {
return true;
}
return /^(?:в|на)\s+(?:январ(?:е|ь)|феврал(?:е|ь)|март(?:е)?|апрел(?:е|ь)|ма(?:й|е)|июн(?:е|ь)|июл(?:е|ь)|август(?:е)?|сентябр(?:е|ь)|октябр(?:е|ь)|ноябр(?:е|ь)|декабр(?:е|ь))(?:\s+\d{4}(?:\s+г(?:\.|ода)?)?)?$/iu.test(normalized);
}
function isLowQualityWarehouseAnchorValue(rawValue) {
@ -1097,7 +1103,8 @@ function isLowQualityWarehouseAnchorValue(rawValue) {
"охуеть",
"пиздец",
"блять",
"бля"
"бля",
"нет"
]);
const tokens = value
.split(/[^a-zа-я0-9]+/iu)
@ -1127,7 +1134,7 @@ function hasImplicitSelfScopeSignal(text) {
}
function isImplicitSelfScopeWarehouseAnchor(candidate) {
const normalized = normalizeSemanticAnchorCandidate(candidate);
return /^(?:у\s+нас|у\s+себя|у\s+меня|наш(?:ем|ей|его|их|а|е)?|сво(?:ем|ей|его|их|я|е)?)$/iu.test(normalized);
return /^(?:у\s+нас|у\s+себя|у\s+меня|наш(?:ем|ей|его|их|а|е)?|сво(?:ем|ей|его|их|я|е)?)(?:\s+(?:висит|висят|висело|висели|лежит|лежат|лежало|лежали))?$/iu.test(normalized);
}
function hasSelectedObjectScopeSignal(text) {
return /(?:по\s+выбранному\s+объекту|selected\s+object)/iu.test(String(text ?? ""));

View File

@ -1039,10 +1039,21 @@ function toNormalizedRows(rows) {
.filter((item) => Boolean(item.period || item.registrator));
}
function rowSearchableText(row) {
return [row.registrator, row.item ?? "", row.warehouse ?? "", row.account_dt ?? "", row.account_kt ?? "", ...row.analytics]
return [
row.registrator,
row.item ?? "",
row.warehouse ?? "",
row.organization ?? "",
row.account_dt ?? "",
row.account_kt ?? "",
...row.analytics
]
.join(" ")
.toLowerCase();
}
function rowOrganizationSearchableText(row) {
return [row.organization ?? "", row.registrator, ...row.analytics].join(" ").toLowerCase();
}
function rowMatchesAnyAccount(row, accountScope) {
if (accountScope.length === 0) {
return true;
@ -1107,9 +1118,12 @@ function applyAddressFilters(rows, filters) {
if (filters.organization && String(filters.organization).trim()) {
const needle = String(filters.organization);
const before = filtered.length;
filtered = filtered.filter((row) => matchesAnchorText(rowSearchableText(row), needle));
if (before > 0 && filtered.length === 0 && mismatchReason === null) {
mismatchReason = "organization_anchor_not_matched_in_materialized_rows";
const organizationMaterialized = filtered.some((row) => Boolean(String(row.organization ?? "").trim()));
if (organizationMaterialized) {
filtered = filtered.filter((row) => matchesAnchorText(rowOrganizationSearchableText(row), needle));
if (before > 0 && filtered.length === 0 && mismatchReason === null) {
mismatchReason = "organization_anchor_not_matched_in_materialized_rows";
}
}
}
if (filters.item && String(filters.item).trim()) {

View File

@ -1114,9 +1114,22 @@ function buildInventoryMovementQuery(filters, resolvedLimit, side) {
: side === "kt"
? creditPredicate
: `(${debitPredicate} ИЛИ ${creditPredicate})`;
const itemFieldPaths = side === "dt"
? ["Движения.СубконтоДт1", "Движения.СубконтоДт2", "Движения.СубконтоДт3"]
: side === "kt"
? ["Движения.СубконтоКт1", "Движения.СубконтоКт2", "Движения.СубконтоКт3"]
: [
"Движения.СубконтоДт1",
"Движения.СубконтоДт2",
"Движения.СубконтоДт3",
"Движения.СубконтоКт1",
"Движения.СубконтоКт2",
"Движения.СубконтоКт3"
];
const itemCondition = buildInventoryItemReferenceCondition(filters, itemFieldPaths);
return INVENTORY_MOVEMENTS_QUERY_TEMPLATE
.replace("__LIMIT__", String(resolvedLimit))
.replace("__WHERE_CLAUSE__", buildWhereClause(filters, "Движения.Период", [inventoryCondition]))
.replace("__WHERE_CLAUSE__", buildWhereClause(filters, "Движения.Период", [inventoryCondition, itemCondition].filter((item) => Boolean(item))))
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort));
}
function buildInventoryItemReferenceCondition(filters, fieldPaths) {
@ -1332,7 +1345,7 @@ function buildAddressRecipePlan(recipe, filters) {
: recipe.query_template === "inventory_supplier_stock_overlap_profile"
? buildInventoryMovementQuery(filters, resolvedLimit, "dt")
: recipe.query_template === "inventory_sale_trace_profile"
? buildInventorySaleDocumentQuery(filters, resolvedLimit)
? buildInventoryMovementQuery(filters, resolvedLimit, "kt")
: recipe.query_template === "inventory_purchase_to_sale_chain_profile"
? buildInventoryMovementQuery(filters, resolvedLimit, "either")
: recipe.query_template === "inventory_aging_by_purchase_date_profile"

View File

@ -31,7 +31,7 @@ function hasAllTimeHint(text) {
return /(?:за\s+вс[её]\s+время|за\s+весь\s+период|за\s+весь\s+срок|за\s+всю\s+истори(?:ю|и)|за\s+любой\s+период|за\s+любой\s+срок|for\s+all\s+time|all\s+time|for\s+entire\s+period|entire\s+period|for\s+any\s+period|any\s+period|for\s+full\s+history|full\s+history)/iu.test(normalized);
}
function hasSameDateHint(text) {
return /(?:на\s+ту\s+же\s+дат[ауеы]|на\s+эту\s+же\s+дат[ауеы]|на\s+эту\s+дат[ауеы]|эту\s+дат[ауеы]|та\s+же\s+дата|same\s+date|as\s+of\s+same\s+date|the\s+same\s+date)/iu.test(String(text ?? ""));
return /(?:на\s+ту\s+же\s+дат[ауеы]|на\s+эту\s+же\s+дат[ауеы]|на\s+эту\s+дат[ауеы]|эту\s+дат[ауеы]|та\s+же\s+дата|дат[ауеы],?\s+котор(?:ую|ая)\s+(?:до\s+этого|раньше|ранее)\s+(?:рассматривали|смотрели)|дат[ауеы],?\s+которая\s+был[ао]?\s+ранее\s+рассмотрен[ао]?|same\s+date|as\s+of\s+same\s+date|the\s+same\s+date|date\s+we\s+looked\s+at\s+before|previously\s+considered\s+date)/iu.test(String(text ?? ""));
}
function hasSamePeriodHint(text) {
return /(?:на\s+тот\s+же\s+период|за\s+тот\s+же\s+период|тот\s+же\s+период(?:\s+рассмотрения)?|на\s+этот\s+же\s+период|за\s+этот\s+же\s+период|аналогичн\w+\s+текущ\w+\s+период\w+|same\s+period|same\s+range|same\s+window)/iu.test(String(text ?? ""));
@ -382,17 +382,49 @@ function resolveRelativeMonthPeriodFromInventoryRoot(userMessage, followupContex
as_of_date: periodTo
};
}
function resolveRelativeMonthPeriodFromFollowupYear(userMessage, followupContext) {
if (!followupContext) {
return null;
}
const month = resolveMonthNumberFromText(userMessage);
if (!month) {
return null;
}
const normalized = String(userMessage ?? "");
if (hasExplicitPeriodLiteral(normalized) || hasExplicitCurrentDateHint(normalized) || hasSameDateHint(normalized)) {
return null;
}
const shortTemporalPatch = getTokenCount(normalized) <= 8 || hasRelativeYearHint(normalized);
if (!shortTemporalPatch) {
return null;
}
const year = resolveYearFromFilters(followupContext.previous_filters) ??
resolveYearFromFilters(followupContext.root_filters);
if (!year) {
return null;
}
const lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate();
const periodFrom = `${year}-${String(month).padStart(2, "0")}-01`;
const periodTo = `${year}-${String(month).padStart(2, "0")}-${String(lastDay).padStart(2, "0")}`;
return {
period_from: periodFrom,
period_to: periodTo,
as_of_date: periodTo
};
}
function shouldRestoreInventoryRootFrame(userMessage, intent, extractedFilters, followupContext) {
if (!followupContext || !isInventoryRootFrameIntent(followupContext.root_intent)) {
return false;
}
const currentFrameKind = followupContext.current_frame_kind ?? null;
const previousIntent = followupContext.previous_intent;
const rootContextOnly = followupContext.root_context_only === true;
const comingFromInventoryDrilldown = currentFrameKind === "inventory_drilldown" || isInventoryDrilldownFrameIntent(previousIntent);
const normalized = String(userMessage ?? "");
const hasInventoryRootRestatementCue = /(?:склад|остат(?:ок|ки)|позици(?:я|и|ю)|товар(?:ы|ов)?|номенклатур)/iu.test(normalized) &&
/(?:покажи|показать|выведи|раскрой|еще\s+раз|ещ[её]\s+раз|снова|опять|верни|вернись|повтори|тот\s+же|этот\s+же|same|again)/iu.test(normalized);
const canReenterInventoryRoot = comingFromInventoryDrilldown ||
rootContextOnly ||
(currentFrameKind === "inventory_root" && hasSamePeriodHint(normalized)) ||
(currentFrameKind === "generic" && hasInventoryRootRestatementCue && hasSamePeriodHint(normalized));
if (!canReenterInventoryRoot) {
@ -401,7 +433,7 @@ function shouldRestoreInventoryRootFrame(userMessage, intent, extractedFilters,
if (intent !== "unknown" && !isInventoryIntent(intent) && !hasInventoryRootRestatementCue) {
return false;
}
if (hasSelectedObjectInventorySignal(normalized) ||
if ((hasSelectedObjectInventorySignal(normalized) && !hasInventoryRootRestatementCue) ||
hasInventorySupplierFollowupCue(normalized) ||
hasInventoryPurchaseDocumentsFollowupCue(normalized) ||
hasInventoryPurchaseDateFollowupCue(normalized) ||
@ -415,6 +447,7 @@ function shouldRestoreInventoryRootFrame(userMessage, intent, extractedFilters,
}
const hasTemporalPatch = hasExplicitPeriodWindow(extractedFilters) ||
Boolean(toNonEmptyString(extractedFilters.as_of_date)) ||
hasSameDateHint(normalized) ||
hasSamePeriodHint(normalized) ||
hasExplicitPeriodLiteral(normalized) ||
Boolean(resolveRelativeMonthPeriodFromInventoryRoot(normalized, followupContext));
@ -544,9 +577,11 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
const previousPeriodFrom = toNonEmptyString(previous.period_from);
const previousPeriodTo = toNonEmptyString(previous.period_to);
const relativeMonthFromInventoryRoot = resolveRelativeMonthPeriodFromInventoryRoot(userMessage, followupContext);
const relativeMonthFromFollowupYear = resolveRelativeMonthPeriodFromFollowupYear(userMessage, followupContext);
const allTimeRequested = hasAllTimeHint(userMessage);
const sameDateRequested = hasSameDateHint(userMessage);
const samePeriodRequested = hasSamePeriodHint(userMessage);
const explicitQuotedItem = extractSelectedObjectItemFromFollowupText(userMessage);
if (!toNonEmptyString(merged.organization) && previousOrganization) {
merged.organization = previousOrganization;
reasons.push("organization_from_followup_context");
@ -654,6 +689,15 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
merged.counterparty = inheritedCounterparty;
reasons.push(currentCounterparty ? "counterparty_replaced_from_followup_context" : "counterparty_from_followup_context");
}
if (intent === "inventory_on_hand_as_of_date" && explicitQuotedItem) {
const currentItem = toNonEmptyString(merged.item);
if (!currentItem ||
currentItem !== explicitQuotedItem ||
(0, addressFilterExtractor_1.isInventoryItemAnchorDegradation)(explicitQuotedItem, currentItem)) {
merged.item = explicitQuotedItem;
reasons.push(currentItem ? "item_replaced_from_explicit_quote" : "item_from_explicit_quote");
}
}
if ((intent === "inventory_purchase_provenance_for_item" ||
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_sale_trace_for_item" ||
@ -661,7 +705,6 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
intent === "inventory_purchase_to_sale_chain" ||
intent === "inventory_aging_by_purchase_date")) {
const inheritedItem = previousItem ?? previousAnchorItem;
const explicitQuotedItem = extractSelectedObjectItemFromFollowupText(userMessage);
const currentItem = toNonEmptyString(merged.item);
const shouldAdoptExplicitQuotedItem = Boolean(explicitQuotedItem) &&
(!currentItem ||
@ -780,6 +823,7 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
}
if (!sameDateRequested &&
hasFollowupSignalForConfirmed &&
!isInventoryLifecycleHistoryIntent(intent) &&
!hasExplicitPeriodLiteral(userMessage) &&
!hasExplicitCurrentDateHint(userMessage)) {
const inheritedAsOfDate = previousAsOfDate ?? previousPeriodTo ?? previousPeriodFrom;
@ -830,10 +874,26 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
intent === "vat_payable_confirmed_as_of_date";
const currentHasPeriod = hasExplicitPeriodWindow(merged);
const previousHasPeriod = hasExplicitPeriodWindow(previous);
const vatRelativeMonthFollowup = relativeMonthFromFollowupYear &&
(intent === "vat_payable_confirmed_as_of_date" ||
intent === "vat_payable_forecast" ||
intent === "vat_liability_confirmed_for_tax_period");
if (vatRelativeMonthFollowup) {
merged.period_from = relativeMonthFromFollowupYear.period_from;
merged.period_to = relativeMonthFromFollowupYear.period_to;
if (intent === "vat_payable_confirmed_as_of_date") {
merged.as_of_date = relativeMonthFromFollowupYear.as_of_date;
}
else if (toNonEmptyString(merged.as_of_date)) {
delete merged.as_of_date;
}
reasons.push("period_derived_from_followup_context_year");
}
if ((intent === "vat_payable_forecast" || intent === "vat_liability_confirmed_for_tax_period") &&
previousHasPeriod &&
hasFollowupSignal &&
!hasExplicitPeriodInMessage) {
!hasExplicitPeriodInMessage &&
!vatRelativeMonthFollowup) {
const currentPeriodFrom = toNonEmptyString(merged.period_from);
const currentPeriodTo = toNonEmptyString(merged.period_to);
const todayIso = new Date().toISOString().slice(0, 10);
@ -852,7 +912,8 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
previousHasPeriod &&
hasFollowupSignal &&
!hasExplicitPeriodInMessage &&
!inventoryLifecycleHistoryIntent) {
!inventoryLifecycleHistoryIntent &&
!vatRelativeMonthFollowup) {
if (previousPeriodFrom) {
merged.period_from = previousPeriodFrom;
}

View File

@ -10,6 +10,18 @@ function hasSelectedObjectInventoryActionCue(text) {
const value = String(text ?? "");
return (/(?:кому[\s\S]{0,80}(?:продал[аи]?|реализова[нлт][а-я]*|поставил[аи]?|поставлен[а-я]*|отгрузил[аи]?|отгружен[а-я]*)|кому\s+был\s+продан|куда[\s\S]{0,80}(?:продал[аи]?|реализова[нлт][а-я]*|поставил[аи]?|поставлен[а-я]*|отгрузил[аи]?|отгружен[а-я]*)|кто[\s\S]{0,40}купил|кто\s+это\s+поставил|кто\s+поставил|у\s+кого\s+купили|у\s+кого\s+куплено|где\s+мы\s+купили|где\s+куплено|по\s+каким\s+документам|какими\s+документами|покажи\s+документы|документы[\s\S]{0,80}(?:по\s+(?:ним|ней|нему|этой\s+позиции|этому\s+товару)|операци)|документы\s+закупки|buyer|sale\s+trace|supplier|vendor|purchase\s+documents|purchase[\s-]?to[\s-]?sale|old\s+purchase|aged\s+stock)/iu.test(value) || (0, inventoryLifecycleCueHelpers_1.hasInventoryProfitabilityCue)(value));
}
function hasShortInventoryPurchaseFollowupCue(text) {
return /(?:^|[\s,.;:!?])(а\s+)?(?:купили\s+у\s+кого|у\s+кого\s+купили|поставщик|продавец|seller)(?:[\s,.;:!?]|$)/iu.test(String(text ?? ""));
}
function isInventorySelectedObjectOrRootIntent(intent) {
return (intent === "inventory_on_hand_as_of_date" ||
intent === "inventory_purchase_provenance_for_item" ||
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_profitability_for_item" ||
intent === "inventory_purchase_to_sale_chain" ||
intent === "inventory_aging_by_purchase_date");
}
function isGenericCanonicalDriftIntent(intent) {
return (intent === "open_items_by_counterparty_or_contract" ||
intent === "customer_revenue_and_payments" ||
@ -19,6 +31,25 @@ function isGenericCanonicalDriftIntent(intent) {
intent === "bank_operations_by_contract" ||
intent === "documents_forming_balance");
}
function hasSameDateFollowupSignal(text) {
return /(?:эту\s+же\s+дат(?:у|е|ой)|ту\s+же\s+дат(?:у|е|ой)|same\s+date)/iu.test(String(text ?? ""));
}
function hasExplicitCurrentDateSignal(text) {
return /(?:текущ(?:ую|ая|ий|ее|ей)\s+дат(?:у|а|е|ой)|сегодняшн(?:юю|ий|ей)\s+дат(?:у|а|е|ой)|today|current\s+date)/iu.test(String(text ?? ""));
}
function hasInventoryTemporalRootFollowupCue(text) {
const value = String(text ?? "").trim().toLowerCase();
if (!value) {
return false;
}
const tokenCount = value.split(/\s+/).filter(Boolean).length;
const hasMonthYearCue = /(?:январ(?:ь|е)|феврал(?:ь|е)|март(?:е)?|апрел(?:ь|е)|ма(?:й|е)|июн(?:ь|е)|июл(?:ь|е)|август(?:е)?|сентябр(?:ь|е)|октябр(?:ь|е)|ноябр(?:ь|е)|декабр(?:ь|е))(?:\s+\d{4})?/iu.test(value) || /\b(?:19|20)\d{2}\b/u.test(value);
if (tokenCount <= 3 && hasMonthYearCue) {
return true;
}
const hasInventoryLexeme = /(?:остат|склад|товар|позици|номенклатур)/iu.test(value);
return hasInventoryLexeme && (hasMonthYearCue || hasSameDateFollowupSignal(value));
}
function shouldPreferRawFollowupMessage(userMessage, addressInputMessage, carryover, addressPreDecompose, toNonEmptyString) {
if (!carryover?.followupContext || typeof carryover.followupContext !== "object") {
return false;
@ -33,12 +64,29 @@ function shouldPreferRawFollowupMessage(userMessage, addressInputMessage, carryo
: null;
const mode = toNonEmptyString(predecomposeContract?.mode) ?? "unknown";
const intent = toNonEmptyString(predecomposeContract?.intent) ?? "unknown";
const followupContext = carryover.followupContext && typeof carryover.followupContext === "object"
? carryover.followupContext
: null;
const previousIntent = toNonEmptyString(followupContext?.previous_intent);
const rootIntent = toNonEmptyString(followupContext?.root_intent);
const previousAnchorType = toNonEmptyString(followupContext?.previous_anchor_type);
const hasInventoryItemCarryover = previousAnchorType === "item" && isInventorySelectedObjectOrRootIntent(previousIntent);
const hasInventoryFrameCarryover = isInventorySelectedObjectOrRootIntent(previousIntent) ||
isInventorySelectedObjectOrRootIntent(rootIntent);
if (mode === "unsupported" && intent === "unknown") {
return true;
}
return (hasSelectedObjectInventorySignal(rawMessage) &&
hasSelectedObjectInventoryActionCue(rawMessage) &&
isGenericCanonicalDriftIntent(intent));
if (hasSameDateFollowupSignal(rawMessage) && hasExplicitCurrentDateSignal(canonicalMessage)) {
return true;
}
if (hasInventoryFrameCarryover &&
hasInventoryTemporalRootFollowupCue(rawMessage) &&
(intent === "account_balance_snapshot" || intent === "documents_forming_balance" || intent === "unknown")) {
return true;
}
return ((hasSelectedObjectInventorySignal(rawMessage) || hasInventoryItemCarryover) &&
(hasSelectedObjectInventoryActionCue(rawMessage) || hasShortInventoryPurchaseFollowupCue(rawMessage)) &&
(isGenericCanonicalDriftIntent(intent) || intent === "unknown"));
}
function fallbackAddressPreDecompose(userMessage, llmProvider, buildAddressLlmPredecomposeContractV1, sanitizeAddressMessageForFallback) {
const provider = llmProvider === "local" ? "local" : llmProvider === "openai" ? "openai" : null;

View File

@ -159,8 +159,16 @@ function createAssistantRoutePolicy(deps) {
hasShortDebtMirrorFollowupSignal(repairedRawUserMessage) ||
hasShortDebtMirrorFollowupSignal(effectiveAddressUserMessage) ||
hasShortDebtMirrorFollowupSignal(repairedEffectiveAddressUserMessage);
const followupPreviousIntent = toNonEmptyString(followupContext?.previous_intent);
const followupPreviousFilters = followupContext?.previous_filters && typeof followupContext.previous_filters === "object"
? followupContext.previous_filters
: null;
const protectedInventoryShortFollowup = Boolean(followupContext &&
isInventorySelectedObjectIntent(toNonEmptyString(followupContext.previous_intent)) &&
(isInventorySelectedObjectIntent(followupPreviousIntent) ||
(followupPreviousIntent === "inventory_on_hand_as_of_date" &&
(toNonEmptyString(followupContext.previous_anchor_type) === "item" ||
toNonEmptyString(followupContext.previous_anchor_value) ||
toNonEmptyString(followupPreviousFilters?.item)))) &&
(hasShortInventoryObjectFollowupSignal(rawUserMessage) ||
hasShortInventoryObjectFollowupSignal(repairedRawUserMessage) ||
hasShortInventoryObjectFollowupSignal(effectiveAddressUserMessage) ||

View File

@ -2799,8 +2799,11 @@ function hasShortInventoryObjectFollowupSignal(userMessage) {
if (minTokens > 8) {
return false;
}
const hasDirectPurchaseFollowupCue = (sample) => /(?:^|[\s,.;:!?])(?:а\s+)?(?:у|от)\s+кого(?:\s+\S+){0,5}\s+купил(?:и|о)?(?=$|[\s,.;:!?])/iu.test(sample) ||
/(?:^|[\s,.;:!?])(?:а\s+)?купил(?:и|о)?(?:\s+\S+){0,5}\s+(?:у|от)\s+кого(?=$|[\s,.;:!?])/iu.test(sample);
const hasDirectSaleFollowupCue = (sample) => /(?:кому|каму|куда)(?:\s+\S+){0,4}\s+(?:продали|продано|продан(?:о|а|ы)?|реализовали|реализован(?:о|а|ы)?)|(?:продали|продано|реализовали|реализован(?:о|а|ы)?)(?:\s+\S+){0,4}\s+(?:кому|каму|куда)|(?:^|\s)(?:продано|продали|реализовано|реализовали)(?=$|[\s,.;:!?])/iu.test(sample);
return samples.some((sample) => /^(?:кто|когда|документы|сумма|поставщик|покупатель)(?:\?)?$/iu.test(sample) ||
return samples.some((sample) => /^(?:кто|когда|документы|сумма|поставщик|покупатель|продавец|seller)(?:\?)?$/iu.test(sample) ||
hasDirectPurchaseFollowupCue(sample) ||
hasDirectSaleFollowupCue(sample) ||
(0, decomposeStage_1.hasInventorySupplierFollowupCue)(sample) ||
(0, decomposeStage_1.hasInventoryPurchaseDocumentsFollowupCue)(sample) ||
@ -2809,6 +2812,41 @@ function hasShortInventoryObjectFollowupSignal(userMessage) {
(0, decomposeStage_1.hasInventorySaleFollowupCue)(sample) ||
(0, decomposeStage_1.hasInventoryPurchaseToSaleChainFollowupCue)(sample));
}
function hasInventoryRootTemporalFollowupSignal(userMessage, sourceIntentHint, hasInventoryRootFrame) {
if (!hasInventoryRootFrame) {
return false;
}
if (!(sourceIntentHint === "inventory_on_hand_as_of_date" ||
sourceIntentHint === "inventory_supplier_stock_overlap_as_of_date" ||
isInventorySelectedObjectIntent(sourceIntentHint))) {
return false;
}
const rawText = compactWhitespace(String(userMessage ?? "").toLowerCase());
const repairedText = compactWhitespace(repairAddressMojibake(String(userMessage ?? "")).toLowerCase());
const samples = [rawText, repairedText].filter((item) => item.length > 0);
if (samples.length === 0) {
return false;
}
const minTokens = samples.reduce((min, sample) => Math.min(min, countTokens(sample)), Number.POSITIVE_INFINITY);
if (minTokens > 8) {
return false;
}
const hasMonthYearCue = samples.some((sample) => /(?:январ|феврал|март|апрел|ма(?:й|е|я)|июн|июл|август|сентябр|октябр|ноябр|декабр)(?:\s+\d{4})?/iu.test(sample) ||
/\b(?:19|20)\d{2}\b/u.test(sample));
const hasTemporalCue = samples.some((sample) => hasPeriodLiteral(sample) ||
hasShortNamedPeriodFollowupLiteral(sample) ||
hasShortCurrentDateFollowupLiteral(sample) ||
/(?:на\s+ту\s+же\s+дат[ауеы]|на\s+эту\s+же\s+дат[ауеы]|same\s+date|the\s+same\s+date)/iu.test(sample));
if (!hasTemporalCue && !hasMonthYearCue) {
return false;
}
if (samples.some((sample) => hasForeignAccountingPivotOverInventoryMessage(sample))) {
return false;
}
const hasInventoryLexeme = samples.some((sample) => /(?:РѕСЃСР°С|склад|Совар|номенклаС|РїРѕР·РёС)/iu.test(sample));
const hasPlainInventoryLexeme = samples.some((sample) => /(?:остат|склад|товар|номенклатур|позиц)/iu.test(sample));
return hasInventoryLexeme || hasPlainInventoryLexeme || minTokens <= 3;
}
function hasForeignAccountingPivotOverInventoryMessage(userMessage, alternateMessage = null) {
const samples = [
compactWhitespace(repairAddressMojibake(String(userMessage ?? "")).toLowerCase()),
@ -2890,8 +2928,22 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
(isImplicitAddressContinuationByLlm(userMessage, llmPreDecomposeMeta) ||
(toNonEmptyString(alternateMessage) ? isImplicitAddressContinuationByLlm(alternateMessage, llmPreDecomposeMeta) : false));
const sourceIntentHint = toNonEmptyString(previousAddressDebug?.detected_intent);
const inventoryShortFollowupPrimary = isInventorySelectedObjectIntent(sourceIntentHint) && hasShortInventoryObjectFollowupSignal(userMessage);
const inventoryShortFollowupAlternate = isInventorySelectedObjectIntent(sourceIntentHint) && toNonEmptyString(alternateMessage)
const navigationFocusObjectHint = addressNavigationState &&
typeof addressNavigationState === "object" &&
addressNavigationState.session_context &&
typeof addressNavigationState.session_context === "object" &&
addressNavigationState.session_context.active_focus_object &&
typeof addressNavigationState.session_context.active_focus_object === "object"
? addressNavigationState.session_context.active_focus_object
: null;
const hasNavigationInventoryItemFocusHint = Boolean(toNonEmptyString(navigationFocusObjectHint?.label) &&
toNonEmptyString(navigationFocusObjectHint?.object_type) === "item" &&
(sourceIntentHint === "inventory_on_hand_as_of_date" ||
sourceIntentHint === "inventory_supplier_stock_overlap_as_of_date" ||
isInventorySelectedObjectIntent(sourceIntentHint)));
let inventoryShortFollowupPrimary = (isInventorySelectedObjectIntent(sourceIntentHint) || hasNavigationInventoryItemFocusHint) &&
hasShortInventoryObjectFollowupSignal(userMessage);
let inventoryShortFollowupAlternate = (isInventorySelectedObjectIntent(sourceIntentHint) || hasNavigationInventoryItemFocusHint) && toNonEmptyString(alternateMessage)
? hasShortInventoryObjectFollowupSignal(String(alternateMessage ?? ""))
: false;
const debtRoleSwapPrimary = sourceIntentHint ? resolveDebtRoleSwapFollowupIntent(userMessage, sourceIntentHint) : null;
@ -2899,8 +2951,8 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
? resolveDebtRoleSwapFollowupIntent(String(alternateMessage ?? ""), sourceIntentHint)
: null;
const debtRoleSwapIntent = debtRoleSwapPrimary ?? debtRoleSwapAlternate ?? null;
const hasPrimaryFollowupSignal = hasAddressFollowupContextSignal(userMessage) || Boolean(debtRoleSwapPrimary) || inventoryShortFollowupPrimary;
const hasAlternateFollowupSignal = toNonEmptyString(alternateMessage)
let hasPrimaryFollowupSignal = hasAddressFollowupContextSignal(userMessage) || Boolean(debtRoleSwapPrimary) || inventoryShortFollowupPrimary;
let hasAlternateFollowupSignal = toNonEmptyString(alternateMessage)
? hasAddressFollowupContextSignal(alternateMessage) || Boolean(debtRoleSwapAlternate) || inventoryShortFollowupAlternate
: false;
const hasPrimaryIndexReferenceSignal = extractDisplayedEntityIndexMention(userMessage) !== null;
@ -2908,12 +2960,19 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
? extractDisplayedEntityIndexMention(String(alternateMessage ?? "")) !== null
: false;
const hasIndexReferenceSignal = hasPrimaryIndexReferenceSignal || hasAlternateIndexReferenceSignal;
const hasStrongFollowupReference = hasPrimaryIndexReferenceSignal ||
const recentInventoryRootFrame = findRecentInventoryRootFrame(items);
const hasInventoryRootTemporalFollowupPrimary = hasInventoryRootTemporalFollowupSignal(userMessage, sourceIntentHint, Boolean(recentInventoryRootFrame));
const hasInventoryRootTemporalFollowupAlternate = toNonEmptyString(alternateMessage)
? hasInventoryRootTemporalFollowupSignal(String(alternateMessage ?? ""), sourceIntentHint, Boolean(recentInventoryRootFrame))
: false;
let hasStrongFollowupReference = hasPrimaryIndexReferenceSignal ||
hasAlternateIndexReferenceSignal ||
hasOrganizationClarificationContinuation ||
hasImplicitContinuationSignal ||
inventoryShortFollowupPrimary ||
inventoryShortFollowupAlternate ||
hasInventoryRootTemporalFollowupPrimary ||
hasInventoryRootTemporalFollowupAlternate ||
Boolean(debtRoleSwapIntent) ||
hasFollowupMarker(userMessage) ||
hasReferentialPointer(userMessage) ||
@ -2925,6 +2984,8 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
if (hasStandaloneAddressTopic &&
!hasPrimaryFollowupSignal &&
!hasAlternateFollowupSignal &&
!hasInventoryRootTemporalFollowupPrimary &&
!hasInventoryRootTemporalFollowupAlternate &&
!hasImplicitContinuationSignal &&
!hasOrganizationClarificationContinuation &&
!hasIndexReferenceSignal) {
@ -2932,6 +2993,8 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
}
if (!hasPrimaryFollowupSignal &&
!hasAlternateFollowupSignal &&
!hasInventoryRootTemporalFollowupPrimary &&
!hasInventoryRootTemporalFollowupAlternate &&
!hasImplicitContinuationSignal &&
!hasOrganizationClarificationContinuation &&
!hasIndexReferenceSignal) {
@ -2993,6 +3056,41 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
: null;
const navigationFocusObjectType = toNonEmptyString(navigationFocusObject?.object_type);
const navigationFocusObjectLabel = toNonEmptyString(navigationFocusObject?.label);
const hasInventoryItemFocusCarryover = navigationFocusObjectType === "item" &&
navigationFocusObjectLabel &&
(sourceIntentHint === "inventory_on_hand_as_of_date" ||
sourceIntentHint === "inventory_supplier_stock_overlap_as_of_date" ||
isInventorySelectedObjectIntent(sourceIntentHint));
if (!inventoryShortFollowupPrimary && hasInventoryItemFocusCarryover) {
inventoryShortFollowupPrimary = hasShortInventoryObjectFollowupSignal(userMessage);
}
if (!inventoryShortFollowupAlternate && hasInventoryItemFocusCarryover && toNonEmptyString(alternateMessage)) {
inventoryShortFollowupAlternate = hasShortInventoryObjectFollowupSignal(String(alternateMessage ?? ""));
}
hasPrimaryFollowupSignal = hasAddressFollowupContextSignal(userMessage) ||
Boolean(debtRoleSwapPrimary) ||
inventoryShortFollowupPrimary ||
hasInventoryRootTemporalFollowupPrimary;
hasAlternateFollowupSignal = toNonEmptyString(alternateMessage)
? hasAddressFollowupContextSignal(alternateMessage) ||
Boolean(debtRoleSwapAlternate) ||
inventoryShortFollowupAlternate ||
hasInventoryRootTemporalFollowupAlternate
: false;
hasStrongFollowupReference = hasPrimaryIndexReferenceSignal ||
hasAlternateIndexReferenceSignal ||
hasOrganizationClarificationContinuation ||
hasImplicitContinuationSignal ||
inventoryShortFollowupPrimary ||
inventoryShortFollowupAlternate ||
hasInventoryRootTemporalFollowupPrimary ||
hasInventoryRootTemporalFollowupAlternate ||
Boolean(debtRoleSwapIntent) ||
hasFollowupMarker(userMessage) ||
hasReferentialPointer(userMessage) ||
(toNonEmptyString(alternateMessage)
? hasFollowupMarker(String(alternateMessage ?? "")) || hasReferentialPointer(String(alternateMessage ?? ""))
: false);
const hasSelectedObjectInventorySignalPrimary = /(?:по\s+выбранному\s+объекту|по\s+этой\s+позиции|по\s+этому\s+товару|selected\s+object)/iu.test(String(userMessage ?? ""));
const hasSelectedObjectInventorySignalAlternate = toNonEmptyString(alternateMessage)
? /(?:по\s+выбранному\s+объекту|по\s+этой\s+позиции|по\s+этому\s+товару|selected\s+object)/iu.test(String(alternateMessage ?? ""))
@ -3054,18 +3152,39 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
if (!toNonEmptyString(previousFilters.organization) && organizationClarificationSelection) {
previousFilters.organization = organizationClarificationSelection;
}
if (!toNonEmptyString(previousFilters.as_of_date) && toNonEmptyString(navigationDateScope?.as_of_date)) {
const shouldBackfillPreviousDateScopeFromNavigation = sourceIntentHint === "inventory_on_hand_as_of_date" ||
sourceIntentHint === "inventory_supplier_stock_overlap_as_of_date" ||
sourceIntentHint === "inventory_purchase_provenance_for_item" ||
sourceIntentHint === "inventory_purchase_documents_for_item" ||
sourceIntentHint === "inventory_sale_trace_for_item" ||
sourceIntentHint === "inventory_profitability_for_item" ||
sourceIntentHint === "inventory_purchase_to_sale_chain" ||
sourceIntentHint === "inventory_aging_by_purchase_date" ||
sourceIntentHint === "account_balance_snapshot" ||
sourceIntentHint === "documents_forming_balance";
if (shouldBackfillPreviousDateScopeFromNavigation &&
!toNonEmptyString(previousFilters.as_of_date) &&
toNonEmptyString(navigationDateScope?.as_of_date)) {
previousFilters.as_of_date = toNonEmptyString(navigationDateScope?.as_of_date);
}
if (!toNonEmptyString(previousFilters.period_from) && toNonEmptyString(navigationDateScope?.period_from)) {
if (shouldBackfillPreviousDateScopeFromNavigation &&
!toNonEmptyString(previousFilters.period_from) &&
toNonEmptyString(navigationDateScope?.period_from)) {
previousFilters.period_from = toNonEmptyString(navigationDateScope?.period_from);
}
if (!toNonEmptyString(previousFilters.period_to) && toNonEmptyString(navigationDateScope?.period_to)) {
if (shouldBackfillPreviousDateScopeFromNavigation &&
!toNonEmptyString(previousFilters.period_to) &&
toNonEmptyString(navigationDateScope?.period_to)) {
previousFilters.period_to = toNonEmptyString(navigationDateScope?.period_to);
}
const rootContextOnlyPivot = Boolean((isInventorySelectedObjectIntent(sourceIntentHint) || currentFrameKind === "inventory_drilldown") &&
hasForeignAccountingPivotOverInventoryMessage(userMessage, alternateMessage));
if (rootContextOnlyPivot) {
const inventoryRootTemporalPivot = Boolean(inventoryRootFrame &&
(isInventorySelectedObjectIntent(sourceIntentHint) || currentFrameKind === "inventory_drilldown") &&
(hasInventoryRootTemporalFollowupPrimary || hasInventoryRootTemporalFollowupAlternate) &&
!hasForeignAccountingPivotOverInventoryMessage(userMessage, alternateMessage));
const rootScopedPivot = rootContextOnlyPivot || inventoryRootTemporalPivot;
if (rootScopedPivot) {
previousIntent = null;
previousAnchorType = null;
previousAnchor = null;
@ -3079,7 +3198,7 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
(toNonEmptyString(alternateMessage)
? resolveDisplayedAddressEntityMention(String(alternateMessage ?? ""), displayedEntities)
: null);
if (resolvedEntityFromFollowup && !rootContextOnlyPivot) {
if (resolvedEntityFromFollowup && !rootScopedPivot) {
if (resolvedEntityFromFollowup.entityType === "counterparty") {
previousFilters.counterparty = resolvedEntityFromFollowup.value;
previousAnchorType = "counterparty";
@ -3100,7 +3219,7 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
followupSelectionMode = "carry_referenced_entity";
}
}
if (!rootContextOnlyPivot &&
if (!rootScopedPivot &&
!toNonEmptyString(previousFilters.item) &&
navigationFocusObjectType === "item" &&
navigationFocusObjectLabel &&
@ -3142,7 +3261,7 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
previous_anchor_type: previousAnchorType ?? undefined,
previous_anchor_value: previousAnchor,
resolved_counterparty_from_display: resolvedCounterpartyFromDisplay || undefined,
root_context_only: rootContextOnlyPivot || undefined,
root_context_only: rootScopedPivot || undefined,
root_intent: inventoryRootFrame?.intent ?? undefined,
root_filters: inventoryRootFrame?.filters ?? undefined,
root_anchor_type: inventoryRootFrame?.anchorType ?? undefined,
@ -3163,11 +3282,13 @@ function buildAddressDialogContinuationContractV2(userMessage, effectiveMessage,
const previousIntent = toNonEmptyString(carryoverMeta?.previousSourceIntent) ?? null;
const selectionMode = toNonEmptyString(carryoverMeta?.followupSelectionMode) ?? null;
const rootContextOnly = selectionMode === "carry_root_context";
const explicitIntent = toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.intent);
const explicitIntentRaw = toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.intent);
const explicitIntent = explicitIntentRaw === "unknown" ? null : explicitIntentRaw;
const rootIntent = toNonEmptyString(carryoverMeta?.followupContext?.root_intent) ?? null;
const targetIntent = selectionMode === "switch_to_suggested_intent"
? toNonEmptyString(carryoverMeta?.previousAddressIntent) ?? null
: rootContextOnly
? explicitIntent ?? null
? rootIntent ?? explicitIntent ?? null
: explicitIntent ?? toNonEmptyString(carryoverMeta?.previousAddressIntent) ?? null;
const hasImplicitContinuationSignal = Boolean(carryoverMeta?.hasImplicitContinuationSignal);
const rewrittenByPredecompose = compactWhitespace(sourceMessage.toLowerCase()) !== compactWhitespace(canonicalMessage.toLowerCase());

View File

@ -15,6 +15,9 @@ function hasInventorySupplierCue(text) {
if (/(?:купил(?:и|о)?\s+у\s+кого|куплен(?:о)?\s+у\s+кого|купил(?:и|о)?\s+от\s+кого|куплен(?:о)?\s+от\s+кого)/iu.test(value)) {
return true;
}
if (/(?:кто\s+(?:был\s+)?продавец|кто\s+нам\s+продал|кто\s+продал\s+нам|(?:^|[\s,.;:!?()\-])(?:продавец|seller)(?=$|[\s,.;:!?()\-]))/iu.test(value)) {
return true;
}
if (/(?:кто\s+(?:(?:это|этот\s+товар|эту\s+позицию)\s+)?(?:нам\s+)?поставил|кто\s+(?:нам\s+)?поставил\s+(?:это|этот\s+товар|эту\s+позицию)|от\s+какого\s+поставщика|у\s+какого\s+поставщика|от\s+кого\s+куплен|у\s+кого\s+купили|у\s+кого\s+куплено|где\s+(?:мы\s+)?купили(?:\s+(?:это|его|этот\s+товар|эту\s+позицию))?|где\s+(?:мы\s+)?взяли(?:\s+(?:это|его|этот\s+товар|эту\s+позицию))?|откуда\s+(?:мы\s+)?взяли(?:\s+(?:это|его|этот\s+товар|эту\s+позицию))?|где\s+куплено|supplier|vendor|поставщик)/iu.test(value)) {
return true;
}

View File

@ -1189,6 +1189,12 @@ function isTemporalWarehousePhrase(candidate: string): boolean {
.toLowerCase()
.replace(/ё/g, "е")
.trim();
if (/^(?:на\s+)?(?:эту|ту|текущ(?:ую|ая|ий|ее|ей)|сегодняшн(?:юю|ая|ий|ее|ей))(?:\s+же)?\s+дат(?:у|а|е|ой)$/iu.test(normalized)) {
return true;
}
if (/^(?:нет|не\s+было|не\s+было\s+ли)(?:\s+на\s+(?:эту|ту|такую)\s+дат(?:у|е|ой))?$/iu.test(normalized)) {
return true;
}
return /^(?:в|на)\s+(?:январ(?:е|ь)|феврал(?:е|ь)|март(?:е)?|апрел(?:е|ь)|ма(?:й|е)|июн(?:е|ь)|июл(?:е|ь)|август(?:е)?|сентябр(?:е|ь)|октябр(?:е|ь)|ноябр(?:е|ь)|декабр(?:е|ь))(?:\s+\d{4}(?:\s+г(?:\.|ода)?)?)?$/iu.test(
normalized
);
@ -1244,7 +1250,8 @@ function isLowQualityWarehouseAnchorValue(rawValue: string): boolean {
"охуеть",
"пиздец",
"блять",
"бля"
"бля",
"нет"
]);
const tokens = value
.split(/[^a-zа-я0-9]+/iu)
@ -1279,7 +1286,9 @@ function hasImplicitSelfScopeSignal(text: string): boolean {
function isImplicitSelfScopeWarehouseAnchor(candidate: string): boolean {
const normalized = normalizeSemanticAnchorCandidate(candidate);
return /^(?:у\s+нас|у\s+себя|у\s+меня|наш(?:ем|ей|его|их|а|е)?|сво(?:ем|ей|его|их|я|е)?)$/iu.test(normalized);
return /^(?:у\s+нас|у\s+себя|у\s+меня|наш(?:ем|ей|его|их|а|е)?|сво(?:ем|ей|его|их|я|е)?)(?:\s+(?:висит|висят|висело|висели|лежит|лежат|лежало|лежали))?$/iu.test(
normalized
);
}
function hasSelectedObjectScopeSignal(text: string): boolean {

View File

@ -1286,11 +1286,23 @@ function toNormalizedRows(rows: Array<Record<string, unknown>>): NormalizedAddre
}
function rowSearchableText(row: NormalizedAddressRow): string {
return [row.registrator, row.item ?? "", row.warehouse ?? "", row.account_dt ?? "", row.account_kt ?? "", ...row.analytics]
return [
row.registrator,
row.item ?? "",
row.warehouse ?? "",
row.organization ?? "",
row.account_dt ?? "",
row.account_kt ?? "",
...row.analytics
]
.join(" ")
.toLowerCase();
}
function rowOrganizationSearchableText(row: NormalizedAddressRow): string {
return [row.organization ?? "", row.registrator, ...row.analytics].join(" ").toLowerCase();
}
function rowMatchesAnyAccount(row: NormalizedAddressRow, accountScope: string[]): boolean {
if (accountScope.length === 0) {
return true;
@ -1366,9 +1378,12 @@ function applyAddressFilters(rows: NormalizedAddressRow[], filters: AddressFilte
if (filters.organization && String(filters.organization).trim()) {
const needle = String(filters.organization);
const before = filtered.length;
filtered = filtered.filter((row) => matchesAnchorText(rowSearchableText(row), needle));
if (before > 0 && filtered.length === 0 && mismatchReason === null) {
mismatchReason = "organization_anchor_not_matched_in_materialized_rows";
const organizationMaterialized = filtered.some((row) => Boolean(String(row.organization ?? "").trim()));
if (organizationMaterialized) {
filtered = filtered.filter((row) => matchesAnchorText(rowOrganizationSearchableText(row), needle));
if (before > 0 && filtered.length === 0 && mismatchReason === null) {
mismatchReason = "organization_anchor_not_matched_in_materialized_rows";
}
}
}

View File

@ -1181,9 +1181,30 @@ function buildInventoryMovementQuery(
: side === "kt"
? creditPredicate
: `(${debitPredicate} ИЛИ ${creditPredicate})`;
const itemFieldPaths =
side === "dt"
? ["Движения.СубконтоДт1", "Движения.СубконтоДт2", "Движения.СубконтоДт3"]
: side === "kt"
? ["Движения.СубконтоКт1", "Движения.СубконтоКт2", "Движения.СубконтоКт3"]
: [
"Движения.СубконтоДт1",
"Движения.СубконтоДт2",
"Движения.СубконтоДт3",
"Движения.СубконтоКт1",
"Движения.СубконтоКт2",
"Движения.СубконтоКт3"
];
const itemCondition = buildInventoryItemReferenceCondition(filters, itemFieldPaths);
return INVENTORY_MOVEMENTS_QUERY_TEMPLATE
.replace("__LIMIT__", String(resolvedLimit))
.replace("__WHERE_CLAUSE__", buildWhereClause(filters, "Движения.Период", [inventoryCondition]))
.replace(
"__WHERE_CLAUSE__",
buildWhereClause(
filters,
"Движения.Период",
[inventoryCondition, itemCondition].filter((item): item is string => Boolean(item))
)
)
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort));
}
@ -1469,7 +1490,7 @@ export function buildAddressRecipePlan(
: recipe.query_template === "inventory_supplier_stock_overlap_profile"
? buildInventoryMovementQuery(filters, resolvedLimit, "dt")
: recipe.query_template === "inventory_sale_trace_profile"
? buildInventorySaleDocumentQuery(filters, resolvedLimit)
? buildInventoryMovementQuery(filters, resolvedLimit, "kt")
: recipe.query_template === "inventory_purchase_to_sale_chain_profile"
? buildInventoryMovementQuery(filters, resolvedLimit, "either")
: recipe.query_template === "inventory_aging_by_purchase_date_profile"

View File

@ -52,6 +52,7 @@ export interface AddressFollowupContext {
| "unknown"
| null;
root_anchor_value?: string | null;
root_context_only?: boolean;
current_frame_kind?: "generic" | "inventory_root" | "inventory_drilldown";
}
@ -91,7 +92,7 @@ function hasAllTimeHint(text: string): boolean {
}
function hasSameDateHint(text: string): boolean {
return /(?:на\s+ту\s+же\s+дат[ауеы]|на\s+эту\s+же\s+дат[ауеы]|на\s+эту\s+дат[ауеы]|эту\s+дат[ауеы]|та\s+же\s+дата|same\s+date|as\s+of\s+same\s+date|the\s+same\s+date)/iu.test(
return /(?:на\s+ту\s+же\s+дат[ауеы]|на\s+эту\s+же\s+дат[ауеы]|на\s+эту\s+дат[ауеы]|эту\s+дат[ауеы]|та\s+же\s+дата|дат[ауеы],?\s+котор(?:ую|ая)\s+(?:до\s+этого|раньше|ранее)\s+(?:рассматривали|смотрели)|дат[ауеы],?\s+которая\s+был[ао]?\s+ранее\s+рассмотрен[ао]?|same\s+date|as\s+of\s+same\s+date|the\s+same\s+date|date\s+we\s+looked\s+at\s+before|previously\s+considered\s+date)/iu.test(
String(text ?? "")
);
}
@ -487,6 +488,41 @@ function resolveRelativeMonthPeriodFromInventoryRoot(
};
}
function resolveRelativeMonthPeriodFromFollowupYear(
userMessage: string,
followupContext: AddressFollowupContext | null
): { period_from: string; period_to: string; as_of_date: string } | null {
if (!followupContext) {
return null;
}
const month = resolveMonthNumberFromText(userMessage);
if (!month) {
return null;
}
const normalized = String(userMessage ?? "");
if (hasExplicitPeriodLiteral(normalized) || hasExplicitCurrentDateHint(normalized) || hasSameDateHint(normalized)) {
return null;
}
const shortTemporalPatch = getTokenCount(normalized) <= 8 || hasRelativeYearHint(normalized);
if (!shortTemporalPatch) {
return null;
}
const year =
resolveYearFromFilters(followupContext.previous_filters) ??
resolveYearFromFilters(followupContext.root_filters);
if (!year) {
return null;
}
const lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate();
const periodFrom = `${year}-${String(month).padStart(2, "0")}-01`;
const periodTo = `${year}-${String(month).padStart(2, "0")}-${String(lastDay).padStart(2, "0")}`;
return {
period_from: periodFrom,
period_to: periodTo,
as_of_date: periodTo
};
}
function shouldRestoreInventoryRootFrame(
userMessage: string,
intent: AddressIntent,
@ -498,6 +534,7 @@ function shouldRestoreInventoryRootFrame(
}
const currentFrameKind = followupContext.current_frame_kind ?? null;
const previousIntent = followupContext.previous_intent;
const rootContextOnly = followupContext.root_context_only === true;
const comingFromInventoryDrilldown =
currentFrameKind === "inventory_drilldown" || isInventoryDrilldownFrameIntent(previousIntent);
const normalized = String(userMessage ?? "");
@ -508,6 +545,7 @@ function shouldRestoreInventoryRootFrame(
);
const canReenterInventoryRoot =
comingFromInventoryDrilldown ||
rootContextOnly ||
(currentFrameKind === "inventory_root" && hasSamePeriodHint(normalized)) ||
(currentFrameKind === "generic" && hasInventoryRootRestatementCue && hasSamePeriodHint(normalized));
if (!canReenterInventoryRoot) {
@ -517,7 +555,7 @@ function shouldRestoreInventoryRootFrame(
return false;
}
if (
hasSelectedObjectInventorySignal(normalized) ||
(hasSelectedObjectInventorySignal(normalized) && !hasInventoryRootRestatementCue) ||
hasInventorySupplierFollowupCue(normalized) ||
hasInventoryPurchaseDocumentsFollowupCue(normalized) ||
hasInventoryPurchaseDateFollowupCue(normalized) ||
@ -533,6 +571,7 @@ function shouldRestoreInventoryRootFrame(
const hasTemporalPatch =
hasExplicitPeriodWindow(extractedFilters) ||
Boolean(toNonEmptyString(extractedFilters.as_of_date)) ||
hasSameDateHint(normalized) ||
hasSamePeriodHint(normalized) ||
hasExplicitPeriodLiteral(normalized) ||
Boolean(resolveRelativeMonthPeriodFromInventoryRoot(normalized, followupContext));
@ -709,9 +748,11 @@ function mergeFollowupFilters(
const previousPeriodFrom = toNonEmptyString(previous.period_from);
const previousPeriodTo = toNonEmptyString(previous.period_to);
const relativeMonthFromInventoryRoot = resolveRelativeMonthPeriodFromInventoryRoot(userMessage, followupContext);
const relativeMonthFromFollowupYear = resolveRelativeMonthPeriodFromFollowupYear(userMessage, followupContext);
const allTimeRequested = hasAllTimeHint(userMessage);
const sameDateRequested = hasSameDateHint(userMessage);
const samePeriodRequested = hasSamePeriodHint(userMessage);
const explicitQuotedItem = extractSelectedObjectItemFromFollowupText(userMessage);
if (!toNonEmptyString(merged.organization) && previousOrganization) {
merged.organization = previousOrganization;
reasons.push("organization_from_followup_context");
@ -837,6 +878,17 @@ function mergeFollowupFilters(
merged.counterparty = inheritedCounterparty;
reasons.push(currentCounterparty ? "counterparty_replaced_from_followup_context" : "counterparty_from_followup_context");
}
if (intent === "inventory_on_hand_as_of_date" && explicitQuotedItem) {
const currentItem = toNonEmptyString(merged.item);
if (
!currentItem ||
currentItem !== explicitQuotedItem ||
isInventoryItemAnchorDegradation(explicitQuotedItem, currentItem)
) {
merged.item = explicitQuotedItem;
reasons.push(currentItem ? "item_replaced_from_explicit_quote" : "item_from_explicit_quote");
}
}
if (
(intent === "inventory_purchase_provenance_for_item" ||
intent === "inventory_purchase_documents_for_item" ||
@ -846,7 +898,6 @@ function mergeFollowupFilters(
intent === "inventory_aging_by_purchase_date")
) {
const inheritedItem = previousItem ?? previousAnchorItem;
const explicitQuotedItem = extractSelectedObjectItemFromFollowupText(userMessage);
const currentItem = toNonEmptyString(merged.item);
const shouldAdoptExplicitQuotedItem =
Boolean(explicitQuotedItem) &&
@ -983,6 +1034,7 @@ function mergeFollowupFilters(
if (
!sameDateRequested &&
hasFollowupSignalForConfirmed &&
!isInventoryLifecycleHistoryIntent(intent) &&
!hasExplicitPeriodLiteral(userMessage) &&
!hasExplicitCurrentDateHint(userMessage)
) {
@ -1040,12 +1092,29 @@ function mergeFollowupFilters(
intent === "vat_payable_confirmed_as_of_date";
const currentHasPeriod = hasExplicitPeriodWindow(merged);
const previousHasPeriod = hasExplicitPeriodWindow(previous);
const vatRelativeMonthFollowup =
relativeMonthFromFollowupYear &&
(intent === "vat_payable_confirmed_as_of_date" ||
intent === "vat_payable_forecast" ||
intent === "vat_liability_confirmed_for_tax_period");
if (vatRelativeMonthFollowup) {
merged.period_from = relativeMonthFromFollowupYear.period_from;
merged.period_to = relativeMonthFromFollowupYear.period_to;
if (intent === "vat_payable_confirmed_as_of_date") {
merged.as_of_date = relativeMonthFromFollowupYear.as_of_date;
} else if (toNonEmptyString(merged.as_of_date)) {
delete merged.as_of_date;
}
reasons.push("period_derived_from_followup_context_year");
}
if (
(intent === "vat_payable_forecast" || intent === "vat_liability_confirmed_for_tax_period") &&
previousHasPeriod &&
hasFollowupSignal &&
!hasExplicitPeriodInMessage
!hasExplicitPeriodInMessage &&
!vatRelativeMonthFollowup
) {
const currentPeriodFrom = toNonEmptyString(merged.period_from);
const currentPeriodTo = toNonEmptyString(merged.period_to);
@ -1067,7 +1136,8 @@ function mergeFollowupFilters(
previousHasPeriod &&
hasFollowupSignal &&
!hasExplicitPeriodInMessage &&
!inventoryLifecycleHistoryIntent
!inventoryLifecycleHistoryIntent &&
!vatRelativeMonthFollowup
) {
if (previousPeriodFrom) {
merged.period_from = previousPeriodFrom;

View File

@ -77,6 +77,24 @@ function hasSelectedObjectInventoryActionCue(text: string | null): boolean {
);
}
function hasShortInventoryPurchaseFollowupCue(text: string | null): boolean {
return /(?:^|[\s,.;:!?])(а\s+)?(?:купили\s+у\s+кого|у\s+кого\s+купили|поставщик|продавец|seller)(?:[\s,.;:!?]|$)/iu.test(
String(text ?? "")
);
}
function isInventorySelectedObjectOrRootIntent(intent: string | null): boolean {
return (
intent === "inventory_on_hand_as_of_date" ||
intent === "inventory_purchase_provenance_for_item" ||
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_profitability_for_item" ||
intent === "inventory_purchase_to_sale_chain" ||
intent === "inventory_aging_by_purchase_date"
);
}
function isGenericCanonicalDriftIntent(intent: string | null): boolean {
return (
intent === "open_items_by_counterparty_or_contract" ||
@ -89,6 +107,33 @@ function isGenericCanonicalDriftIntent(intent: string | null): boolean {
);
}
function hasSameDateFollowupSignal(text: string | null): boolean {
return /(?:эту\s+же\s+дат(?:у|е|ой)|ту\s+же\s+дат(?:у|е|ой)|same\s+date)/iu.test(String(text ?? ""));
}
function hasExplicitCurrentDateSignal(text: string | null): boolean {
return /(?:текущ(?:ую|ая|ий|ее|ей)\s+дат(?:у|а|е|ой)|сегодняшн(?:юю|ий|ей)\s+дат(?:у|а|е|ой)|today|current\s+date)/iu.test(
String(text ?? "")
);
}
function hasInventoryTemporalRootFollowupCue(text: string | null): boolean {
const value = String(text ?? "").trim().toLowerCase();
if (!value) {
return false;
}
const tokenCount = value.split(/\s+/).filter(Boolean).length;
const hasMonthYearCue =
/(?:январ(?:ь|е)|феврал(?:ь|е)|март(?:е)?|апрел(?:ь|е)|ма(?:й|е)|июн(?:ь|е)|июл(?:ь|е)|август(?:е)?|сентябр(?:ь|е)|октябр(?:ь|е)|ноябр(?:ь|е)|декабр(?:ь|е))(?:\s+\d{4})?/iu.test(
value
) || /\b(?:19|20)\d{2}\b/u.test(value);
if (tokenCount <= 3 && hasMonthYearCue) {
return true;
}
const hasInventoryLexeme = /(?:остат|склад|товар|позици|номенклатур)/iu.test(value);
return hasInventoryLexeme && (hasMonthYearCue || hasSameDateFollowupSignal(value));
}
function shouldPreferRawFollowupMessage(
userMessage: string,
addressInputMessage: string,
@ -112,15 +157,39 @@ function shouldPreferRawFollowupMessage(
: null;
const mode = toNonEmptyString(predecomposeContract?.mode) ?? "unknown";
const intent = toNonEmptyString(predecomposeContract?.intent) ?? "unknown";
const followupContext =
carryover.followupContext && typeof carryover.followupContext === "object"
? (carryover.followupContext as Record<string, unknown>)
: null;
const previousIntent = toNonEmptyString(followupContext?.previous_intent);
const rootIntent = toNonEmptyString(followupContext?.root_intent);
const previousAnchorType = toNonEmptyString(followupContext?.previous_anchor_type);
const hasInventoryItemCarryover =
previousAnchorType === "item" && isInventorySelectedObjectOrRootIntent(previousIntent);
const hasInventoryFrameCarryover =
isInventorySelectedObjectOrRootIntent(previousIntent) ||
isInventorySelectedObjectOrRootIntent(rootIntent);
if (mode === "unsupported" && intent === "unknown") {
return true;
}
if (hasSameDateFollowupSignal(rawMessage) && hasExplicitCurrentDateSignal(canonicalMessage)) {
return true;
}
if (
hasInventoryFrameCarryover &&
hasInventoryTemporalRootFollowupCue(rawMessage) &&
(intent === "account_balance_snapshot" || intent === "documents_forming_balance" || intent === "unknown")
) {
return true;
}
return (
hasSelectedObjectInventorySignal(rawMessage) &&
hasSelectedObjectInventoryActionCue(rawMessage) &&
isGenericCanonicalDriftIntent(intent)
(hasSelectedObjectInventorySignal(rawMessage) || hasInventoryItemCarryover) &&
(hasSelectedObjectInventoryActionCue(rawMessage) || hasShortInventoryPurchaseFollowupCue(rawMessage)) &&
(isGenericCanonicalDriftIntent(intent) || intent === "unknown")
);
}

View File

@ -193,8 +193,16 @@ export function createAssistantRoutePolicy(deps) {
hasShortDebtMirrorFollowupSignal(repairedRawUserMessage) ||
hasShortDebtMirrorFollowupSignal(effectiveAddressUserMessage) ||
hasShortDebtMirrorFollowupSignal(repairedEffectiveAddressUserMessage);
const followupPreviousIntent = toNonEmptyString(followupContext?.previous_intent);
const followupPreviousFilters = followupContext?.previous_filters && typeof followupContext.previous_filters === "object"
? followupContext.previous_filters
: null;
const protectedInventoryShortFollowup = Boolean(followupContext &&
isInventorySelectedObjectIntent(toNonEmptyString(followupContext.previous_intent)) &&
(isInventorySelectedObjectIntent(followupPreviousIntent) ||
(followupPreviousIntent === "inventory_on_hand_as_of_date" &&
(toNonEmptyString(followupContext.previous_anchor_type) === "item" ||
toNonEmptyString(followupContext.previous_anchor_value) ||
toNonEmptyString(followupPreviousFilters?.item)))) &&
(hasShortInventoryObjectFollowupSignal(rawUserMessage) ||
hasShortInventoryObjectFollowupSignal(repairedRawUserMessage) ||
hasShortInventoryObjectFollowupSignal(effectiveAddressUserMessage) ||

View File

@ -2757,8 +2757,11 @@ function hasShortInventoryObjectFollowupSignal(userMessage) {
if (minTokens > 8) {
return false;
}
const hasDirectPurchaseFollowupCue = (sample) => /(?:^|[\s,.;:!?])(?:а\s+)?(?:у|от)\s+кого(?:\s+\S+){0,5}\s+купил(?:и|о)?(?=$|[\s,.;:!?])/iu.test(sample) ||
/(?:^|[\s,.;:!?])(?:а\s+)?купил(?:и|о)?(?:\s+\S+){0,5}\s+(?:у|от)\s+кого(?=$|[\s,.;:!?])/iu.test(sample);
const hasDirectSaleFollowupCue = (sample) => /(?:кому|каму|куда)(?:\s+\S+){0,4}\s+(?:продали|продано|продан(?:о|а|ы)?|реализовали|реализован(?:о|а|ы)?)|(?:продали|продано|реализовали|реализован(?:о|а|ы)?)(?:\s+\S+){0,4}\s+(?:кому|каму|куда)|(?:^|\s)(?:продано|продали|реализовано|реализовали)(?=$|[\s,.;:!?])/iu.test(sample);
return samples.some((sample) => /^(?:кто|когда|документы|сумма|поставщик|покупатель)(?:\?)?$/iu.test(sample) ||
return samples.some((sample) => /^(?:кто|когда|документы|сумма|поставщик|покупатель|продавец|seller)(?:\?)?$/iu.test(sample) ||
hasDirectPurchaseFollowupCue(sample) ||
hasDirectSaleFollowupCue(sample) ||
(0, decomposeStage_1.hasInventorySupplierFollowupCue)(sample) ||
(0, decomposeStage_1.hasInventoryPurchaseDocumentsFollowupCue)(sample) ||
@ -2767,6 +2770,41 @@ function hasShortInventoryObjectFollowupSignal(userMessage) {
(0, decomposeStage_1.hasInventorySaleFollowupCue)(sample) ||
(0, decomposeStage_1.hasInventoryPurchaseToSaleChainFollowupCue)(sample));
}
function hasInventoryRootTemporalFollowupSignal(userMessage, sourceIntentHint, hasInventoryRootFrame) {
if (!hasInventoryRootFrame) {
return false;
}
if (!(sourceIntentHint === "inventory_on_hand_as_of_date" ||
sourceIntentHint === "inventory_supplier_stock_overlap_as_of_date" ||
isInventorySelectedObjectIntent(sourceIntentHint))) {
return false;
}
const rawText = compactWhitespace(String(userMessage ?? "").toLowerCase());
const repairedText = compactWhitespace(repairAddressMojibake(String(userMessage ?? "")).toLowerCase());
const samples = [rawText, repairedText].filter((item) => item.length > 0);
if (samples.length === 0) {
return false;
}
const minTokens = samples.reduce((min, sample) => Math.min(min, countTokens(sample)), Number.POSITIVE_INFINITY);
if (minTokens > 8) {
return false;
}
const hasMonthYearCue = samples.some((sample) => /(?:январ|феврал|март|апрел|ма(?:й|е|я)|июн|июл|август|сентябр|октябр|ноябр|декабр)(?:\s+\d{4})?/iu.test(sample) ||
/\b(?:19|20)\d{2}\b/u.test(sample));
const hasTemporalCue = samples.some((sample) => hasPeriodLiteral(sample) ||
hasShortNamedPeriodFollowupLiteral(sample) ||
hasShortCurrentDateFollowupLiteral(sample) ||
/(?:РЅР°\s+ССѓ\s+РРµ\s+РґР°С[ауеС]|РЅР°\s+СЌССѓ\s+РРµ\s+РґР°С[ауеС]|same\s+date|the\s+same\s+date)/iu.test(sample));
if (!hasTemporalCue && !hasMonthYearCue) {
return false;
}
if (samples.some((sample) => hasForeignAccountingPivotOverInventoryMessage(sample))) {
return false;
}
const hasInventoryLexeme = samples.some((sample) => /(?:РѕСЃСР°С|склад|Совар|номенклаС|РїРѕР·РёС)/iu.test(sample));
const hasPlainInventoryLexeme = samples.some((sample) => /(?:остат|склад|товар|номенклатур|позиц)/iu.test(sample));
return hasInventoryLexeme || hasPlainInventoryLexeme || minTokens <= 3;
}
function hasForeignAccountingPivotOverInventoryMessage(userMessage, alternateMessage = null) {
const samples = [
compactWhitespace(repairAddressMojibake(String(userMessage ?? "")).toLowerCase()),
@ -2848,8 +2886,22 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
(isImplicitAddressContinuationByLlm(userMessage, llmPreDecomposeMeta) ||
(toNonEmptyString(alternateMessage) ? isImplicitAddressContinuationByLlm(alternateMessage, llmPreDecomposeMeta) : false));
const sourceIntentHint = toNonEmptyString(previousAddressDebug?.detected_intent);
const inventoryShortFollowupPrimary = isInventorySelectedObjectIntent(sourceIntentHint) && hasShortInventoryObjectFollowupSignal(userMessage);
const inventoryShortFollowupAlternate = isInventorySelectedObjectIntent(sourceIntentHint) && toNonEmptyString(alternateMessage)
const navigationFocusObjectHint = addressNavigationState &&
typeof addressNavigationState === "object" &&
addressNavigationState.session_context &&
typeof addressNavigationState.session_context === "object" &&
addressNavigationState.session_context.active_focus_object &&
typeof addressNavigationState.session_context.active_focus_object === "object"
? addressNavigationState.session_context.active_focus_object
: null;
const hasNavigationInventoryItemFocusHint = Boolean(toNonEmptyString(navigationFocusObjectHint?.label) &&
toNonEmptyString(navigationFocusObjectHint?.object_type) === "item" &&
(sourceIntentHint === "inventory_on_hand_as_of_date" ||
sourceIntentHint === "inventory_supplier_stock_overlap_as_of_date" ||
isInventorySelectedObjectIntent(sourceIntentHint)));
let inventoryShortFollowupPrimary = (isInventorySelectedObjectIntent(sourceIntentHint) || hasNavigationInventoryItemFocusHint) &&
hasShortInventoryObjectFollowupSignal(userMessage);
let inventoryShortFollowupAlternate = (isInventorySelectedObjectIntent(sourceIntentHint) || hasNavigationInventoryItemFocusHint) && toNonEmptyString(alternateMessage)
? hasShortInventoryObjectFollowupSignal(String(alternateMessage ?? ""))
: false;
const debtRoleSwapPrimary = sourceIntentHint ? resolveDebtRoleSwapFollowupIntent(userMessage, sourceIntentHint) : null;
@ -2857,8 +2909,8 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
? resolveDebtRoleSwapFollowupIntent(String(alternateMessage ?? ""), sourceIntentHint)
: null;
const debtRoleSwapIntent = debtRoleSwapPrimary ?? debtRoleSwapAlternate ?? null;
const hasPrimaryFollowupSignal = hasAddressFollowupContextSignal(userMessage) || Boolean(debtRoleSwapPrimary) || inventoryShortFollowupPrimary;
const hasAlternateFollowupSignal = toNonEmptyString(alternateMessage)
let hasPrimaryFollowupSignal = hasAddressFollowupContextSignal(userMessage) || Boolean(debtRoleSwapPrimary) || inventoryShortFollowupPrimary;
let hasAlternateFollowupSignal = toNonEmptyString(alternateMessage)
? hasAddressFollowupContextSignal(alternateMessage) || Boolean(debtRoleSwapAlternate) || inventoryShortFollowupAlternate
: false;
const hasPrimaryIndexReferenceSignal = extractDisplayedEntityIndexMention(userMessage) !== null;
@ -2866,12 +2918,19 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
? extractDisplayedEntityIndexMention(String(alternateMessage ?? "")) !== null
: false;
const hasIndexReferenceSignal = hasPrimaryIndexReferenceSignal || hasAlternateIndexReferenceSignal;
const hasStrongFollowupReference = hasPrimaryIndexReferenceSignal ||
const recentInventoryRootFrame = findRecentInventoryRootFrame(items);
const hasInventoryRootTemporalFollowupPrimary = hasInventoryRootTemporalFollowupSignal(userMessage, sourceIntentHint, Boolean(recentInventoryRootFrame));
const hasInventoryRootTemporalFollowupAlternate = toNonEmptyString(alternateMessage)
? hasInventoryRootTemporalFollowupSignal(String(alternateMessage ?? ""), sourceIntentHint, Boolean(recentInventoryRootFrame))
: false;
let hasStrongFollowupReference = hasPrimaryIndexReferenceSignal ||
hasAlternateIndexReferenceSignal ||
hasOrganizationClarificationContinuation ||
hasImplicitContinuationSignal ||
inventoryShortFollowupPrimary ||
inventoryShortFollowupAlternate ||
hasInventoryRootTemporalFollowupPrimary ||
hasInventoryRootTemporalFollowupAlternate ||
Boolean(debtRoleSwapIntent) ||
hasFollowupMarker(userMessage) ||
hasReferentialPointer(userMessage) ||
@ -2883,6 +2942,8 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
if (hasStandaloneAddressTopic &&
!hasPrimaryFollowupSignal &&
!hasAlternateFollowupSignal &&
!hasInventoryRootTemporalFollowupPrimary &&
!hasInventoryRootTemporalFollowupAlternate &&
!hasImplicitContinuationSignal &&
!hasOrganizationClarificationContinuation &&
!hasIndexReferenceSignal) {
@ -2890,6 +2951,8 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
}
if (!hasPrimaryFollowupSignal &&
!hasAlternateFollowupSignal &&
!hasInventoryRootTemporalFollowupPrimary &&
!hasInventoryRootTemporalFollowupAlternate &&
!hasImplicitContinuationSignal &&
!hasOrganizationClarificationContinuation &&
!hasIndexReferenceSignal) {
@ -2951,6 +3014,41 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
: null;
const navigationFocusObjectType = toNonEmptyString(navigationFocusObject?.object_type);
const navigationFocusObjectLabel = toNonEmptyString(navigationFocusObject?.label);
const hasInventoryItemFocusCarryover = navigationFocusObjectType === "item" &&
navigationFocusObjectLabel &&
(sourceIntentHint === "inventory_on_hand_as_of_date" ||
sourceIntentHint === "inventory_supplier_stock_overlap_as_of_date" ||
isInventorySelectedObjectIntent(sourceIntentHint));
if (!inventoryShortFollowupPrimary && hasInventoryItemFocusCarryover) {
inventoryShortFollowupPrimary = hasShortInventoryObjectFollowupSignal(userMessage);
}
if (!inventoryShortFollowupAlternate && hasInventoryItemFocusCarryover && toNonEmptyString(alternateMessage)) {
inventoryShortFollowupAlternate = hasShortInventoryObjectFollowupSignal(String(alternateMessage ?? ""));
}
hasPrimaryFollowupSignal = hasAddressFollowupContextSignal(userMessage) ||
Boolean(debtRoleSwapPrimary) ||
inventoryShortFollowupPrimary ||
hasInventoryRootTemporalFollowupPrimary;
hasAlternateFollowupSignal = toNonEmptyString(alternateMessage)
? hasAddressFollowupContextSignal(alternateMessage) ||
Boolean(debtRoleSwapAlternate) ||
inventoryShortFollowupAlternate ||
hasInventoryRootTemporalFollowupAlternate
: false;
hasStrongFollowupReference = hasPrimaryIndexReferenceSignal ||
hasAlternateIndexReferenceSignal ||
hasOrganizationClarificationContinuation ||
hasImplicitContinuationSignal ||
inventoryShortFollowupPrimary ||
inventoryShortFollowupAlternate ||
hasInventoryRootTemporalFollowupPrimary ||
hasInventoryRootTemporalFollowupAlternate ||
Boolean(debtRoleSwapIntent) ||
hasFollowupMarker(userMessage) ||
hasReferentialPointer(userMessage) ||
(toNonEmptyString(alternateMessage)
? hasFollowupMarker(String(alternateMessage ?? "")) || hasReferentialPointer(String(alternateMessage ?? ""))
: false);
const hasSelectedObjectInventorySignalPrimary = /(?:по\s+выбранному\s+объекту|по\s+этой\s+позиции|по\s+этому\s+товару|selected\s+object)/iu.test(String(userMessage ?? ""));
const hasSelectedObjectInventorySignalAlternate = toNonEmptyString(alternateMessage)
? /(?:по\s+выбранному\s+объекту|по\s+этой\s+позиции|по\s+этому\s+товару|selected\s+object)/iu.test(String(alternateMessage ?? ""))
@ -3012,18 +3110,39 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
if (!toNonEmptyString(previousFilters.organization) && organizationClarificationSelection) {
previousFilters.organization = organizationClarificationSelection;
}
if (!toNonEmptyString(previousFilters.as_of_date) && toNonEmptyString(navigationDateScope?.as_of_date)) {
const shouldBackfillPreviousDateScopeFromNavigation = sourceIntentHint === "inventory_on_hand_as_of_date" ||
sourceIntentHint === "inventory_supplier_stock_overlap_as_of_date" ||
sourceIntentHint === "inventory_purchase_provenance_for_item" ||
sourceIntentHint === "inventory_purchase_documents_for_item" ||
sourceIntentHint === "inventory_sale_trace_for_item" ||
sourceIntentHint === "inventory_profitability_for_item" ||
sourceIntentHint === "inventory_purchase_to_sale_chain" ||
sourceIntentHint === "inventory_aging_by_purchase_date" ||
sourceIntentHint === "account_balance_snapshot" ||
sourceIntentHint === "documents_forming_balance";
if (shouldBackfillPreviousDateScopeFromNavigation &&
!toNonEmptyString(previousFilters.as_of_date) &&
toNonEmptyString(navigationDateScope?.as_of_date)) {
previousFilters.as_of_date = toNonEmptyString(navigationDateScope?.as_of_date);
}
if (!toNonEmptyString(previousFilters.period_from) && toNonEmptyString(navigationDateScope?.period_from)) {
if (shouldBackfillPreviousDateScopeFromNavigation &&
!toNonEmptyString(previousFilters.period_from) &&
toNonEmptyString(navigationDateScope?.period_from)) {
previousFilters.period_from = toNonEmptyString(navigationDateScope?.period_from);
}
if (!toNonEmptyString(previousFilters.period_to) && toNonEmptyString(navigationDateScope?.period_to)) {
if (shouldBackfillPreviousDateScopeFromNavigation &&
!toNonEmptyString(previousFilters.period_to) &&
toNonEmptyString(navigationDateScope?.period_to)) {
previousFilters.period_to = toNonEmptyString(navigationDateScope?.period_to);
}
const rootContextOnlyPivot = Boolean((isInventorySelectedObjectIntent(sourceIntentHint) || currentFrameKind === "inventory_drilldown") &&
hasForeignAccountingPivotOverInventoryMessage(userMessage, alternateMessage));
if (rootContextOnlyPivot) {
const inventoryRootTemporalPivot = Boolean(inventoryRootFrame &&
(isInventorySelectedObjectIntent(sourceIntentHint) || currentFrameKind === "inventory_drilldown") &&
(hasInventoryRootTemporalFollowupPrimary || hasInventoryRootTemporalFollowupAlternate) &&
!hasForeignAccountingPivotOverInventoryMessage(userMessage, alternateMessage));
const rootScopedPivot = rootContextOnlyPivot || inventoryRootTemporalPivot;
if (rootScopedPivot) {
previousIntent = null;
previousAnchorType = null;
previousAnchor = null;
@ -3037,7 +3156,7 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
(toNonEmptyString(alternateMessage)
? resolveDisplayedAddressEntityMention(String(alternateMessage ?? ""), displayedEntities)
: null);
if (resolvedEntityFromFollowup && !rootContextOnlyPivot) {
if (resolvedEntityFromFollowup && !rootScopedPivot) {
if (resolvedEntityFromFollowup.entityType === "counterparty") {
previousFilters.counterparty = resolvedEntityFromFollowup.value;
previousAnchorType = "counterparty";
@ -3058,7 +3177,7 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
followupSelectionMode = "carry_referenced_entity";
}
}
if (!rootContextOnlyPivot &&
if (!rootScopedPivot &&
!toNonEmptyString(previousFilters.item) &&
navigationFocusObjectType === "item" &&
navigationFocusObjectLabel &&
@ -3100,7 +3219,7 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
previous_anchor_type: previousAnchorType ?? undefined,
previous_anchor_value: previousAnchor,
resolved_counterparty_from_display: resolvedCounterpartyFromDisplay || undefined,
root_context_only: rootContextOnlyPivot || undefined,
root_context_only: rootScopedPivot || undefined,
root_intent: inventoryRootFrame?.intent ?? undefined,
root_filters: inventoryRootFrame?.filters ?? undefined,
root_anchor_type: inventoryRootFrame?.anchorType ?? undefined,
@ -3121,11 +3240,13 @@ function buildAddressDialogContinuationContractV2(userMessage, effectiveMessage,
const previousIntent = toNonEmptyString(carryoverMeta?.previousSourceIntent) ?? null;
const selectionMode = toNonEmptyString(carryoverMeta?.followupSelectionMode) ?? null;
const rootContextOnly = selectionMode === "carry_root_context";
const explicitIntent = toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.intent);
const explicitIntentRaw = toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.intent);
const explicitIntent = explicitIntentRaw === "unknown" ? null : explicitIntentRaw;
const rootIntent = toNonEmptyString(carryoverMeta?.followupContext?.root_intent) ?? null;
const targetIntent = selectionMode === "switch_to_suggested_intent"
? toNonEmptyString(carryoverMeta?.previousAddressIntent) ?? null
: rootContextOnly
? explicitIntent ?? null
? rootIntent ?? explicitIntent ?? null
: explicitIntent ?? toNonEmptyString(carryoverMeta?.previousAddressIntent) ?? null;
const hasImplicitContinuationSignal = Boolean(carryoverMeta?.hasImplicitContinuationSignal);
const rewrittenByPredecompose = compactWhitespace(sourceMessage.toLowerCase()) !== compactWhitespace(canonicalMessage.toLowerCase());

View File

@ -11,6 +11,9 @@ export function hasInventorySupplierCue(text: string): boolean {
if (/(?:купил(?:и|о)?\s+у\s+кого|куплен(?:о)?\s+у\s+кого|купил(?:и|о)?\s+от\s+кого|куплен(?:о)?\s+от\s+кого)/iu.test(value)) {
return true;
}
if (/(?:кто\s+(?:был\s+)?продавец|кто\s+нам\s+продал|кто\s+продал\s+нам|(?:^|[\s,.;:!?()\-])(?:продавец|seller)(?=$|[\s,.;:!?()\-]))/iu.test(value)) {
return true;
}
if (
/(?:кто\s+(?:(?:это|этот\s+товар|эту\s+позицию)\s+)?(?:нам\s+)?поставил|кто\s+(?:нам\s+)?поставил\s+(?:это|этот\s+товар|эту\s+позицию)|от\s+какого\s+поставщика|у\s+какого\s+поставщика|от\s+кого\s+куплен|у\s+кого\s+купили|у\s+кого\s+куплено|где\s+(?:мы\s+)?купили(?:\s+(?:это|его|этот\s+товар|эту\s+позицию))?|где\s+(?:мы\s+)?взяли(?:\s+(?:это|его|этот\s+товар|эту\s+позицию))?|откуда\s+(?:мы\s+)?взяли(?:\s+(?:это|его|этот\s+товар|эту\s+позицию))?|где\s+куплено|supplier|vendor|поставщик)/iu.test(
value

View File

@ -0,0 +1,82 @@
import { afterEach, describe, expect, it, vi } from "vitest";
const { executeAddressMcpQueryMock } = vi.hoisted(() => ({
executeAddressMcpQueryMock: vi.fn()
}));
vi.mock("../src/services/addressMcpClient", async () => {
const actual = await vi.importActual<typeof import("../src/services/addressMcpClient")>(
"../src/services/addressMcpClient"
);
return {
...actual,
executeAddressMcpQuery: executeAddressMcpQueryMock
};
});
import { AddressQueryService } from "../src/services/addressQueryService";
afterEach(() => {
executeAddressMcpQueryMock.mockReset();
vi.restoreAllMocks();
});
describe("counterparty lifecycle organization scope regressions", () => {
it("keeps carried organization scope without false empty-match when rows omit organization column", async () => {
executeAddressMcpQueryMock.mockResolvedValueOnce({
fetched_rows: 3,
matched_rows: 3,
raw_rows: [
{
Period: "2020-01-15T00:00:00Z",
Registrator: "CP_CUSTOMER_ACTIVITY",
AccountDt: "62.01",
AccountKt: "90.01",
Amount: 12,
Counterparty: 'ООО "Ромашка"'
},
{
Period: "2020-02-20T00:00:00Z",
Registrator: "CP_CUSTOMER_ACTIVITY",
AccountDt: "62.01",
AccountKt: "90.01",
Amount: 8,
Counterparty: 'ООО "Ландыш"'
},
{
Period: "2020-03-10T00:00:00Z",
Registrator: "CP_CUSTOMER_ACTIVITY",
AccountDt: "62.01",
AccountKt: "90.01",
Amount: 4,
Counterparty: 'ООО "Ромашка"'
}
],
rows: [],
error: null
});
const service = new AddressQueryService();
const result = await service.tryHandle("какие клиенты заплатили больше всего денег за все время", {
followupContext: {
previous_intent: "vat_payable_forecast",
previous_filters: {
organization: 'ООО "Альтернатива Плюс"',
period_from: "2020-01-01",
period_to: "2020-03-31"
},
previous_anchor_type: "organization",
previous_anchor_value: 'ООО "Альтернатива Плюс"'
}
});
expect(result?.handled).toBe(true);
expect(result?.reply_type).toBe("factual");
expect(result?.response_type).toBe("FACTUAL_LIST");
expect(result?.debug.detected_intent).toBe("counterparty_activity_lifecycle");
expect(result?.debug.extracted_filters?.organization).toBe('ООО "Альтернатива Плюс"');
expect(result?.debug.match_failure_reason).toBeNull();
expect(result?.debug.rows_matched).toBe(3);
expect(String(result?.reply_text ?? "")).toContain('ООО "Ромашка"');
});
});

View File

@ -0,0 +1,63 @@
import { describe, expect, it } from "vitest";
import { runAddressDecomposeStage } from "../src/services/address_runtime/decomposeStage";
describe("address follow-up temporal regressions", () => {
it("replaces VAT month-only follow-up with the previous follow-up year", () => {
const result = runAddressDecomposeStage("а на март", {
previous_intent: "vat_payable_confirmed_as_of_date",
previous_filters: {
period_from: "2019-05-01",
period_to: "2019-05-31",
as_of_date: "2019-05-31"
},
previous_anchor_type: "unknown",
previous_anchor_value: null
});
expect(result).not.toBeNull();
expect(result?.intent.intent).toBe("vat_payable_confirmed_as_of_date");
expect(result?.filters.extracted_filters.period_from).toBe("2019-03-01");
expect(result?.filters.extracted_filters.period_to).toBe("2019-03-31");
expect(result?.filters.extracted_filters.as_of_date).toBe("2019-03-31");
expect(result?.baseReasons).toContain("period_derived_from_followup_context_year");
});
it("replaces VAT month-only follow-up with a later month from the same inherited year", () => {
const result = runAddressDecomposeStage("на сентябрь", {
previous_intent: "vat_payable_confirmed_as_of_date",
previous_filters: {
period_from: "2019-05-01",
period_to: "2019-05-31",
as_of_date: "2019-05-31"
},
previous_anchor_type: "unknown",
previous_anchor_value: null
});
expect(result).not.toBeNull();
expect(result?.intent.intent).toBe("vat_payable_confirmed_as_of_date");
expect(result?.filters.extracted_filters.period_from).toBe("2019-09-01");
expect(result?.filters.extracted_filters.period_to).toBe("2019-09-30");
expect(result?.filters.extracted_filters.as_of_date).toBe("2019-09-30");
expect(result?.baseReasons).toContain("period_derived_from_followup_context_year");
});
it("keeps same-date inventory pivot anchored to the previous VAT date", () => {
const result = runAddressDecomposeStage("какие остатки по складу на эту же дату", {
previous_intent: "vat_payable_confirmed_as_of_date",
previous_filters: {
period_from: "2019-09-01",
period_to: "2019-09-30",
as_of_date: "2019-09-30"
},
previous_anchor_type: "unknown",
previous_anchor_value: null
});
expect(result).not.toBeNull();
expect(result?.intent.intent).toBe("inventory_on_hand_as_of_date");
expect(result?.filters.extracted_filters.as_of_date).toBe("2019-09-30");
expect(result?.filters.extracted_filters.warehouse).toBeUndefined();
expect(result?.baseReasons).toContain("as_of_date_from_followup_context");
});
});

View File

@ -56,7 +56,6 @@ describe("inventory root frame regressions", () => {
expect(result).not.toBeNull();
expect(result?.intent.intent).toBe("inventory_purchase_provenance_for_item");
expect(result?.filters.extracted_filters.item).toBe("Зеркало для инвалидов поворотное травмобезопасное");
expect(result?.filters.extracted_filters.as_of_date).toBe("2022-02-28");
expect(
result?.baseReasons?.includes("intent_adjusted_to_inventory_followup_context") ||
result?.intent.reasons.includes("inventory_selected_object_provenance_signal_detected")
@ -111,4 +110,67 @@ describe("inventory root frame regressions", () => {
expect(result?.intent.intent).not.toBe("bank_operations_by_counterparty");
expect(result?.filters.extracted_filters.item).toBe("Четки Пост (84*117)");
});
it("restores stock snapshot intent for selected-object restatement on the previously reviewed date", () => {
const result = runAddressDecomposeStage(
'По выбранному объекту "Столешница 600*3050*26 альмандин": покажи еще раз остатки на дату которую до этого рассматривали',
{
previous_intent: "inventory_purchase_documents_for_item",
previous_filters: {
item: "Столешница 600*3050*26 альмандин",
organization: 'ООО "Альтернатива Плюс"',
as_of_date: "2019-05-31"
},
previous_anchor_type: "item",
previous_anchor_value: "Столешница 600*3050*26 альмандин",
root_intent: "inventory_on_hand_as_of_date",
root_filters: {
organization: 'ООО "Альтернатива Плюс"',
period_from: "2019-05-01",
period_to: "2019-05-31",
as_of_date: "2019-05-31"
},
root_anchor_type: "organization",
root_anchor_value: 'ООО "Альтернатива Плюс"',
current_frame_kind: "inventory_drilldown"
}
);
expect(result).not.toBeNull();
expect(result?.intent.intent).toBe("inventory_on_hand_as_of_date");
expect(result?.intent.reasons).toContain("intent_restored_to_inventory_root_frame");
expect(result?.filters.extracted_filters.item).toBe("Столешница 600*3050*26 альмандин");
expect(result?.filters.extracted_filters.organization).toBe('ООО "Альтернатива Плюс"');
expect(result?.filters.extracted_filters.as_of_date).toBe("2019-05-31");
expect(result?.baseReasons).toContain("address_followup_context_applied");
});
it("restores inventory root frame for root-context-only month follow-up after drilldown", () => {
const result = runAddressDecomposeStage("остатки на июль 2019", {
previous_filters: {
organization: 'ООО "Альтернатива Плюс"',
as_of_date: "2020-03-31",
period_from: "2020-03-01",
period_to: "2020-03-31"
},
previous_anchor_type: null,
previous_anchor_value: null,
root_intent: "inventory_on_hand_as_of_date",
root_filters: {
organization: 'ООО "Альтернатива Плюс"',
as_of_date: "2020-03-31",
period_from: "2020-03-01",
period_to: "2020-03-31"
},
root_context_only: true,
current_frame_kind: "inventory_root"
});
expect(result).not.toBeNull();
expect(result?.intent.intent).toBe("inventory_on_hand_as_of_date");
expect(result?.intent.reasons).toContain("intent_restored_to_inventory_root_frame");
expect(result?.filters.extracted_filters.organization).toBe('ООО "Альтернатива Плюс"');
expect(result?.filters.extracted_filters.period_from).toBe("2019-07-01");
expect(result?.filters.extracted_filters.period_to).toBe("2019-07-31");
expect(result?.filters.extracted_filters.as_of_date).toBe("2019-07-31");
});
});

View File

@ -21,8 +21,8 @@ afterEach(() => {
vi.restoreAllMocks();
});
describe("inventory sale trace document route", () => {
it("uses document sales route with native item resolution for selected-object buyer follow-up", async () => {
describe("inventory sale trace movement route", () => {
it("uses 41.01 movement route with native item resolution for selected-object buyer follow-up", async () => {
executeAddressMcpQueryMock.mockResolvedValueOnce({
fetched_rows: 2,
matched_rows: 2,
@ -30,25 +30,27 @@ describe("inventory sale trace document route", () => {
{
Period: "2015-02-25T12:00:00Z",
Registrator: "Реализация товаров и услуг 00000000012 от 25.02.2015 12:00:00",
AccountDt: "",
AccountDt: "62.01",
AccountKt: "41.01",
Amount: 12605435.66,
Quantity: 40,
Item: "Рабочая станция универсального специалиста (индивидуальное изготовление)",
Counterparty: "Комитет государственных услуг г. Москвы",
Contract: "Гос.контракт № 42/15 от 20.02.2015г. Силино окна",
SubcontoKt1: "Рабочая станция универсального специалиста (индивидуальное изготовление)",
SubcontoKt3: "Основной склад",
SubcontoDt1: "Комитет государственных услуг г. Москвы",
SubcontoDt2: "Гос.контракт № 42/15 от 20.02.2015 г. Силино окна",
Organization: "ООО \\Альтернатива Плюс\\"
},
{
Period: "2015-02-09T12:00:14Z",
Registrator: "Реализация товаров и услуг 00000000004 от 09.02.2015 12:00:14",
AccountDt: "",
AccountDt: "62.01",
AccountKt: "41.01",
Amount: 16421320.17,
Quantity: 51,
Item: "Рабочая станция универсального специалиста (индивидуальное изготовление)",
Counterparty: "Комитет государственных услуг г. Москвы",
Contract: "Гос.контракт № 17/15 от 02.02.2015г.",
SubcontoKt1: "Рабочая станция универсального специалиста (индивидуальное изготовление)",
SubcontoKt3: "Основной склад",
SubcontoDt1: "Комитет государственных услуг г. Москвы",
SubcontoDt2: "Гос.контракт № 17/15 от 02.02.2015 г.",
Organization: "ООО \\Альтернатива Плюс\\"
}
],
@ -82,11 +84,12 @@ describe("inventory sale trace document route", () => {
expect(executeAddressMcpQueryMock).toHaveBeenCalledTimes(1);
const query = String(executeAddressMcpQueryMock.mock.calls[0]?.[0]?.query ?? "");
expect(query).toContain("Документ.РеализацияТоваровУслуг.Товары КАК Товары");
expect(query).toContain("Товары.Номенклатура В (ВЫБРАТЬ Номенклатура.Ссылка");
expect(query).toContain('Номенклатура.Наименование = "Рабочая станция универсального специалиста (индивидуальное изготовление)"');
expect(query).toContain("ПРЕДСТАВЛЕНИЕ(Товары.Ссылка.Контрагент) КАК Контрагент");
expect(query).toContain("ПРЕДСТАВЛЕНИЕ(Товары.Ссылка.Организация) КАК Организация");
expect(query).toContain("РегистрБухгалтерии.Хозрасчетный.ДвиженияССубконто КАК Движения");
expect(query).toContain('ПОДСТРОКА(ЕСТЬNULL(Движения.СчетКт.Код, ""), 1, 5) = "41.01"');
expect(query).toContain("Движения.СубконтоКт1 В (ВЫБРАТЬ Номенклатура.Ссылка");
expect(query).toContain(
'Номенклатура.Наименование = "Рабочая станция универсального специалиста (индивидуальное изготовление)"'
);
expect(query).not.toContain("2016-06-30");
expect(query).not.toContain("2016-06-01");
expect(query).not.toContain('ПРЕДСТАВЛЕНИЕ(Движения.Организация) = "ООО \\Альтернатива Плюс\\"');

View File

@ -25,13 +25,14 @@ describe("inventory sale trace selected-object regressions", () => {
const saleRow = {
Period: "2021-04-15T00:00:00Z",
Registrator: "Реализация товаров и услуг 00000000201 от 15.04.2021 0:00:00",
AccountDt: "",
AccountDt: "62.01",
AccountKt: "41.01",
Amount: 165.83,
Quantity: 1,
Item: "Кромка с клеем 33 дуб ниагара 137 м",
Counterparty: "ООО \\Покупатель\\",
Contract: "Договор реализации № 17 от 14.04.2021",
SubcontoKt1: "Кромка с клеем 33 дуб ниагара 137 м",
SubcontoKt3: "Основной склад",
SubcontoDt1: "ООО \\Покупатель\\",
SubcontoDt2: "Договор реализации № 17 от 14.04.2021",
Organization: "ООО \\Альтернатива Плюс\\"
};
@ -70,7 +71,7 @@ describe("inventory sale trace selected-object regressions", () => {
expect(executeAddressMcpQueryMock).toHaveBeenCalledTimes(1);
const query = String(executeAddressMcpQueryMock.mock.calls[0]?.[0]?.query ?? "");
expect(query).toContain("Документ.РеализацияТоваровУслуг.Товары КАК Товары");
expect(query).toContain("РегистрБухгалтерии.Хозрасчетный.ДвиженияССубконто КАК Движения");
expect(query).toContain('Номенклатура.Наименование = "Кромка с клеем 33 дуб ниагара 137 м"');
expect(query).not.toContain('Номенклатура.Наименование = "Кромка"');
});
@ -132,6 +133,7 @@ describe("inventory sale trace selected-object regressions", () => {
expect(executeAddressMcpQueryMock).toHaveBeenCalledTimes(1);
const query = String(executeAddressMcpQueryMock.mock.calls[0]?.[0]?.query ?? "");
expect(query).toContain("РегистрБухгалтерии.Хозрасчетный.ДвиженияССубконто КАК Движения");
expect(query).toContain('Номенклатура.Наименование = "Кромка с клеем 33 дуб ниагара 137 м"');
expect(query).not.toContain('Номенклатура.Наименование = "Кромка"');
});

View File

@ -193,7 +193,7 @@ describe("inventory selected-object follow-up", () => {
expect(result?.response_type).toBe("FACTUAL_SUMMARY");
expect(result?.debug.detected_intent).toBe("inventory_purchase_provenance_for_item");
expect(result?.debug.extracted_filters?.item).toBe("Столешница 600*3050*26 дуб ниагара");
expect(result?.debug.extracted_filters?.as_of_date).toBe("2019-03-31");
expect(result?.debug.extracted_filters?.as_of_date).toBeUndefined();
expect(String(result?.reply_text ?? "")).toContain("Торговый дом \\Союз МСК\\");
});
@ -242,7 +242,7 @@ describe("inventory selected-object follow-up", () => {
expect(result?.response_type).toBe("FACTUAL_SUMMARY");
expect(result?.debug.detected_intent).toBe("inventory_purchase_provenance_for_item");
expect(result?.debug.extracted_filters?.item).toBe("Рабочая станция универсального специалиста (индивидуальное изготовление)");
expect(result?.debug.extracted_filters?.as_of_date).toBe("2016-07-31");
expect(result?.debug.extracted_filters?.as_of_date).toBeUndefined();
expect(String(result?.reply_text ?? "")).toContain("ООО \\Производство мебели\\");
});
@ -292,7 +292,7 @@ describe("inventory selected-object follow-up", () => {
expect(result?.debug.detected_intent).toBe("inventory_purchase_provenance_for_item");
expect(result?.debug.selected_recipe).toBe("address_inventory_purchase_provenance_for_item_v1");
expect(result?.debug.extracted_filters?.item).toBe("Конструкция трансформер рабочей станции 1300*900*2000");
expect(result?.debug.extracted_filters?.as_of_date).toBe("2020-06-30");
expect(result?.debug.extracted_filters?.as_of_date).toBeUndefined();
expect(result?.debug.extracted_filters?.period_from).toBeUndefined();
expect(result?.debug.extracted_filters?.period_to).toBeUndefined();
expect(result?.debug.capability_id).toBe("inventory_inventory_purchase_provenance_for_item");
@ -345,7 +345,7 @@ describe("inventory selected-object follow-up", () => {
expect(result?.response_type).toBe("FACTUAL_SUMMARY");
expect(result?.debug.detected_intent).toBe("inventory_purchase_provenance_for_item");
expect(result?.debug.extracted_filters?.item).toBe("Рабочая станция универсального специалиста (индивидуальное изготовление)");
expect(result?.debug.extracted_filters?.as_of_date).toBe("2016-05-31");
expect(result?.debug.extracted_filters?.as_of_date).toBeUndefined();
expect(String(result?.reply_text ?? "")).toContain("ООО \\Производство мебели\\");
});
@ -394,7 +394,7 @@ describe("inventory selected-object follow-up", () => {
expect(result?.response_type).toBe("FACTUAL_SUMMARY");
expect(result?.debug.detected_intent).toBe("inventory_purchase_provenance_for_item");
expect(result?.debug.extracted_filters?.item).toBe("Рабочая станция универсального специалиста (индивидуальное изготовление)");
expect(result?.debug.extracted_filters?.as_of_date).toBe("2016-05-31");
expect(result?.debug.extracted_filters?.as_of_date).toBeUndefined();
expect(String(result?.reply_text ?? "")).toContain("ООО \\Производство мебели\\");
});
@ -444,7 +444,7 @@ describe("inventory selected-object follow-up", () => {
expect(result?.debug.detected_intent).toBe("inventory_purchase_provenance_for_item");
expect(result?.debug.selected_recipe).toBe("address_inventory_purchase_provenance_for_item_v1");
expect(result?.debug.extracted_filters?.item).toBe("Рабочая станция универсального специалиста (индивидуальное изготовление)");
expect(result?.debug.extracted_filters?.as_of_date).toBe("2016-05-31");
expect(result?.debug.extracted_filters?.as_of_date).toBeUndefined();
expect(String(result?.reply_text ?? "")).toContain("ООО \\Производство мебели\\");
});
@ -491,7 +491,7 @@ describe("inventory selected-object follow-up", () => {
expect(result?.debug.detected_intent).toBe("inventory_purchase_documents_for_item");
expect(result?.debug.selected_recipe).toBe("address_inventory_purchase_documents_for_item_v1");
expect(result?.debug.extracted_filters?.item).toBe("Столешница 600*3050*26 дуб ниагара");
expect(result?.debug.extracted_filters?.as_of_date).toBe("2019-03-31");
expect(result?.debug.extracted_filters?.as_of_date).toBeUndefined();
expect(String(result?.reply_text ?? "")).toContain("Поступление товаров и услуг 00000000077");
});
@ -537,7 +537,7 @@ describe("inventory selected-object follow-up", () => {
expect(result?.debug.detected_intent).toBe("inventory_sale_trace_for_item");
expect(result?.debug.selected_recipe).toBe("address_inventory_sale_trace_for_item_v1");
expect(result?.debug.extracted_filters?.item).toBe("Четки Пост (84*117)");
expect(result?.debug.extracted_filters?.as_of_date).toBe("2020-03-31");
expect(result?.debug.extracted_filters?.as_of_date).toBeUndefined();
expect(String(result?.reply_text ?? "").split("\n")[0]).toContain("ИП Покупатель");
expect(String(result?.reply_text ?? "")).toContain("Документы выбытия");
});
@ -583,7 +583,7 @@ describe("inventory selected-object follow-up", () => {
expect(result?.debug.detected_intent).toBe("inventory_sale_trace_for_item");
expect(result?.debug.selected_recipe).toBe("address_inventory_sale_trace_for_item_v1");
expect(result?.debug.extracted_filters?.item).toBe("Пуф арий");
expect(result?.debug.extracted_filters?.as_of_date).toBe("2020-05-31");
expect(result?.debug.extracted_filters?.as_of_date).toBeUndefined();
expect(result?.debug.reasons).toContain("inventory_selected_object_sale_trace_signal_detected");
expect(String(result?.reply_text ?? "").split("\n")[0]).toContain("ООО \\Ромашка\\");
expect(String(result?.reply_text ?? "")).toContain("Документы выбытия");
@ -631,7 +631,7 @@ describe("inventory selected-object follow-up", () => {
expect(result?.debug.detected_intent).toBe("inventory_sale_trace_for_item");
expect(result?.debug.selected_recipe).toBe("address_inventory_sale_trace_for_item_v1");
expect(result?.debug.extracted_filters?.item).toBe("Кромка с клеем 33 альмандин 137 м");
expect(result?.debug.extracted_filters?.as_of_date).toBe("2020-03-31");
expect(result?.debug.extracted_filters?.as_of_date).toBeUndefined();
expect(String(result?.reply_text ?? "")).toContain("ООО \\Покупатель\\");
});
@ -678,10 +678,7 @@ describe("inventory selected-object follow-up", () => {
expect(result?.handled).toBe(true);
expect(result?.response_type).toBe("FACTUAL_LIST");
expect(result?.debug.detected_intent).toBe("inventory_sale_trace_for_item");
expect(result?.debug.reasons).toContain("lifecycle_execution_detached_from_snapshot_date");
expect(result?.debug.reasons).toContain("as_of_date_cleared_for_history_recovery");
expect(result?.debug.limitations).toContain("lifecycle_execution_detached_from_snapshot_date");
expect(result?.debug.limitations).toContain("as_of_date_cleared_for_history_recovery");
expect(result?.debug.extracted_filters?.as_of_date).toBeUndefined();
expect(executeAddressMcpQueryMock).toHaveBeenCalledTimes(1);
const query = String(executeAddressMcpQueryMock.mock.calls[0]?.[0]?.query ?? "");
expect(query).not.toContain("2019-03-31");
@ -808,7 +805,7 @@ describe("inventory selected-object follow-up", () => {
expect(result?.response_type).toBe("FACTUAL_SUMMARY");
expect(result?.debug.detected_intent).toBe("inventory_purchase_provenance_for_item");
expect(result?.debug.extracted_filters?.item).toBe("Кресло орион");
expect(result?.debug.extracted_filters?.as_of_date).toBe("2020-03-31");
expect(result?.debug.extracted_filters?.as_of_date).toBeUndefined();
expect(result?.debug.extracted_filters?.period_from).toBeUndefined();
expect(result?.debug.extracted_filters?.period_to).toBeUndefined();
expect(result?.debug.reasons ?? []).not.toContain("lifecycle_execution_detached_from_snapshot_date");
@ -820,7 +817,7 @@ describe("inventory selected-object follow-up", () => {
const query = String(executeAddressMcpQueryMock.mock.calls[0]?.[0]?.query ?? "");
expect(query).toContain("Документ.ПоступлениеТоваровУслуг.Товары КАК Товары");
expect(query).toContain("Товары.Номенклатура В (ВЫБРАТЬ Номенклатура.Ссылка");
expect(query).toContain("Товары.Ссылка.Дата <= ДАТАВРЕМЯ(2020, 3, 31, 23, 59, 59)");
expect(query).not.toContain("2020-03-31");
expect(query).toContain("ПРЕДСТАВЛЕНИЕ(Товары.Ссылка.Организация) КАК Организация");
});
});

View File

@ -62,6 +62,29 @@ describe("inventory warehouse anchor extraction", () => {
"inventory_on_hand_as_of_date"
).extracted_filters;
expect(filters.warehouse).toBeUndefined();
});
it("does not materialize self-scope slang tail as warehouse anchor in stock snapshot wording from the run", () => {
const result = extractAddressFilters("что там на складе у нас висит", "inventory_on_hand_as_of_date");
expect(result.extracted_filters.warehouse).toBeUndefined();
expect(result.warnings).toContain("warehouse_self_scope_detected");
expect(result.semantic_frame?.scope_kind).toBe("implicit_self_scope");
expect(result.semantic_frame?.anchor_kind).toBe("self_scope");
expect(result.semantic_frame?.anchor_value).toBeNull();
});
it("does not materialize same-date phrasing as warehouse anchor in stock follow-up", () => {
const filters = extractAddressFilters("какие остатки по складу на эту же дату", "inventory_on_hand_as_of_date").extracted_filters;
expect(filters.warehouse).toBeUndefined();
});
it("does not materialize current-date phrasing as warehouse anchor in stock follow-up", () => {
const filters = extractAddressFilters(
"получить остатки по складу на текущую дату",
"inventory_on_hand_as_of_date"
).extracted_filters;
expect(filters.warehouse).toBeUndefined();
});
});

View File

@ -2376,5 +2376,228 @@ describe("assistant address follow-up carryover", () => {
expect(normalizerService.normalize).not.toHaveBeenCalled();
});
it("treats short inventory month follow-up after drilldown as continuation of the recent inventory root frame", async () => {
const calls: Array<{ message: string; options?: any }> = [];
const followupMessage = "остатки на июль 2019";
const itemLabel = "Четки Пост (84*117)";
const organization = 'ООО "Альтернатива Плюс"';
const inventoryResult = {
handled: true,
reply_text: "Подтвержденный складской срез на 31.07.2019 собран.",
reply_type: "factual",
response_type: "FACTUAL_SUMMARY",
debug: {
detected_mode: "address_query",
detected_intent: "inventory_on_hand_as_of_date",
detected_intent_confidence: "high",
extracted_filters: {
as_of_date: "2019-07-31",
period_from: "2019-07-01",
period_to: "2019-07-31",
organization
},
selected_recipe: "address_inventory_on_hand_as_of_date_v1",
reasons: ["address_action_detected", "address_entity_detected", "address_followup_context_applied"]
}
} as any;
const addressQueryService = {
tryHandle: vi.fn(async (message: string, options?: any) => {
calls.push({ message, options });
if (message === followupMessage && options?.followupContext) {
return inventoryResult;
}
return null;
})
} as any;
const normalizerService = {
normalize: vi.fn(async () => ({
assistant_reply: "normalizer_fallback_should_not_be_used",
reply_type: "partial_coverage",
debug: {}
}))
} as any;
const sessions = new AssistantSessionStore();
const service = new AssistantService(
normalizerService,
sessions as any,
{} as any,
{ persistSession: vi.fn() } as any,
addressQueryService
);
const sessionId = `asst-address-followup-inventory-root-month-${Date.now()}`;
sessions.appendItem(sessionId, {
message_id: "msg-inventory-root-seed-july",
session_id: sessionId,
role: "assistant",
text: "inventory root seed",
reply_type: "factual",
created_at: "2026-04-15T19:00:00.000Z",
trace_id: "address-root-seed-july",
debug: {
detected_mode: "address_query",
detected_intent: "inventory_on_hand_as_of_date",
extracted_filters: {
as_of_date: "2020-03-31",
period_from: "2020-03-01",
period_to: "2020-03-31",
organization
},
selected_recipe: "address_inventory_on_hand_as_of_date_v1"
}
} as any);
sessions.appendItem(sessionId, {
message_id: "msg-inventory-drilldown-seed-july",
session_id: sessionId,
role: "assistant",
text: "inventory provenance seed",
reply_type: "factual",
created_at: "2026-04-15T19:01:00.000Z",
trace_id: "address-drilldown-seed-july",
debug: {
detected_mode: "address_query",
detected_intent: "inventory_purchase_provenance_for_item",
extracted_filters: {
item: itemLabel,
organization
},
selected_recipe: "address_inventory_purchase_provenance_for_item_v1",
anchor_type: "item",
anchor_value_raw: itemLabel,
anchor_value_resolved: itemLabel
}
} as any);
const second = await service.handleMessage({
session_id: sessionId,
user_message: followupMessage,
useMock: true
} as any);
expect(second.ok).toBe(true);
expect(second.reply_type).toBe("factual");
expect(calls).toHaveLength(1);
expect(calls[0].message).toBe(followupMessage);
expect(calls[0].options?.followupContext?.previous_intent).toBeUndefined();
expect(calls[0].options?.followupContext?.previous_filters?.item).toBeUndefined();
expect(calls[0].options?.followupContext?.root_context_only).toBe(true);
expect(calls[0].options?.followupContext?.root_intent).toBe("inventory_on_hand_as_of_date");
expect(calls[0].options?.followupContext?.root_filters?.organization).toBe(organization);
expect(calls[0].options?.followupContext?.root_filters?.as_of_date).toBe("2020-03-31");
expect(calls[0].options?.followupContext?.current_frame_kind).toBe("inventory_root");
expect(normalizerService.normalize).not.toHaveBeenCalled();
});
it("treats colloquial supplier follow-up from an inventory root slice as continuation of the focused item", async () => {
const calls: Array<{ message: string; options?: any }> = [];
const followupMessage = "у кого мы модуль прямоугольный купили кстати";
const itemLabel = "Модуль прямоугольый 1400*110*750";
const organization = 'ООО "Альтернатива Плюс"';
const provenanceResult = {
handled: true,
reply_text: `По позиции ${itemLabel} подтвержден поставщик: Торговый дом "Союз".`,
reply_type: "factual",
response_type: "FACTUAL_SUMMARY",
debug: {
detected_mode: "address_query",
detected_intent: "inventory_purchase_provenance_for_item",
detected_intent_confidence: "medium",
extracted_filters: {
item: itemLabel,
organization,
as_of_date: "2020-05-31"
},
selected_recipe: "address_inventory_purchase_provenance_for_item_v1",
reasons: ["address_action_detected", "address_entity_detected", "address_followup_context_applied"]
}
} as any;
const addressQueryService = {
tryHandle: vi.fn(async (message: string, options?: any) => {
calls.push({ message, options });
if (message === followupMessage && options?.followupContext) {
return provenanceResult;
}
return null;
})
} as any;
const normalizerService = {
normalize: vi.fn(async () => ({
assistant_reply: "normalizer_fallback_should_not_be_used",
reply_type: "partial_coverage",
debug: {}
}))
} as any;
const sessions = new AssistantSessionStore();
const service = new AssistantService(
normalizerService,
sessions as any,
{} as any,
{ persistSession: vi.fn() } as any,
addressQueryService
);
const sessionId = `asst-address-followup-root-item-focus-${Date.now()}`;
sessions.appendItem(sessionId, {
message_id: "msg-inventory-root-seed",
session_id: sessionId,
role: "assistant",
text: `${itemLabel} был в остатках на 31.05.2020.`,
reply_type: "factual",
created_at: "2026-04-15T19:00:00.000Z",
trace_id: "address-root-seed",
debug: {
detected_mode: "address_query",
detected_intent: "inventory_on_hand_as_of_date",
extracted_filters: {
organization,
as_of_date: "2020-05-31",
period_from: "2020-05-01",
period_to: "2020-05-31"
},
selected_recipe: "address_inventory_on_hand_as_of_date_v1"
}
} as any);
sessions.setAddressNavigationState(sessionId, {
session_id: sessionId,
session_context: {
active_focus_object: {
object_type: "item",
label: itemLabel
},
organization_scope: organization,
date_scope: {
as_of_date: "2020-05-31",
period_from: "2020-05-01",
period_to: "2020-05-31"
}
}
} as any);
const response = await service.handleMessage({
session_id: sessionId,
user_message: followupMessage,
useMock: true
} as any);
expect(response.ok).toBe(true);
expect(response.reply_type).toBe("factual");
expect(calls).toHaveLength(1);
expect(calls[0].message).toBe(followupMessage);
expect(calls[0].options?.followupContext?.previous_intent).toBe("inventory_on_hand_as_of_date");
expect(calls[0].options?.followupContext?.previous_anchor_type).toBe("item");
expect(calls[0].options?.followupContext?.previous_anchor_value).toBe(itemLabel);
expect(calls[0].options?.followupContext?.previous_filters?.item).toBe(itemLabel);
expect(calls[0].options?.followupContext?.previous_filters?.organization).toBe(organization);
expect(calls[0].options?.followupContext?.previous_filters?.as_of_date).toBe("2020-05-31");
expect(normalizerService.normalize).not.toHaveBeenCalled();
});
});

View File

@ -34,8 +34,8 @@ describe("assistant address lane runtime adapter", () => {
const runAddressLaneAttempt = vi.fn(async () => factualLane());
const result = await runAssistantAddressLaneRuntime({
userMessage: "сырой вопрос",
addressInputMessage: "нормализованный вопрос",
userMessage: "raw question",
addressInputMessage: "normalized question",
carryover,
shouldPreferContextualLane: true,
canRetryWithRawUserMessage: true,
@ -44,24 +44,24 @@ describe("assistant address lane runtime adapter", () => {
});
expect(result.handled).toBe(true);
expect(result.selection?.messageUsed).toBe("нормализованный вопрос");
expect(result.selection?.messageUsed).toBe("normalized question");
expect(result.selection?.carryMeta).toBe(carryover);
expect(result.retryAudit.attempted).toBe(false);
expect(runAddressLaneAttempt).toHaveBeenCalledTimes(1);
expect(runAddressLaneAttempt).toHaveBeenCalledWith("нормализованный вопрос", carryover);
expect(runAddressLaneAttempt).toHaveBeenCalledWith("normalized question", carryover, null);
});
it("retries with raw message after limited result and returns factual retry", async () => {
const carryover: AssistantAddressFollowupCarryoverLike = { followupContext: { scope: "ctx" } };
const runAddressLaneAttempt = vi
.fn()
.mockResolvedValueOnce(limitedLane("empty_match")) // primary
.mockResolvedValueOnce(limitedLane("empty_match")) // contextual
.mockResolvedValueOnce(factualLane()); // raw contextual retry
.mockResolvedValueOnce(limitedLane("empty_match"))
.mockResolvedValueOnce(limitedLane("empty_match"))
.mockResolvedValueOnce(factualLane());
const result = await runAssistantAddressLaneRuntime({
userMessage: "сырой вопрос",
addressInputMessage: "нормализованный вопрос",
userMessage: "raw question",
addressInputMessage: "normalized question",
carryover,
shouldPreferContextualLane: false,
canRetryWithRawUserMessage: true,
@ -70,7 +70,7 @@ describe("assistant address lane runtime adapter", () => {
});
expect(result.handled).toBe(true);
expect(result.selection?.messageUsed).toBe("сырой вопрос");
expect(result.selection?.messageUsed).toBe("raw question");
expect(result.selection?.carryMeta).toBe(carryover);
expect(result.retryAudit.attempted).toBe(true);
expect(result.retryAudit.reason).toBe("limited_result_retry_with_raw_message");
@ -83,12 +83,12 @@ describe("assistant address lane runtime adapter", () => {
it("returns pending limited result when retry is disabled", async () => {
const runAddressLaneAttempt = vi
.fn()
.mockResolvedValueOnce(limitedLane("missing_anchor")) // primary
.mockResolvedValueOnce(unhandledLane()); // contextual fallback
.mockResolvedValueOnce(limitedLane("missing_anchor"))
.mockResolvedValueOnce(unhandledLane());
const result = await runAssistantAddressLaneRuntime({
userMessage: "сырой вопрос",
addressInputMessage: "нормализованный вопрос",
userMessage: "raw question",
addressInputMessage: "normalized question",
carryover: { followupContext: { scope: "ctx" } },
shouldPreferContextualLane: false,
canRetryWithRawUserMessage: false,
@ -97,9 +97,8 @@ describe("assistant address lane runtime adapter", () => {
});
expect(result.handled).toBe(true);
expect(result.selection?.messageUsed).toBe("нормализованный вопрос");
expect(result.selection?.messageUsed).toBe("normalized question");
expect(result.selection?.addressLane.debug?.limited_reason_category).toBe("missing_anchor");
expect(result.retryAudit.attempted).toBe(false);
});
});

View File

@ -178,6 +178,80 @@ describe("assistant address orchestration runtime adapter", () => {
);
});
it("prefers raw inventory temporal root follow-up over account-balance canonical drift", async () => {
const resolveAddressFollowupCarryoverContext = vi.fn(() => ({
followupContext: {
previous_intent: "inventory_purchase_provenance_for_item",
previous_filters: {
item: "Четки Пост (84*117)",
organization: "ООО \\Альтернатива Плюс\\"
},
previous_anchor_type: "item",
previous_anchor_value: "Четки Пост (84*117)",
root_intent: "inventory_on_hand_as_of_date",
root_filters: {
as_of_date: "2020-03-31",
period_from: "2020-03-01",
period_to: "2020-03-31",
organization: "ООО \\Альтернатива Плюс\\"
},
current_frame_kind: "inventory_drilldown"
}
}));
const resolveAssistantOrchestrationDecision = vi.fn(() => ({
runAddressLane: true,
livingMode: "address_data",
livingReason: "address_lane_triggered",
toolGateDecision: "run_address_lane",
toolGateReason: "followup_context_detected",
orchestrationContract: { schema_version: "assistant_orchestration_contract_v1" }
}));
const buildAddressLlmPredecomposeContractV1 = vi.fn(
({ sourceMessage, canonicalMessage }: { sourceMessage: string; canonicalMessage: string }) => ({
schema_version: "address_llm_predecompose_contract_v1",
source_message: sourceMessage,
canonical_message: canonicalMessage,
mode: "address_query",
intent: canonicalMessage === sourceMessage ? "inventory_on_hand_as_of_date" : "account_balance_snapshot"
})
);
const rawMessage = "остатки на июль 2019";
const output = await buildAssistantAddressOrchestrationRuntime(
buildInput({
userMessage: rawMessage,
runAddressLlmPreDecompose: vi.fn(async () => ({
attempted: true,
applied: true,
effectiveMessage: "проверить остатки по счетам на июль 2019 года",
reason: "normalized_fragment_applied",
predecomposeContract: {
mode: "address_query",
intent: "account_balance_snapshot"
}
})),
buildAddressLlmPredecomposeContractV1,
resolveAddressFollowupCarryoverContext,
resolveAssistantOrchestrationDecision
})
);
expect(output.addressInputMessage).toBe(rawMessage);
expect(output.addressPreDecompose.applied).toBe(false);
expect(output.addressPreDecompose.reason).toBe("followup_raw_message_preferred_over_llm_rewrite");
expect(buildAddressLlmPredecomposeContractV1).toHaveBeenCalledWith({
sourceMessage: rawMessage,
canonicalMessage: rawMessage
});
expect(resolveAddressFollowupCarryoverContext).toHaveBeenCalledTimes(2);
expect(resolveAssistantOrchestrationDecision).toHaveBeenCalledWith(
expect.objectContaining({
rawUserMessage: rawMessage,
effectiveAddressUserMessage: rawMessage
})
);
});
it("prefers raw selected-object inventory action over generic canonical drift intent", async () => {
const resolveAddressFollowupCarryoverContext = vi.fn(() => ({
followupContext: {
@ -441,4 +515,56 @@ describe("assistant address orchestration runtime adapter", () => {
})
);
});
it("prefers raw same-date follow-up over canonical rewrite to current date", async () => {
const resolveAddressFollowupCarryoverContext = vi.fn(() => ({
followupContext: {
previous_intent: "vat_payable_confirmed_as_of_date",
previous_filters: {
as_of_date: "2019-09-30",
period_from: "2019-09-01",
period_to: "2019-09-30"
}
}
}));
const resolveAssistantOrchestrationDecision = vi.fn(() => ({
runAddressLane: true,
livingMode: "address_data",
livingReason: "address_lane_triggered",
toolGateDecision: "run_address_lane",
toolGateReason: "followup_context_detected",
orchestrationContract: { schema_version: "assistant_orchestration_contract_v1" }
}));
const rawMessage = "какие остатки по складу на эту же дату";
const output = await buildAssistantAddressOrchestrationRuntime(
buildInput({
userMessage: rawMessage,
runAddressLlmPreDecompose: vi.fn(async () => ({
attempted: true,
applied: true,
effectiveMessage: "получить остатки по складу на текущую дату",
reason: "normalized_fragment_applied",
predecomposeContract: {
mode: "address_query",
intent: "inventory_on_hand_as_of_date"
}
})),
resolveAddressFollowupCarryoverContext,
resolveAssistantOrchestrationDecision
})
);
expect(output.addressInputMessage).toBe(rawMessage);
expect(output.addressPreDecompose.applied).toBe(false);
expect(output.addressPreDecompose.reason).toBe("followup_raw_message_preferred_over_llm_rewrite");
expect(resolveAddressFollowupCarryoverContext).toHaveBeenCalledTimes(2);
expect(resolveAssistantOrchestrationDecision).toHaveBeenCalledWith(
expect.objectContaining({
rawUserMessage: rawMessage,
effectiveAddressUserMessage: rawMessage
})
);
});
});

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>NDC AI Normalizer Playground</title>
<script type="module" crossorigin src="/assets/index-Qq5WpuqR.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Dcuz1nX5.css">
<script type="module" crossorigin src="/assets/index-VJV2AL7G.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CaUiKcE3.css">
</head>
<body>
<div id="root"></div>

View File

@ -31,7 +31,7 @@ const SESSION_CONFIG_KEY = "ndc_normalizer_session_config_v1";
const AUTORUNS_LAYOUT_CONFIG_KEY = "ndc_autoruns_layout_config_v1";
const AUTORUNS_SAVE_EVENT = "ndc-autoruns-save";
const ASSISTANT_STAGES = ["Анализ запроса", "Получение данных", "Подготовка ответа"];
const DEFAULT_UI_MODE: UiMode = "assistant";
const DEFAULT_UI_MODE: UiMode = "autoruns";
const AUTOLOAD_PROMPT_VERSION = "normalizer_v2_0_2";
const ASSISTANT_PROMPT_VERSION = "address_query_runtime_v1";
const TAB_KEYS: TabKey[] = ["normalized", "fragments", "scope", "flags", "route", "raw", "validation", "logs"];
@ -117,6 +117,8 @@ export default function App() {
const [lastError, setLastError] = useState("");
const [uiMode, setUiMode] = useState<UiMode>(DEFAULT_UI_MODE);
const [showAutorunsSettingsMode, setShowAutorunsSettingsMode] = useState(true);
const [showAutorunsAutoRunsMode, setShowAutorunsAutoRunsMode] = useState(true);
const [showAutorunsAssistantMode, setShowAutorunsAssistantMode] = useState(true);
const [showAutorunsDecompositionMode, setShowAutorunsDecompositionMode] = useState(true);
const [showAutorunsProgressMode, setShowAutorunsProgressMode] = useState(true);
@ -239,6 +241,8 @@ export default function App() {
const parsed = JSON.parse(cachedAutorunsLayout) as {
uiMode?: UiMode;
activeTab?: TabKey;
showAutorunsSettingsMode?: boolean;
showAutorunsAutoRunsMode?: boolean;
showAutorunsAssistantMode?: boolean;
showAutorunsDecompositionMode?: boolean;
showAutorunsProgressMode?: boolean;
@ -257,12 +261,20 @@ export default function App() {
showDecompositionRuntimeMode?: boolean;
prompts?: PromptState;
};
if (parsed.uiMode === "assistant" || parsed.uiMode === "decomposition" || parsed.uiMode === "autoruns") {
setUiMode(parsed.uiMode);
if (parsed.uiMode === "decomposition") {
setUiMode("decomposition");
} else if (parsed.uiMode === "assistant" || parsed.uiMode === "autoruns") {
setUiMode("autoruns");
}
if (parsed.activeTab && TAB_KEYS.includes(parsed.activeTab)) {
setActiveTab(parsed.activeTab);
}
if (typeof parsed.showAutorunsSettingsMode === "boolean") {
setShowAutorunsSettingsMode(parsed.showAutorunsSettingsMode);
}
if (typeof parsed.showAutorunsAutoRunsMode === "boolean") {
setShowAutorunsAutoRunsMode(parsed.showAutorunsAutoRunsMode);
}
if (typeof parsed.showAutorunsAssistantMode === "boolean") {
setShowAutorunsAssistantMode(parsed.showAutorunsAssistantMode);
}
@ -430,6 +442,8 @@ export default function App() {
JSON.stringify({
uiMode,
activeTab,
showAutorunsSettingsMode,
showAutorunsAutoRunsMode,
showAutorunsAssistantMode,
showAutorunsDecompositionMode,
showAutorunsProgressMode,
@ -946,15 +960,12 @@ export default function App() {
>
<header className="app-topbar">
<div className="mode-switch-row">
<button type="button" className={uiMode === "assistant" ? "tab active" : "tab"} onClick={() => setUiMode("assistant")}>
Ассистент
<button type="button" className={uiMode === "autoruns" ? "tab active" : "tab"} onClick={() => setUiMode("autoruns")}>
Управление ассистентом
</button>
<button type="button" className={uiMode === "decomposition" ? "tab active" : "tab"} onClick={() => setUiMode("decomposition")}>
Декомпозиция
</button>
<button type="button" className={uiMode === "autoruns" ? "tab active" : "tab"} onClick={() => setUiMode("autoruns")}>
История автопрогонов
</button>
<button type="button" className="tab" onClick={saveAutorunsLayout}>
Сохранить
</button>
@ -1036,6 +1047,20 @@ export default function App() {
</div>
) : uiMode === "autoruns" ? (
<div className="mode-switch-row mode-switch-row-right">
<button
type="button"
className={showAutorunsSettingsMode ? "tab active" : "tab"}
onClick={() => setShowAutorunsSettingsMode((prev) => !prev)}
>
Настройки
</button>
<button
type="button"
className={showAutorunsAutoRunsMode ? "tab active" : "tab"}
onClick={() => setShowAutorunsAutoRunsMode((prev) => !prev)}
>
Автопрогоны
</button>
<button
type="button"
className={showAutorunsAssistantMode ? "tab active" : "tab"}
@ -1300,9 +1325,30 @@ export default function App() {
<div className="layout-grid layout-grid-autoruns">
<AutoRunsHistoryPanel
connection={connection}
modelOptions={modelOptions}
modelsBusy={modelsBusy}
connectionStatus={connectionStatus}
connectionBusy={busy}
onConnectionChange={setConnection}
onReloadModels={reloadModels}
onSaveLocalConfig={saveLocalConfig}
onTestConnection={testConnection}
prompts={prompts}
onPromptsChange={setPrompts}
promptPresets={presetList}
selectedPresetId={selectedPresetId}
onSelectPreset={setSelectedPresetId}
onLoadPreset={loadSelectedPreset}
onSavePreset={savePreset}
onResetDefaults={resetDefaults}
onDiffPrevious={diffWithPrevious}
presetName={presetName}
onPresetNameChange={setPresetName}
diffSummary={diffSummary}
assistantPromptVersion={ASSISTANT_PROMPT_VERSION}
decompositionPromptVersion={AUTOLOAD_PROMPT_VERSION}
showSettingsMode={showAutorunsSettingsMode}
showAutoRunsMode={showAutorunsAutoRunsMode}
showAssistantMode={showAutorunsAssistantMode}
showDecompositionMode={showAutorunsDecompositionMode}
showProgressMode={showAutorunsProgressMode}

View File

@ -22,14 +22,46 @@ import type {
PromptState
} from "../state/types";
import { AssistantPanel } from "./AssistantPanel";
import { ConnectionPanel } from "./ConnectionPanel";
import { JsonView } from "./JsonView";
import { PanelFrame } from "./PanelFrame";
import { PromptPanel } from "./PromptPanel";
interface AutoRunsHistoryPanelProps {
connection: ConnectionState;
modelOptions: string[];
modelsBusy: boolean;
connectionStatus: string;
connectionBusy: boolean;
onConnectionChange: (next: ConnectionState) => void;
onReloadModels: () => Promise<void> | void;
onSaveLocalConfig: () => void;
onTestConnection: () => Promise<void> | void;
prompts: PromptState;
onPromptsChange: (next: PromptState) => void;
promptPresets: Array<{
id: string;
name: string;
prompt_version: string;
systemPrompt: string;
developerPrompt: string;
domainPrompt: string;
schemaNotes?: string;
fewShotExamples?: string;
}>;
selectedPresetId: string;
onSelectPreset: (id: string) => void;
onLoadPreset: () => void;
onSavePreset: () => void;
onResetDefaults: () => void;
onDiffPrevious: () => void;
presetName: string;
onPresetNameChange: (name: string) => void;
diffSummary: string;
assistantPromptVersion: string;
decompositionPromptVersion: string;
showSettingsMode: boolean;
showAutoRunsMode: boolean;
showAssistantMode: boolean;
showDecompositionMode: boolean;
showProgressMode: boolean;
@ -470,9 +502,30 @@ function CopyOutlineIcon() {
export function AutoRunsHistoryPanel({
connection,
modelOptions,
modelsBusy,
connectionStatus,
connectionBusy,
onConnectionChange,
onReloadModels,
onSaveLocalConfig,
onTestConnection,
prompts,
onPromptsChange,
promptPresets,
selectedPresetId,
onSelectPreset,
onLoadPreset,
onSavePreset,
onResetDefaults,
onDiffPrevious,
presetName,
onPresetNameChange,
diffSummary,
assistantPromptVersion,
decompositionPromptVersion,
showSettingsMode,
showAutoRunsMode,
showAssistantMode,
showDecompositionMode,
showProgressMode,
@ -1726,9 +1779,47 @@ export function AutoRunsHistoryPanel({
hideHeader
>
<div className="autoruns-columns">
<section className="autoruns-col">
{showSettingsMode ? (
<section className="autoruns-col autoruns-settings-col">
<div className="autoruns-col-header">
<h3>Настройки</h3>
</div>
<div className="autoruns-settings-stack">
<ConnectionPanel
embedded
value={connection}
modelOptions={modelOptions}
modelsBusy={modelsBusy}
onChange={onConnectionChange}
onReloadModels={onReloadModels}
onSaveLocalConfig={onSaveLocalConfig}
onTestConnection={onTestConnection}
lastStatus={connectionStatus}
busy={connectionBusy}
/>
<PromptPanel
embedded
value={prompts}
onChange={onPromptsChange}
presets={promptPresets}
selectedPresetId={selectedPresetId}
onSelectPreset={onSelectPreset}
onLoadPreset={onLoadPreset}
onSavePreset={onSavePreset}
onResetDefaults={onResetDefaults}
onDiffPrevious={onDiffPrevious}
presetName={presetName}
onPresetNameChange={onPresetNameChange}
diffSummary={diffSummary}
/>
</div>
</section>
) : null}
{showAutoRunsMode ? (
<section className="autoruns-col">
<div className="autoruns-col-header">
<h3>Настройки</h3>
<h3>Автопрогоны</h3>
</div>
<h4>Настройки выборки</h4>
@ -2086,6 +2177,7 @@ export function AutoRunsHistoryPanel({
{errorText ? <p className="error-text">{errorText}</p> : null}
</section>
) : null}
<section className="autoruns-col">
<div className="autoruns-col-header">

View File

@ -63,6 +63,7 @@ interface ConnectionPanelProps {
onSaveLocalConfig: () => void;
lastStatus: string;
busy: boolean;
embedded?: boolean;
}
export function ConnectionPanel({
@ -74,7 +75,8 @@ export function ConnectionPanel({
onTestConnection,
onSaveLocalConfig,
lastStatus,
busy
busy,
embedded = false
}: ConnectionPanelProps) {
const isLocal = value.llmProvider === "local";
const providerPreset = deriveProviderPreset(value);
@ -121,12 +123,8 @@ export function ConnectionPanel({
setMaxOutputTokensInput(String(parsed));
};
return (
<PanelFrame
title="LLM Connection"
subtitle="Switch between OpenAI cloud and local OpenAI-compatible server."
actions={<span className="status-chip">{lastStatus || "Status: not checked"}</span>}
>
const content = (
<>
<div className="grid-two">
<label>
Provider
@ -269,6 +267,31 @@ export function ConnectionPanel({
{busy ? "Checking..." : "Test connection"}
</button>
</div>
</>
);
if (embedded) {
return (
<section className="embedded-panel-section">
<div className="embedded-panel-section-header">
<div>
<h4>LLM Connector</h4>
<p>Switch between OpenAI cloud and local OpenAI-compatible server.</p>
</div>
<span className="status-chip">{lastStatus || "Status: not checked"}</span>
</div>
{content}
</section>
);
}
return (
<PanelFrame
title="LLM Connector"
subtitle="Switch between OpenAI cloud and local OpenAI-compatible server."
actions={<span className="status-chip">{lastStatus || "Status: not checked"}</span>}
>
{content}
</PanelFrame>
);
}

View File

@ -25,6 +25,7 @@ interface PromptPanelProps {
presetName: string;
onPresetNameChange: (name: string) => void;
diffSummary: string;
embedded?: boolean;
}
export function PromptPanel({
@ -39,10 +40,11 @@ export function PromptPanel({
onDiffPrevious,
presetName,
onPresetNameChange,
diffSummary
diffSummary,
embedded = false
}: PromptPanelProps) {
return (
<PanelFrame title="Prompt Manager" subtitle="Системный, developer и domain уровни управляются отдельно.">
const content = (
<>
<div className="prompt-manager-grid">
<label>
Системный prompt
@ -101,6 +103,26 @@ export function PromptPanel({
</button>
</div>
{diffSummary ? <p className="diff-summary">{diffSummary}</p> : null}
</>
);
if (embedded) {
return (
<section className="embedded-panel-section">
<div className="embedded-panel-section-header">
<div>
<h4>Prompt Manager</h4>
<p>Системный, developer и domain уровни управляются отдельно.</p>
</div>
</div>
{content}
</section>
);
}
return (
<PanelFrame title="Prompt Manager" subtitle="Системный, developer и domain уровни управляются отдельно.">
{content}
</PanelFrame>
);
}

View File

@ -966,6 +966,44 @@ button:disabled {
scrollbar-gutter: stable;
}
.autoruns-settings-col {
display: flex;
flex-direction: column;
}
.autoruns-settings-stack {
display: grid;
gap: 12px;
}
.embedded-panel-section {
display: grid;
gap: 12px;
border-radius: 12px;
background: rgb(var(--rgb-surface-horizontal));
padding: 12px;
}
.embedded-panel-section-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.embedded-panel-section-header h4 {
margin: 0;
color: var(--text-main);
font-size: 0.92rem;
}
.embedded-panel-section-header p {
margin: 6px 0 0;
color: var(--text-muted);
font-size: 0.78rem;
line-height: 1.4;
}
.autoruns-assistant-live-col {
background: rgb(var(--rgb-surface-main));
padding: 12px;

File diff suppressed because it is too large Load Diff